Spaces:
Sleeping
Sleeping
Upload 553 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- extensions/bluebubbles/index.ts +19 -0
- extensions/bluebubbles/openclaw.plugin.json +9 -0
- extensions/bluebubbles/package.json +36 -0
- extensions/bluebubbles/src/accounts.ts +88 -0
- extensions/bluebubbles/src/actions.test.ts +650 -0
- extensions/bluebubbles/src/actions.ts +438 -0
- extensions/bluebubbles/src/attachments.test.ts +345 -0
- extensions/bluebubbles/src/attachments.ts +300 -0
- extensions/bluebubbles/src/channel.ts +414 -0
- extensions/bluebubbles/src/chat.test.ts +461 -0
- extensions/bluebubbles/src/chat.ts +378 -0
- extensions/bluebubbles/src/config-schema.ts +51 -0
- extensions/bluebubbles/src/media-send.ts +174 -0
- extensions/bluebubbles/src/monitor.test.ts +2340 -0
- extensions/bluebubbles/src/monitor.ts +2469 -0
- extensions/bluebubbles/src/onboarding.ts +352 -0
- extensions/bluebubbles/src/probe.ts +135 -0
- extensions/bluebubbles/src/reactions.test.ts +392 -0
- extensions/bluebubbles/src/reactions.ts +188 -0
- extensions/bluebubbles/src/runtime.ts +14 -0
- extensions/bluebubbles/src/send.test.ts +808 -0
- extensions/bluebubbles/src/send.ts +467 -0
- extensions/bluebubbles/src/targets.test.ts +183 -0
- extensions/bluebubbles/src/targets.ts +422 -0
- extensions/bluebubbles/src/types.ts +127 -0
- extensions/copilot-proxy/README.md +24 -0
- extensions/copilot-proxy/index.ts +148 -0
- extensions/copilot-proxy/openclaw.plugin.json +9 -0
- extensions/copilot-proxy/package.json +14 -0
- extensions/diagnostics-otel/index.ts +15 -0
- extensions/diagnostics-otel/openclaw.plugin.json +8 -0
- extensions/diagnostics-otel/package.json +27 -0
- extensions/diagnostics-otel/src/service.test.ts +226 -0
- extensions/diagnostics-otel/src/service.ts +635 -0
- extensions/discord/index.ts +17 -0
- extensions/discord/openclaw.plugin.json +9 -0
- extensions/discord/package.json +14 -0
- extensions/discord/src/channel.ts +422 -0
- extensions/discord/src/runtime.ts +14 -0
- extensions/google-antigravity-auth/README.md +24 -0
- extensions/google-antigravity-auth/index.ts +461 -0
- extensions/google-antigravity-auth/openclaw.plugin.json +9 -0
- extensions/google-antigravity-auth/package.json +14 -0
- extensions/google-gemini-cli-auth/README.md +35 -0
- extensions/google-gemini-cli-auth/index.ts +88 -0
- extensions/google-gemini-cli-auth/oauth.test.ts +240 -0
- extensions/google-gemini-cli-auth/oauth.ts +662 -0
- extensions/google-gemini-cli-auth/openclaw.plugin.json +9 -0
- extensions/google-gemini-cli-auth/package.json +14 -0
- extensions/googlechat/index.ts +19 -0
extensions/bluebubbles/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
| 2 |
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
| 3 |
+
import { bluebubblesPlugin } from "./src/channel.js";
|
| 4 |
+
import { handleBlueBubblesWebhookRequest } from "./src/monitor.js";
|
| 5 |
+
import { setBlueBubblesRuntime } from "./src/runtime.js";
|
| 6 |
+
|
| 7 |
+
const plugin = {
|
| 8 |
+
id: "bluebubbles",
|
| 9 |
+
name: "BlueBubbles",
|
| 10 |
+
description: "BlueBubbles channel plugin (macOS app)",
|
| 11 |
+
configSchema: emptyPluginConfigSchema(),
|
| 12 |
+
register(api: OpenClawPluginApi) {
|
| 13 |
+
setBlueBubblesRuntime(api.runtime);
|
| 14 |
+
api.registerChannel({ plugin: bluebubblesPlugin });
|
| 15 |
+
api.registerHttpHandler(handleBlueBubblesWebhookRequest);
|
| 16 |
+
},
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
export default plugin;
|
extensions/bluebubbles/openclaw.plugin.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "bluebubbles",
|
| 3 |
+
"channels": ["bluebubbles"],
|
| 4 |
+
"configSchema": {
|
| 5 |
+
"type": "object",
|
| 6 |
+
"additionalProperties": false,
|
| 7 |
+
"properties": {}
|
| 8 |
+
}
|
| 9 |
+
}
|
extensions/bluebubbles/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "@openclaw/bluebubbles",
|
| 3 |
+
"version": "2026.1.30",
|
| 4 |
+
"description": "OpenClaw BlueBubbles channel plugin",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"devDependencies": {
|
| 7 |
+
"openclaw": "workspace:*"
|
| 8 |
+
},
|
| 9 |
+
"openclaw": {
|
| 10 |
+
"extensions": [
|
| 11 |
+
"./index.ts"
|
| 12 |
+
],
|
| 13 |
+
"channel": {
|
| 14 |
+
"id": "bluebubbles",
|
| 15 |
+
"label": "BlueBubbles",
|
| 16 |
+
"selectionLabel": "BlueBubbles (macOS app)",
|
| 17 |
+
"detailLabel": "BlueBubbles",
|
| 18 |
+
"docsPath": "/channels/bluebubbles",
|
| 19 |
+
"docsLabel": "bluebubbles",
|
| 20 |
+
"blurb": "iMessage via the BlueBubbles mac app + REST API.",
|
| 21 |
+
"aliases": [
|
| 22 |
+
"bb"
|
| 23 |
+
],
|
| 24 |
+
"preferOver": [
|
| 25 |
+
"imessage"
|
| 26 |
+
],
|
| 27 |
+
"systemImage": "bubble.left.and.text.bubble.right",
|
| 28 |
+
"order": 75
|
| 29 |
+
},
|
| 30 |
+
"install": {
|
| 31 |
+
"npmSpec": "@openclaw/bluebubbles",
|
| 32 |
+
"localPath": "extensions/bluebubbles",
|
| 33 |
+
"defaultChoice": "npm"
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
}
|
extensions/bluebubbles/src/accounts.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
| 2 |
+
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
|
| 3 |
+
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
|
| 4 |
+
|
| 5 |
+
export type ResolvedBlueBubblesAccount = {
|
| 6 |
+
accountId: string;
|
| 7 |
+
enabled: boolean;
|
| 8 |
+
name?: string;
|
| 9 |
+
config: BlueBubblesAccountConfig;
|
| 10 |
+
configured: boolean;
|
| 11 |
+
baseUrl?: string;
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
|
| 15 |
+
const accounts = cfg.channels?.bluebubbles?.accounts;
|
| 16 |
+
if (!accounts || typeof accounts !== "object") {
|
| 17 |
+
return [];
|
| 18 |
+
}
|
| 19 |
+
return Object.keys(accounts).filter(Boolean);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export function listBlueBubblesAccountIds(cfg: OpenClawConfig): string[] {
|
| 23 |
+
const ids = listConfiguredAccountIds(cfg);
|
| 24 |
+
if (ids.length === 0) {
|
| 25 |
+
return [DEFAULT_ACCOUNT_ID];
|
| 26 |
+
}
|
| 27 |
+
return ids.toSorted((a, b) => a.localeCompare(b));
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
export function resolveDefaultBlueBubblesAccountId(cfg: OpenClawConfig): string {
|
| 31 |
+
const ids = listBlueBubblesAccountIds(cfg);
|
| 32 |
+
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
| 33 |
+
return DEFAULT_ACCOUNT_ID;
|
| 34 |
+
}
|
| 35 |
+
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function resolveAccountConfig(
|
| 39 |
+
cfg: OpenClawConfig,
|
| 40 |
+
accountId: string,
|
| 41 |
+
): BlueBubblesAccountConfig | undefined {
|
| 42 |
+
const accounts = cfg.channels?.bluebubbles?.accounts;
|
| 43 |
+
if (!accounts || typeof accounts !== "object") {
|
| 44 |
+
return undefined;
|
| 45 |
+
}
|
| 46 |
+
return accounts[accountId] as BlueBubblesAccountConfig | undefined;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
function mergeBlueBubblesAccountConfig(
|
| 50 |
+
cfg: OpenClawConfig,
|
| 51 |
+
accountId: string,
|
| 52 |
+
): BlueBubblesAccountConfig {
|
| 53 |
+
const base = (cfg.channels?.bluebubbles ?? {}) as BlueBubblesAccountConfig & {
|
| 54 |
+
accounts?: unknown;
|
| 55 |
+
};
|
| 56 |
+
const { accounts: _ignored, ...rest } = base;
|
| 57 |
+
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
| 58 |
+
const chunkMode = account.chunkMode ?? rest.chunkMode ?? "length";
|
| 59 |
+
return { ...rest, ...account, chunkMode };
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
export function resolveBlueBubblesAccount(params: {
|
| 63 |
+
cfg: OpenClawConfig;
|
| 64 |
+
accountId?: string | null;
|
| 65 |
+
}): ResolvedBlueBubblesAccount {
|
| 66 |
+
const accountId = normalizeAccountId(params.accountId);
|
| 67 |
+
const baseEnabled = params.cfg.channels?.bluebubbles?.enabled;
|
| 68 |
+
const merged = mergeBlueBubblesAccountConfig(params.cfg, accountId);
|
| 69 |
+
const accountEnabled = merged.enabled !== false;
|
| 70 |
+
const serverUrl = merged.serverUrl?.trim();
|
| 71 |
+
const password = merged.password?.trim();
|
| 72 |
+
const configured = Boolean(serverUrl && password);
|
| 73 |
+
const baseUrl = serverUrl ? normalizeBlueBubblesServerUrl(serverUrl) : undefined;
|
| 74 |
+
return {
|
| 75 |
+
accountId,
|
| 76 |
+
enabled: baseEnabled !== false && accountEnabled,
|
| 77 |
+
name: merged.name?.trim() || undefined,
|
| 78 |
+
config: merged,
|
| 79 |
+
configured,
|
| 80 |
+
baseUrl,
|
| 81 |
+
};
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
export function listEnabledBlueBubblesAccounts(cfg: OpenClawConfig): ResolvedBlueBubblesAccount[] {
|
| 85 |
+
return listBlueBubblesAccountIds(cfg)
|
| 86 |
+
.map((accountId) => resolveBlueBubblesAccount({ cfg, accountId }))
|
| 87 |
+
.filter((account) => account.enabled);
|
| 88 |
+
}
|
extensions/bluebubbles/src/actions.test.ts
ADDED
|
@@ -0,0 +1,650 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
| 2 |
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
| 3 |
+
import { bluebubblesMessageActions } from "./actions.js";
|
| 4 |
+
|
| 5 |
+
vi.mock("./accounts.js", () => ({
|
| 6 |
+
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
|
| 7 |
+
const config = cfg?.channels?.bluebubbles ?? {};
|
| 8 |
+
return {
|
| 9 |
+
accountId: accountId ?? "default",
|
| 10 |
+
enabled: config.enabled !== false,
|
| 11 |
+
configured: Boolean(config.serverUrl && config.password),
|
| 12 |
+
config,
|
| 13 |
+
};
|
| 14 |
+
}),
|
| 15 |
+
}));
|
| 16 |
+
|
| 17 |
+
vi.mock("./reactions.js", () => ({
|
| 18 |
+
sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined),
|
| 19 |
+
}));
|
| 20 |
+
|
| 21 |
+
vi.mock("./send.js", () => ({
|
| 22 |
+
resolveChatGuidForTarget: vi.fn().mockResolvedValue("iMessage;-;+15551234567"),
|
| 23 |
+
sendMessageBlueBubbles: vi.fn().mockResolvedValue({ messageId: "msg-123" }),
|
| 24 |
+
}));
|
| 25 |
+
|
| 26 |
+
vi.mock("./chat.js", () => ({
|
| 27 |
+
editBlueBubblesMessage: vi.fn().mockResolvedValue(undefined),
|
| 28 |
+
unsendBlueBubblesMessage: vi.fn().mockResolvedValue(undefined),
|
| 29 |
+
renameBlueBubblesChat: vi.fn().mockResolvedValue(undefined),
|
| 30 |
+
setGroupIconBlueBubbles: vi.fn().mockResolvedValue(undefined),
|
| 31 |
+
addBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined),
|
| 32 |
+
removeBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined),
|
| 33 |
+
leaveBlueBubblesChat: vi.fn().mockResolvedValue(undefined),
|
| 34 |
+
}));
|
| 35 |
+
|
| 36 |
+
vi.mock("./attachments.js", () => ({
|
| 37 |
+
sendBlueBubblesAttachment: vi.fn().mockResolvedValue({ messageId: "att-msg-123" }),
|
| 38 |
+
}));
|
| 39 |
+
|
| 40 |
+
vi.mock("./monitor.js", () => ({
|
| 41 |
+
resolveBlueBubblesMessageId: vi.fn((id: string) => id),
|
| 42 |
+
}));
|
| 43 |
+
|
| 44 |
+
describe("bluebubblesMessageActions", () => {
|
| 45 |
+
beforeEach(() => {
|
| 46 |
+
vi.clearAllMocks();
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
describe("listActions", () => {
|
| 50 |
+
it("returns empty array when account is not enabled", () => {
|
| 51 |
+
const cfg: OpenClawConfig = {
|
| 52 |
+
channels: { bluebubbles: { enabled: false } },
|
| 53 |
+
};
|
| 54 |
+
const actions = bluebubblesMessageActions.listActions({ cfg });
|
| 55 |
+
expect(actions).toEqual([]);
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
it("returns empty array when account is not configured", () => {
|
| 59 |
+
const cfg: OpenClawConfig = {
|
| 60 |
+
channels: { bluebubbles: { enabled: true } },
|
| 61 |
+
};
|
| 62 |
+
const actions = bluebubblesMessageActions.listActions({ cfg });
|
| 63 |
+
expect(actions).toEqual([]);
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
it("returns react action when enabled and configured", () => {
|
| 67 |
+
const cfg: OpenClawConfig = {
|
| 68 |
+
channels: {
|
| 69 |
+
bluebubbles: {
|
| 70 |
+
enabled: true,
|
| 71 |
+
serverUrl: "http://localhost:1234",
|
| 72 |
+
password: "test-password",
|
| 73 |
+
},
|
| 74 |
+
},
|
| 75 |
+
};
|
| 76 |
+
const actions = bluebubblesMessageActions.listActions({ cfg });
|
| 77 |
+
expect(actions).toContain("react");
|
| 78 |
+
});
|
| 79 |
+
|
| 80 |
+
it("excludes react action when reactions are gated off", () => {
|
| 81 |
+
const cfg: OpenClawConfig = {
|
| 82 |
+
channels: {
|
| 83 |
+
bluebubbles: {
|
| 84 |
+
enabled: true,
|
| 85 |
+
serverUrl: "http://localhost:1234",
|
| 86 |
+
password: "test-password",
|
| 87 |
+
actions: { reactions: false },
|
| 88 |
+
},
|
| 89 |
+
},
|
| 90 |
+
};
|
| 91 |
+
const actions = bluebubblesMessageActions.listActions({ cfg });
|
| 92 |
+
expect(actions).not.toContain("react");
|
| 93 |
+
// Other actions should still be present
|
| 94 |
+
expect(actions).toContain("edit");
|
| 95 |
+
expect(actions).toContain("unsend");
|
| 96 |
+
});
|
| 97 |
+
});
|
| 98 |
+
|
| 99 |
+
describe("supportsAction", () => {
|
| 100 |
+
it("returns true for react action", () => {
|
| 101 |
+
expect(bluebubblesMessageActions.supportsAction({ action: "react" })).toBe(true);
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
+
it("returns true for all supported actions", () => {
|
| 105 |
+
expect(bluebubblesMessageActions.supportsAction({ action: "edit" })).toBe(true);
|
| 106 |
+
expect(bluebubblesMessageActions.supportsAction({ action: "unsend" })).toBe(true);
|
| 107 |
+
expect(bluebubblesMessageActions.supportsAction({ action: "reply" })).toBe(true);
|
| 108 |
+
expect(bluebubblesMessageActions.supportsAction({ action: "sendWithEffect" })).toBe(true);
|
| 109 |
+
expect(bluebubblesMessageActions.supportsAction({ action: "renameGroup" })).toBe(true);
|
| 110 |
+
expect(bluebubblesMessageActions.supportsAction({ action: "setGroupIcon" })).toBe(true);
|
| 111 |
+
expect(bluebubblesMessageActions.supportsAction({ action: "addParticipant" })).toBe(true);
|
| 112 |
+
expect(bluebubblesMessageActions.supportsAction({ action: "removeParticipant" })).toBe(true);
|
| 113 |
+
expect(bluebubblesMessageActions.supportsAction({ action: "leaveGroup" })).toBe(true);
|
| 114 |
+
expect(bluebubblesMessageActions.supportsAction({ action: "sendAttachment" })).toBe(true);
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
+
it("returns false for unsupported actions", () => {
|
| 118 |
+
expect(bluebubblesMessageActions.supportsAction({ action: "delete" })).toBe(false);
|
| 119 |
+
expect(bluebubblesMessageActions.supportsAction({ action: "unknown" })).toBe(false);
|
| 120 |
+
});
|
| 121 |
+
});
|
| 122 |
+
|
| 123 |
+
describe("extractToolSend", () => {
|
| 124 |
+
it("extracts send params from sendMessage action", () => {
|
| 125 |
+
const result = bluebubblesMessageActions.extractToolSend({
|
| 126 |
+
args: {
|
| 127 |
+
action: "sendMessage",
|
| 128 |
+
to: "+15551234567",
|
| 129 |
+
accountId: "test-account",
|
| 130 |
+
},
|
| 131 |
+
});
|
| 132 |
+
expect(result).toEqual({
|
| 133 |
+
to: "+15551234567",
|
| 134 |
+
accountId: "test-account",
|
| 135 |
+
});
|
| 136 |
+
});
|
| 137 |
+
|
| 138 |
+
it("returns null for non-sendMessage action", () => {
|
| 139 |
+
const result = bluebubblesMessageActions.extractToolSend({
|
| 140 |
+
args: { action: "react", to: "+15551234567" },
|
| 141 |
+
});
|
| 142 |
+
expect(result).toBeNull();
|
| 143 |
+
});
|
| 144 |
+
|
| 145 |
+
it("returns null when to is missing", () => {
|
| 146 |
+
const result = bluebubblesMessageActions.extractToolSend({
|
| 147 |
+
args: { action: "sendMessage" },
|
| 148 |
+
});
|
| 149 |
+
expect(result).toBeNull();
|
| 150 |
+
});
|
| 151 |
+
});
|
| 152 |
+
|
| 153 |
+
describe("handleAction", () => {
|
| 154 |
+
it("throws for unsupported actions", async () => {
|
| 155 |
+
const cfg: OpenClawConfig = {
|
| 156 |
+
channels: {
|
| 157 |
+
bluebubbles: {
|
| 158 |
+
serverUrl: "http://localhost:1234",
|
| 159 |
+
password: "test-password",
|
| 160 |
+
},
|
| 161 |
+
},
|
| 162 |
+
};
|
| 163 |
+
await expect(
|
| 164 |
+
bluebubblesMessageActions.handleAction({
|
| 165 |
+
action: "unknownAction",
|
| 166 |
+
params: {},
|
| 167 |
+
cfg,
|
| 168 |
+
accountId: null,
|
| 169 |
+
}),
|
| 170 |
+
).rejects.toThrow("is not supported");
|
| 171 |
+
});
|
| 172 |
+
|
| 173 |
+
it("throws when emoji is missing for react action", async () => {
|
| 174 |
+
const cfg: OpenClawConfig = {
|
| 175 |
+
channels: {
|
| 176 |
+
bluebubbles: {
|
| 177 |
+
serverUrl: "http://localhost:1234",
|
| 178 |
+
password: "test-password",
|
| 179 |
+
},
|
| 180 |
+
},
|
| 181 |
+
};
|
| 182 |
+
await expect(
|
| 183 |
+
bluebubblesMessageActions.handleAction({
|
| 184 |
+
action: "react",
|
| 185 |
+
params: { messageId: "msg-123" },
|
| 186 |
+
cfg,
|
| 187 |
+
accountId: null,
|
| 188 |
+
}),
|
| 189 |
+
).rejects.toThrow(/emoji/i);
|
| 190 |
+
});
|
| 191 |
+
|
| 192 |
+
it("throws when messageId is missing", async () => {
|
| 193 |
+
const cfg: OpenClawConfig = {
|
| 194 |
+
channels: {
|
| 195 |
+
bluebubbles: {
|
| 196 |
+
serverUrl: "http://localhost:1234",
|
| 197 |
+
password: "test-password",
|
| 198 |
+
},
|
| 199 |
+
},
|
| 200 |
+
};
|
| 201 |
+
await expect(
|
| 202 |
+
bluebubblesMessageActions.handleAction({
|
| 203 |
+
action: "react",
|
| 204 |
+
params: { emoji: "❤️" },
|
| 205 |
+
cfg,
|
| 206 |
+
accountId: null,
|
| 207 |
+
}),
|
| 208 |
+
).rejects.toThrow("messageId");
|
| 209 |
+
});
|
| 210 |
+
|
| 211 |
+
it("throws when chatGuid cannot be resolved", async () => {
|
| 212 |
+
const { resolveChatGuidForTarget } = await import("./send.js");
|
| 213 |
+
vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce(null);
|
| 214 |
+
|
| 215 |
+
const cfg: OpenClawConfig = {
|
| 216 |
+
channels: {
|
| 217 |
+
bluebubbles: {
|
| 218 |
+
serverUrl: "http://localhost:1234",
|
| 219 |
+
password: "test-password",
|
| 220 |
+
},
|
| 221 |
+
},
|
| 222 |
+
};
|
| 223 |
+
await expect(
|
| 224 |
+
bluebubblesMessageActions.handleAction({
|
| 225 |
+
action: "react",
|
| 226 |
+
params: { emoji: "❤️", messageId: "msg-123", to: "+15551234567" },
|
| 227 |
+
cfg,
|
| 228 |
+
accountId: null,
|
| 229 |
+
}),
|
| 230 |
+
).rejects.toThrow("chatGuid not found");
|
| 231 |
+
});
|
| 232 |
+
|
| 233 |
+
it("sends reaction successfully with chatGuid", async () => {
|
| 234 |
+
const { sendBlueBubblesReaction } = await import("./reactions.js");
|
| 235 |
+
|
| 236 |
+
const cfg: OpenClawConfig = {
|
| 237 |
+
channels: {
|
| 238 |
+
bluebubbles: {
|
| 239 |
+
serverUrl: "http://localhost:1234",
|
| 240 |
+
password: "test-password",
|
| 241 |
+
},
|
| 242 |
+
},
|
| 243 |
+
};
|
| 244 |
+
const result = await bluebubblesMessageActions.handleAction({
|
| 245 |
+
action: "react",
|
| 246 |
+
params: {
|
| 247 |
+
emoji: "❤️",
|
| 248 |
+
messageId: "msg-123",
|
| 249 |
+
chatGuid: "iMessage;-;+15551234567",
|
| 250 |
+
},
|
| 251 |
+
cfg,
|
| 252 |
+
accountId: null,
|
| 253 |
+
});
|
| 254 |
+
|
| 255 |
+
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
|
| 256 |
+
expect.objectContaining({
|
| 257 |
+
chatGuid: "iMessage;-;+15551234567",
|
| 258 |
+
messageGuid: "msg-123",
|
| 259 |
+
emoji: "❤️",
|
| 260 |
+
}),
|
| 261 |
+
);
|
| 262 |
+
// jsonResult returns { content: [...], details: payload }
|
| 263 |
+
expect(result).toMatchObject({
|
| 264 |
+
details: { ok: true, added: "❤️" },
|
| 265 |
+
});
|
| 266 |
+
});
|
| 267 |
+
|
| 268 |
+
it("sends reaction removal successfully", async () => {
|
| 269 |
+
const { sendBlueBubblesReaction } = await import("./reactions.js");
|
| 270 |
+
|
| 271 |
+
const cfg: OpenClawConfig = {
|
| 272 |
+
channels: {
|
| 273 |
+
bluebubbles: {
|
| 274 |
+
serverUrl: "http://localhost:1234",
|
| 275 |
+
password: "test-password",
|
| 276 |
+
},
|
| 277 |
+
},
|
| 278 |
+
};
|
| 279 |
+
const result = await bluebubblesMessageActions.handleAction({
|
| 280 |
+
action: "react",
|
| 281 |
+
params: {
|
| 282 |
+
emoji: "❤️",
|
| 283 |
+
messageId: "msg-123",
|
| 284 |
+
chatGuid: "iMessage;-;+15551234567",
|
| 285 |
+
remove: true,
|
| 286 |
+
},
|
| 287 |
+
cfg,
|
| 288 |
+
accountId: null,
|
| 289 |
+
});
|
| 290 |
+
|
| 291 |
+
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
|
| 292 |
+
expect.objectContaining({
|
| 293 |
+
remove: true,
|
| 294 |
+
}),
|
| 295 |
+
);
|
| 296 |
+
// jsonResult returns { content: [...], details: payload }
|
| 297 |
+
expect(result).toMatchObject({
|
| 298 |
+
details: { ok: true, removed: true },
|
| 299 |
+
});
|
| 300 |
+
});
|
| 301 |
+
|
| 302 |
+
it("resolves chatGuid from to parameter", async () => {
|
| 303 |
+
const { sendBlueBubblesReaction } = await import("./reactions.js");
|
| 304 |
+
const { resolveChatGuidForTarget } = await import("./send.js");
|
| 305 |
+
vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15559876543");
|
| 306 |
+
|
| 307 |
+
const cfg: OpenClawConfig = {
|
| 308 |
+
channels: {
|
| 309 |
+
bluebubbles: {
|
| 310 |
+
serverUrl: "http://localhost:1234",
|
| 311 |
+
password: "test-password",
|
| 312 |
+
},
|
| 313 |
+
},
|
| 314 |
+
};
|
| 315 |
+
await bluebubblesMessageActions.handleAction({
|
| 316 |
+
action: "react",
|
| 317 |
+
params: {
|
| 318 |
+
emoji: "👍",
|
| 319 |
+
messageId: "msg-456",
|
| 320 |
+
to: "+15559876543",
|
| 321 |
+
},
|
| 322 |
+
cfg,
|
| 323 |
+
accountId: null,
|
| 324 |
+
});
|
| 325 |
+
|
| 326 |
+
expect(resolveChatGuidForTarget).toHaveBeenCalled();
|
| 327 |
+
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
|
| 328 |
+
expect.objectContaining({
|
| 329 |
+
chatGuid: "iMessage;-;+15559876543",
|
| 330 |
+
}),
|
| 331 |
+
);
|
| 332 |
+
});
|
| 333 |
+
|
| 334 |
+
it("passes partIndex when provided", async () => {
|
| 335 |
+
const { sendBlueBubblesReaction } = await import("./reactions.js");
|
| 336 |
+
|
| 337 |
+
const cfg: OpenClawConfig = {
|
| 338 |
+
channels: {
|
| 339 |
+
bluebubbles: {
|
| 340 |
+
serverUrl: "http://localhost:1234",
|
| 341 |
+
password: "test-password",
|
| 342 |
+
},
|
| 343 |
+
},
|
| 344 |
+
};
|
| 345 |
+
await bluebubblesMessageActions.handleAction({
|
| 346 |
+
action: "react",
|
| 347 |
+
params: {
|
| 348 |
+
emoji: "😂",
|
| 349 |
+
messageId: "msg-789",
|
| 350 |
+
chatGuid: "iMessage;-;chat-guid",
|
| 351 |
+
partIndex: 2,
|
| 352 |
+
},
|
| 353 |
+
cfg,
|
| 354 |
+
accountId: null,
|
| 355 |
+
});
|
| 356 |
+
|
| 357 |
+
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
|
| 358 |
+
expect.objectContaining({
|
| 359 |
+
partIndex: 2,
|
| 360 |
+
}),
|
| 361 |
+
);
|
| 362 |
+
});
|
| 363 |
+
|
| 364 |
+
it("uses toolContext currentChannelId when no explicit target is provided", async () => {
|
| 365 |
+
const { sendBlueBubblesReaction } = await import("./reactions.js");
|
| 366 |
+
const { resolveChatGuidForTarget } = await import("./send.js");
|
| 367 |
+
vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15550001111");
|
| 368 |
+
|
| 369 |
+
const cfg: OpenClawConfig = {
|
| 370 |
+
channels: {
|
| 371 |
+
bluebubbles: {
|
| 372 |
+
serverUrl: "http://localhost:1234",
|
| 373 |
+
password: "test-password",
|
| 374 |
+
},
|
| 375 |
+
},
|
| 376 |
+
};
|
| 377 |
+
await bluebubblesMessageActions.handleAction({
|
| 378 |
+
action: "react",
|
| 379 |
+
params: {
|
| 380 |
+
emoji: "👍",
|
| 381 |
+
messageId: "msg-456",
|
| 382 |
+
},
|
| 383 |
+
cfg,
|
| 384 |
+
accountId: null,
|
| 385 |
+
toolContext: {
|
| 386 |
+
currentChannelId: "bluebubbles:chat_guid:iMessage;-;+15550001111",
|
| 387 |
+
},
|
| 388 |
+
});
|
| 389 |
+
|
| 390 |
+
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
|
| 391 |
+
expect.objectContaining({
|
| 392 |
+
target: { kind: "chat_guid", chatGuid: "iMessage;-;+15550001111" },
|
| 393 |
+
}),
|
| 394 |
+
);
|
| 395 |
+
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
|
| 396 |
+
expect.objectContaining({
|
| 397 |
+
chatGuid: "iMessage;-;+15550001111",
|
| 398 |
+
}),
|
| 399 |
+
);
|
| 400 |
+
});
|
| 401 |
+
|
| 402 |
+
it("resolves short messageId before reacting", async () => {
|
| 403 |
+
const { resolveBlueBubblesMessageId } = await import("./monitor.js");
|
| 404 |
+
const { sendBlueBubblesReaction } = await import("./reactions.js");
|
| 405 |
+
vi.mocked(resolveBlueBubblesMessageId).mockReturnValueOnce("resolved-uuid");
|
| 406 |
+
|
| 407 |
+
const cfg: OpenClawConfig = {
|
| 408 |
+
channels: {
|
| 409 |
+
bluebubbles: {
|
| 410 |
+
serverUrl: "http://localhost:1234",
|
| 411 |
+
password: "test-password",
|
| 412 |
+
},
|
| 413 |
+
},
|
| 414 |
+
};
|
| 415 |
+
|
| 416 |
+
await bluebubblesMessageActions.handleAction({
|
| 417 |
+
action: "react",
|
| 418 |
+
params: {
|
| 419 |
+
emoji: "❤️",
|
| 420 |
+
messageId: "1",
|
| 421 |
+
chatGuid: "iMessage;-;+15551234567",
|
| 422 |
+
},
|
| 423 |
+
cfg,
|
| 424 |
+
accountId: null,
|
| 425 |
+
});
|
| 426 |
+
|
| 427 |
+
expect(resolveBlueBubblesMessageId).toHaveBeenCalledWith("1", { requireKnownShortId: true });
|
| 428 |
+
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
|
| 429 |
+
expect.objectContaining({
|
| 430 |
+
messageGuid: "resolved-uuid",
|
| 431 |
+
}),
|
| 432 |
+
);
|
| 433 |
+
});
|
| 434 |
+
|
| 435 |
+
it("propagates short-id errors from the resolver", async () => {
|
| 436 |
+
const { resolveBlueBubblesMessageId } = await import("./monitor.js");
|
| 437 |
+
vi.mocked(resolveBlueBubblesMessageId).mockImplementationOnce(() => {
|
| 438 |
+
throw new Error("short id expired");
|
| 439 |
+
});
|
| 440 |
+
|
| 441 |
+
const cfg: OpenClawConfig = {
|
| 442 |
+
channels: {
|
| 443 |
+
bluebubbles: {
|
| 444 |
+
serverUrl: "http://localhost:1234",
|
| 445 |
+
password: "test-password",
|
| 446 |
+
},
|
| 447 |
+
},
|
| 448 |
+
};
|
| 449 |
+
|
| 450 |
+
await expect(
|
| 451 |
+
bluebubblesMessageActions.handleAction({
|
| 452 |
+
action: "react",
|
| 453 |
+
params: {
|
| 454 |
+
emoji: "❤️",
|
| 455 |
+
messageId: "999",
|
| 456 |
+
chatGuid: "iMessage;-;+15551234567",
|
| 457 |
+
},
|
| 458 |
+
cfg,
|
| 459 |
+
accountId: null,
|
| 460 |
+
}),
|
| 461 |
+
).rejects.toThrow("short id expired");
|
| 462 |
+
});
|
| 463 |
+
|
| 464 |
+
it("accepts message param for edit action", async () => {
|
| 465 |
+
const { editBlueBubblesMessage } = await import("./chat.js");
|
| 466 |
+
|
| 467 |
+
const cfg: OpenClawConfig = {
|
| 468 |
+
channels: {
|
| 469 |
+
bluebubbles: {
|
| 470 |
+
serverUrl: "http://localhost:1234",
|
| 471 |
+
password: "test-password",
|
| 472 |
+
},
|
| 473 |
+
},
|
| 474 |
+
};
|
| 475 |
+
|
| 476 |
+
await bluebubblesMessageActions.handleAction({
|
| 477 |
+
action: "edit",
|
| 478 |
+
params: { messageId: "msg-123", message: "updated" },
|
| 479 |
+
cfg,
|
| 480 |
+
accountId: null,
|
| 481 |
+
});
|
| 482 |
+
|
| 483 |
+
expect(editBlueBubblesMessage).toHaveBeenCalledWith(
|
| 484 |
+
"msg-123",
|
| 485 |
+
"updated",
|
| 486 |
+
expect.objectContaining({ cfg, accountId: undefined }),
|
| 487 |
+
);
|
| 488 |
+
});
|
| 489 |
+
|
| 490 |
+
it("accepts message/target aliases for sendWithEffect", async () => {
|
| 491 |
+
const { sendMessageBlueBubbles } = await import("./send.js");
|
| 492 |
+
|
| 493 |
+
const cfg: OpenClawConfig = {
|
| 494 |
+
channels: {
|
| 495 |
+
bluebubbles: {
|
| 496 |
+
serverUrl: "http://localhost:1234",
|
| 497 |
+
password: "test-password",
|
| 498 |
+
},
|
| 499 |
+
},
|
| 500 |
+
};
|
| 501 |
+
|
| 502 |
+
const result = await bluebubblesMessageActions.handleAction({
|
| 503 |
+
action: "sendWithEffect",
|
| 504 |
+
params: {
|
| 505 |
+
message: "peekaboo",
|
| 506 |
+
target: "+15551234567",
|
| 507 |
+
effect: "invisible ink",
|
| 508 |
+
},
|
| 509 |
+
cfg,
|
| 510 |
+
accountId: null,
|
| 511 |
+
});
|
| 512 |
+
|
| 513 |
+
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
|
| 514 |
+
"+15551234567",
|
| 515 |
+
"peekaboo",
|
| 516 |
+
expect.objectContaining({ effectId: "invisible ink" }),
|
| 517 |
+
);
|
| 518 |
+
expect(result).toMatchObject({
|
| 519 |
+
details: { ok: true, messageId: "msg-123", effect: "invisible ink" },
|
| 520 |
+
});
|
| 521 |
+
});
|
| 522 |
+
|
| 523 |
+
it("passes asVoice through sendAttachment", async () => {
|
| 524 |
+
const { sendBlueBubblesAttachment } = await import("./attachments.js");
|
| 525 |
+
|
| 526 |
+
const cfg: OpenClawConfig = {
|
| 527 |
+
channels: {
|
| 528 |
+
bluebubbles: {
|
| 529 |
+
serverUrl: "http://localhost:1234",
|
| 530 |
+
password: "test-password",
|
| 531 |
+
},
|
| 532 |
+
},
|
| 533 |
+
};
|
| 534 |
+
|
| 535 |
+
const base64Buffer = Buffer.from("voice").toString("base64");
|
| 536 |
+
|
| 537 |
+
await bluebubblesMessageActions.handleAction({
|
| 538 |
+
action: "sendAttachment",
|
| 539 |
+
params: {
|
| 540 |
+
to: "+15551234567",
|
| 541 |
+
filename: "voice.mp3",
|
| 542 |
+
buffer: base64Buffer,
|
| 543 |
+
contentType: "audio/mpeg",
|
| 544 |
+
asVoice: true,
|
| 545 |
+
},
|
| 546 |
+
cfg,
|
| 547 |
+
accountId: null,
|
| 548 |
+
});
|
| 549 |
+
|
| 550 |
+
expect(sendBlueBubblesAttachment).toHaveBeenCalledWith(
|
| 551 |
+
expect.objectContaining({
|
| 552 |
+
filename: "voice.mp3",
|
| 553 |
+
contentType: "audio/mpeg",
|
| 554 |
+
asVoice: true,
|
| 555 |
+
}),
|
| 556 |
+
);
|
| 557 |
+
});
|
| 558 |
+
|
| 559 |
+
it("throws when buffer is missing for setGroupIcon", async () => {
|
| 560 |
+
const cfg: OpenClawConfig = {
|
| 561 |
+
channels: {
|
| 562 |
+
bluebubbles: {
|
| 563 |
+
serverUrl: "http://localhost:1234",
|
| 564 |
+
password: "test-password",
|
| 565 |
+
},
|
| 566 |
+
},
|
| 567 |
+
};
|
| 568 |
+
|
| 569 |
+
await expect(
|
| 570 |
+
bluebubblesMessageActions.handleAction({
|
| 571 |
+
action: "setGroupIcon",
|
| 572 |
+
params: { chatGuid: "iMessage;-;chat-guid" },
|
| 573 |
+
cfg,
|
| 574 |
+
accountId: null,
|
| 575 |
+
}),
|
| 576 |
+
).rejects.toThrow(/requires an image/i);
|
| 577 |
+
});
|
| 578 |
+
|
| 579 |
+
it("sets group icon successfully with chatGuid and buffer", async () => {
|
| 580 |
+
const { setGroupIconBlueBubbles } = await import("./chat.js");
|
| 581 |
+
|
| 582 |
+
const cfg: OpenClawConfig = {
|
| 583 |
+
channels: {
|
| 584 |
+
bluebubbles: {
|
| 585 |
+
serverUrl: "http://localhost:1234",
|
| 586 |
+
password: "test-password",
|
| 587 |
+
},
|
| 588 |
+
},
|
| 589 |
+
};
|
| 590 |
+
|
| 591 |
+
// Base64 encode a simple test buffer
|
| 592 |
+
const testBuffer = Buffer.from("fake-image-data");
|
| 593 |
+
const base64Buffer = testBuffer.toString("base64");
|
| 594 |
+
|
| 595 |
+
const result = await bluebubblesMessageActions.handleAction({
|
| 596 |
+
action: "setGroupIcon",
|
| 597 |
+
params: {
|
| 598 |
+
chatGuid: "iMessage;-;chat-guid",
|
| 599 |
+
buffer: base64Buffer,
|
| 600 |
+
filename: "group-icon.png",
|
| 601 |
+
contentType: "image/png",
|
| 602 |
+
},
|
| 603 |
+
cfg,
|
| 604 |
+
accountId: null,
|
| 605 |
+
});
|
| 606 |
+
|
| 607 |
+
expect(setGroupIconBlueBubbles).toHaveBeenCalledWith(
|
| 608 |
+
"iMessage;-;chat-guid",
|
| 609 |
+
expect.any(Uint8Array),
|
| 610 |
+
"group-icon.png",
|
| 611 |
+
expect.objectContaining({ contentType: "image/png" }),
|
| 612 |
+
);
|
| 613 |
+
expect(result).toMatchObject({
|
| 614 |
+
details: { ok: true, chatGuid: "iMessage;-;chat-guid", iconSet: true },
|
| 615 |
+
});
|
| 616 |
+
});
|
| 617 |
+
|
| 618 |
+
it("uses default filename when not provided for setGroupIcon", async () => {
|
| 619 |
+
const { setGroupIconBlueBubbles } = await import("./chat.js");
|
| 620 |
+
|
| 621 |
+
const cfg: OpenClawConfig = {
|
| 622 |
+
channels: {
|
| 623 |
+
bluebubbles: {
|
| 624 |
+
serverUrl: "http://localhost:1234",
|
| 625 |
+
password: "test-password",
|
| 626 |
+
},
|
| 627 |
+
},
|
| 628 |
+
};
|
| 629 |
+
|
| 630 |
+
const base64Buffer = Buffer.from("test").toString("base64");
|
| 631 |
+
|
| 632 |
+
await bluebubblesMessageActions.handleAction({
|
| 633 |
+
action: "setGroupIcon",
|
| 634 |
+
params: {
|
| 635 |
+
chatGuid: "iMessage;-;chat-guid",
|
| 636 |
+
buffer: base64Buffer,
|
| 637 |
+
},
|
| 638 |
+
cfg,
|
| 639 |
+
accountId: null,
|
| 640 |
+
});
|
| 641 |
+
|
| 642 |
+
expect(setGroupIconBlueBubbles).toHaveBeenCalledWith(
|
| 643 |
+
"iMessage;-;chat-guid",
|
| 644 |
+
expect.any(Uint8Array),
|
| 645 |
+
"icon.png",
|
| 646 |
+
expect.anything(),
|
| 647 |
+
);
|
| 648 |
+
});
|
| 649 |
+
});
|
| 650 |
+
});
|
extensions/bluebubbles/src/actions.ts
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
BLUEBUBBLES_ACTION_NAMES,
|
| 3 |
+
BLUEBUBBLES_ACTIONS,
|
| 4 |
+
createActionGate,
|
| 5 |
+
jsonResult,
|
| 6 |
+
readNumberParam,
|
| 7 |
+
readReactionParams,
|
| 8 |
+
readStringParam,
|
| 9 |
+
type ChannelMessageActionAdapter,
|
| 10 |
+
type ChannelMessageActionName,
|
| 11 |
+
type ChannelToolSend,
|
| 12 |
+
} from "openclaw/plugin-sdk";
|
| 13 |
+
import type { BlueBubblesSendTarget } from "./types.js";
|
| 14 |
+
import { resolveBlueBubblesAccount } from "./accounts.js";
|
| 15 |
+
import { sendBlueBubblesAttachment } from "./attachments.js";
|
| 16 |
+
import {
|
| 17 |
+
editBlueBubblesMessage,
|
| 18 |
+
unsendBlueBubblesMessage,
|
| 19 |
+
renameBlueBubblesChat,
|
| 20 |
+
setGroupIconBlueBubbles,
|
| 21 |
+
addBlueBubblesParticipant,
|
| 22 |
+
removeBlueBubblesParticipant,
|
| 23 |
+
leaveBlueBubblesChat,
|
| 24 |
+
} from "./chat.js";
|
| 25 |
+
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
| 26 |
+
import { isMacOS26OrHigher } from "./probe.js";
|
| 27 |
+
import { sendBlueBubblesReaction } from "./reactions.js";
|
| 28 |
+
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
| 29 |
+
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
|
| 30 |
+
|
| 31 |
+
const providerId = "bluebubbles";
|
| 32 |
+
|
| 33 |
+
function mapTarget(raw: string): BlueBubblesSendTarget {
|
| 34 |
+
const parsed = parseBlueBubblesTarget(raw);
|
| 35 |
+
if (parsed.kind === "chat_guid") {
|
| 36 |
+
return { kind: "chat_guid", chatGuid: parsed.chatGuid };
|
| 37 |
+
}
|
| 38 |
+
if (parsed.kind === "chat_id") {
|
| 39 |
+
return { kind: "chat_id", chatId: parsed.chatId };
|
| 40 |
+
}
|
| 41 |
+
if (parsed.kind === "chat_identifier") {
|
| 42 |
+
return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
|
| 43 |
+
}
|
| 44 |
+
return {
|
| 45 |
+
kind: "handle",
|
| 46 |
+
address: normalizeBlueBubblesHandle(parsed.to),
|
| 47 |
+
service: parsed.service,
|
| 48 |
+
};
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function readMessageText(params: Record<string, unknown>): string | undefined {
|
| 52 |
+
return readStringParam(params, "text") ?? readStringParam(params, "message");
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function readBooleanParam(params: Record<string, unknown>, key: string): boolean | undefined {
|
| 56 |
+
const raw = params[key];
|
| 57 |
+
if (typeof raw === "boolean") {
|
| 58 |
+
return raw;
|
| 59 |
+
}
|
| 60 |
+
if (typeof raw === "string") {
|
| 61 |
+
const trimmed = raw.trim().toLowerCase();
|
| 62 |
+
if (trimmed === "true") {
|
| 63 |
+
return true;
|
| 64 |
+
}
|
| 65 |
+
if (trimmed === "false") {
|
| 66 |
+
return false;
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
return undefined;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/** Supported action names for BlueBubbles */
|
| 73 |
+
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
|
| 74 |
+
|
| 75 |
+
export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
| 76 |
+
listActions: ({ cfg }) => {
|
| 77 |
+
const account = resolveBlueBubblesAccount({ cfg: cfg });
|
| 78 |
+
if (!account.enabled || !account.configured) {
|
| 79 |
+
return [];
|
| 80 |
+
}
|
| 81 |
+
const gate = createActionGate(cfg.channels?.bluebubbles?.actions);
|
| 82 |
+
const actions = new Set<ChannelMessageActionName>();
|
| 83 |
+
const macOS26 = isMacOS26OrHigher(account.accountId);
|
| 84 |
+
for (const action of BLUEBUBBLES_ACTION_NAMES) {
|
| 85 |
+
const spec = BLUEBUBBLES_ACTIONS[action];
|
| 86 |
+
if (!spec?.gate) {
|
| 87 |
+
continue;
|
| 88 |
+
}
|
| 89 |
+
if (spec.unsupportedOnMacOS26 && macOS26) {
|
| 90 |
+
continue;
|
| 91 |
+
}
|
| 92 |
+
if (gate(spec.gate)) {
|
| 93 |
+
actions.add(action);
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
return Array.from(actions);
|
| 97 |
+
},
|
| 98 |
+
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
|
| 99 |
+
extractToolSend: ({ args }): ChannelToolSend | null => {
|
| 100 |
+
const action = typeof args.action === "string" ? args.action.trim() : "";
|
| 101 |
+
if (action !== "sendMessage") {
|
| 102 |
+
return null;
|
| 103 |
+
}
|
| 104 |
+
const to = typeof args.to === "string" ? args.to : undefined;
|
| 105 |
+
if (!to) {
|
| 106 |
+
return null;
|
| 107 |
+
}
|
| 108 |
+
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
|
| 109 |
+
return { to, accountId };
|
| 110 |
+
},
|
| 111 |
+
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
|
| 112 |
+
const account = resolveBlueBubblesAccount({
|
| 113 |
+
cfg: cfg,
|
| 114 |
+
accountId: accountId ?? undefined,
|
| 115 |
+
});
|
| 116 |
+
const baseUrl = account.config.serverUrl?.trim();
|
| 117 |
+
const password = account.config.password?.trim();
|
| 118 |
+
const opts = { cfg: cfg, accountId: accountId ?? undefined };
|
| 119 |
+
|
| 120 |
+
// Helper to resolve chatGuid from various params or session context
|
| 121 |
+
const resolveChatGuid = async (): Promise<string> => {
|
| 122 |
+
const chatGuid = readStringParam(params, "chatGuid");
|
| 123 |
+
if (chatGuid?.trim()) {
|
| 124 |
+
return chatGuid.trim();
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
const chatIdentifier = readStringParam(params, "chatIdentifier");
|
| 128 |
+
const chatId = readNumberParam(params, "chatId", { integer: true });
|
| 129 |
+
const to = readStringParam(params, "to");
|
| 130 |
+
// Fall back to session context if no explicit target provided
|
| 131 |
+
const contextTarget = toolContext?.currentChannelId?.trim();
|
| 132 |
+
|
| 133 |
+
const target = chatIdentifier?.trim()
|
| 134 |
+
? ({
|
| 135 |
+
kind: "chat_identifier",
|
| 136 |
+
chatIdentifier: chatIdentifier.trim(),
|
| 137 |
+
} as BlueBubblesSendTarget)
|
| 138 |
+
: typeof chatId === "number"
|
| 139 |
+
? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget)
|
| 140 |
+
: to
|
| 141 |
+
? mapTarget(to)
|
| 142 |
+
: contextTarget
|
| 143 |
+
? mapTarget(contextTarget)
|
| 144 |
+
: null;
|
| 145 |
+
|
| 146 |
+
if (!target) {
|
| 147 |
+
throw new Error(`BlueBubbles ${action} requires chatGuid, chatIdentifier, chatId, or to.`);
|
| 148 |
+
}
|
| 149 |
+
if (!baseUrl || !password) {
|
| 150 |
+
throw new Error(`BlueBubbles ${action} requires serverUrl and password.`);
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
const resolved = await resolveChatGuidForTarget({ baseUrl, password, target });
|
| 154 |
+
if (!resolved) {
|
| 155 |
+
throw new Error(`BlueBubbles ${action} failed: chatGuid not found for target.`);
|
| 156 |
+
}
|
| 157 |
+
return resolved;
|
| 158 |
+
};
|
| 159 |
+
|
| 160 |
+
// Handle react action
|
| 161 |
+
if (action === "react") {
|
| 162 |
+
const { emoji, remove, isEmpty } = readReactionParams(params, {
|
| 163 |
+
removeErrorMessage: "Emoji is required to remove a BlueBubbles reaction.",
|
| 164 |
+
});
|
| 165 |
+
if (isEmpty && !remove) {
|
| 166 |
+
throw new Error(
|
| 167 |
+
"BlueBubbles react requires emoji parameter. Use action=react with emoji=<emoji> and messageId=<message_id>.",
|
| 168 |
+
);
|
| 169 |
+
}
|
| 170 |
+
const rawMessageId = readStringParam(params, "messageId");
|
| 171 |
+
if (!rawMessageId) {
|
| 172 |
+
throw new Error(
|
| 173 |
+
"BlueBubbles react requires messageId parameter (the message ID to react to). " +
|
| 174 |
+
"Use action=react with messageId=<message_id>, emoji=<emoji>, and to/chatGuid to identify the chat.",
|
| 175 |
+
);
|
| 176 |
+
}
|
| 177 |
+
// Resolve short ID (e.g., "1", "2") to full UUID
|
| 178 |
+
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
| 179 |
+
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
| 180 |
+
const resolvedChatGuid = await resolveChatGuid();
|
| 181 |
+
|
| 182 |
+
await sendBlueBubblesReaction({
|
| 183 |
+
chatGuid: resolvedChatGuid,
|
| 184 |
+
messageGuid: messageId,
|
| 185 |
+
emoji,
|
| 186 |
+
remove: remove || undefined,
|
| 187 |
+
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
| 188 |
+
opts,
|
| 189 |
+
});
|
| 190 |
+
|
| 191 |
+
return jsonResult({ ok: true, ...(remove ? { removed: true } : { added: emoji }) });
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
// Handle edit action
|
| 195 |
+
if (action === "edit") {
|
| 196 |
+
// Edit is not supported on macOS 26+
|
| 197 |
+
if (isMacOS26OrHigher(accountId ?? undefined)) {
|
| 198 |
+
throw new Error(
|
| 199 |
+
"BlueBubbles edit is not supported on macOS 26 or higher. " +
|
| 200 |
+
"Apple removed the ability to edit iMessages in this version.",
|
| 201 |
+
);
|
| 202 |
+
}
|
| 203 |
+
const rawMessageId = readStringParam(params, "messageId");
|
| 204 |
+
const newText =
|
| 205 |
+
readStringParam(params, "text") ??
|
| 206 |
+
readStringParam(params, "newText") ??
|
| 207 |
+
readStringParam(params, "message");
|
| 208 |
+
if (!rawMessageId || !newText) {
|
| 209 |
+
const missing: string[] = [];
|
| 210 |
+
if (!rawMessageId) {
|
| 211 |
+
missing.push("messageId (the message ID to edit)");
|
| 212 |
+
}
|
| 213 |
+
if (!newText) {
|
| 214 |
+
missing.push("text (the new message content)");
|
| 215 |
+
}
|
| 216 |
+
throw new Error(
|
| 217 |
+
`BlueBubbles edit requires: ${missing.join(", ")}. ` +
|
| 218 |
+
`Use action=edit with messageId=<message_id>, text=<new_content>.`,
|
| 219 |
+
);
|
| 220 |
+
}
|
| 221 |
+
// Resolve short ID (e.g., "1", "2") to full UUID
|
| 222 |
+
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
| 223 |
+
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
| 224 |
+
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
|
| 225 |
+
|
| 226 |
+
await editBlueBubblesMessage(messageId, newText, {
|
| 227 |
+
...opts,
|
| 228 |
+
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
| 229 |
+
backwardsCompatMessage: backwardsCompatMessage ?? undefined,
|
| 230 |
+
});
|
| 231 |
+
|
| 232 |
+
return jsonResult({ ok: true, edited: rawMessageId });
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
// Handle unsend action
|
| 236 |
+
if (action === "unsend") {
|
| 237 |
+
const rawMessageId = readStringParam(params, "messageId");
|
| 238 |
+
if (!rawMessageId) {
|
| 239 |
+
throw new Error(
|
| 240 |
+
"BlueBubbles unsend requires messageId parameter (the message ID to unsend). " +
|
| 241 |
+
"Use action=unsend with messageId=<message_id>.",
|
| 242 |
+
);
|
| 243 |
+
}
|
| 244 |
+
// Resolve short ID (e.g., "1", "2") to full UUID
|
| 245 |
+
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
| 246 |
+
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
| 247 |
+
|
| 248 |
+
await unsendBlueBubblesMessage(messageId, {
|
| 249 |
+
...opts,
|
| 250 |
+
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
| 251 |
+
});
|
| 252 |
+
|
| 253 |
+
return jsonResult({ ok: true, unsent: rawMessageId });
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
// Handle reply action
|
| 257 |
+
if (action === "reply") {
|
| 258 |
+
const rawMessageId = readStringParam(params, "messageId");
|
| 259 |
+
const text = readMessageText(params);
|
| 260 |
+
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
|
| 261 |
+
if (!rawMessageId || !text || !to) {
|
| 262 |
+
const missing: string[] = [];
|
| 263 |
+
if (!rawMessageId) {
|
| 264 |
+
missing.push("messageId (the message ID to reply to)");
|
| 265 |
+
}
|
| 266 |
+
if (!text) {
|
| 267 |
+
missing.push("text or message (the reply message content)");
|
| 268 |
+
}
|
| 269 |
+
if (!to) {
|
| 270 |
+
missing.push("to or target (the chat target)");
|
| 271 |
+
}
|
| 272 |
+
throw new Error(
|
| 273 |
+
`BlueBubbles reply requires: ${missing.join(", ")}. ` +
|
| 274 |
+
`Use action=reply with messageId=<message_id>, message=<your reply>, target=<chat_target>.`,
|
| 275 |
+
);
|
| 276 |
+
}
|
| 277 |
+
// Resolve short ID (e.g., "1", "2") to full UUID
|
| 278 |
+
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
| 279 |
+
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
| 280 |
+
|
| 281 |
+
const result = await sendMessageBlueBubbles(to, text, {
|
| 282 |
+
...opts,
|
| 283 |
+
replyToMessageGuid: messageId,
|
| 284 |
+
replyToPartIndex: typeof partIndex === "number" ? partIndex : undefined,
|
| 285 |
+
});
|
| 286 |
+
|
| 287 |
+
return jsonResult({ ok: true, messageId: result.messageId, repliedTo: rawMessageId });
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
// Handle sendWithEffect action
|
| 291 |
+
if (action === "sendWithEffect") {
|
| 292 |
+
const text = readMessageText(params);
|
| 293 |
+
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
|
| 294 |
+
const effectId = readStringParam(params, "effectId") ?? readStringParam(params, "effect");
|
| 295 |
+
if (!text || !to || !effectId) {
|
| 296 |
+
const missing: string[] = [];
|
| 297 |
+
if (!text) {
|
| 298 |
+
missing.push("text or message (the message content)");
|
| 299 |
+
}
|
| 300 |
+
if (!to) {
|
| 301 |
+
missing.push("to or target (the chat target)");
|
| 302 |
+
}
|
| 303 |
+
if (!effectId) {
|
| 304 |
+
missing.push(
|
| 305 |
+
"effectId or effect (e.g., slam, loud, gentle, invisible-ink, confetti, lasers, fireworks, balloons, heart)",
|
| 306 |
+
);
|
| 307 |
+
}
|
| 308 |
+
throw new Error(
|
| 309 |
+
`BlueBubbles sendWithEffect requires: ${missing.join(", ")}. ` +
|
| 310 |
+
`Use action=sendWithEffect with message=<message>, target=<chat_target>, effectId=<effect_name>.`,
|
| 311 |
+
);
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
const result = await sendMessageBlueBubbles(to, text, {
|
| 315 |
+
...opts,
|
| 316 |
+
effectId,
|
| 317 |
+
});
|
| 318 |
+
|
| 319 |
+
return jsonResult({ ok: true, messageId: result.messageId, effect: effectId });
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
// Handle renameGroup action
|
| 323 |
+
if (action === "renameGroup") {
|
| 324 |
+
const resolvedChatGuid = await resolveChatGuid();
|
| 325 |
+
const displayName = readStringParam(params, "displayName") ?? readStringParam(params, "name");
|
| 326 |
+
if (!displayName) {
|
| 327 |
+
throw new Error("BlueBubbles renameGroup requires displayName or name parameter.");
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
await renameBlueBubblesChat(resolvedChatGuid, displayName, opts);
|
| 331 |
+
|
| 332 |
+
return jsonResult({ ok: true, renamed: resolvedChatGuid, displayName });
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
// Handle setGroupIcon action
|
| 336 |
+
if (action === "setGroupIcon") {
|
| 337 |
+
const resolvedChatGuid = await resolveChatGuid();
|
| 338 |
+
const base64Buffer = readStringParam(params, "buffer");
|
| 339 |
+
const filename =
|
| 340 |
+
readStringParam(params, "filename") ?? readStringParam(params, "name") ?? "icon.png";
|
| 341 |
+
const contentType =
|
| 342 |
+
readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
|
| 343 |
+
|
| 344 |
+
if (!base64Buffer) {
|
| 345 |
+
throw new Error(
|
| 346 |
+
"BlueBubbles setGroupIcon requires an image. " +
|
| 347 |
+
"Use action=setGroupIcon with media=<image_url> or path=<local_file_path> to set the group icon.",
|
| 348 |
+
);
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
// Decode base64 to buffer
|
| 352 |
+
const buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0));
|
| 353 |
+
|
| 354 |
+
await setGroupIconBlueBubbles(resolvedChatGuid, buffer, filename, {
|
| 355 |
+
...opts,
|
| 356 |
+
contentType: contentType ?? undefined,
|
| 357 |
+
});
|
| 358 |
+
|
| 359 |
+
return jsonResult({ ok: true, chatGuid: resolvedChatGuid, iconSet: true });
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
// Handle addParticipant action
|
| 363 |
+
if (action === "addParticipant") {
|
| 364 |
+
const resolvedChatGuid = await resolveChatGuid();
|
| 365 |
+
const address = readStringParam(params, "address") ?? readStringParam(params, "participant");
|
| 366 |
+
if (!address) {
|
| 367 |
+
throw new Error("BlueBubbles addParticipant requires address or participant parameter.");
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
await addBlueBubblesParticipant(resolvedChatGuid, address, opts);
|
| 371 |
+
|
| 372 |
+
return jsonResult({ ok: true, added: address, chatGuid: resolvedChatGuid });
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
// Handle removeParticipant action
|
| 376 |
+
if (action === "removeParticipant") {
|
| 377 |
+
const resolvedChatGuid = await resolveChatGuid();
|
| 378 |
+
const address = readStringParam(params, "address") ?? readStringParam(params, "participant");
|
| 379 |
+
if (!address) {
|
| 380 |
+
throw new Error("BlueBubbles removeParticipant requires address or participant parameter.");
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
await removeBlueBubblesParticipant(resolvedChatGuid, address, opts);
|
| 384 |
+
|
| 385 |
+
return jsonResult({ ok: true, removed: address, chatGuid: resolvedChatGuid });
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
// Handle leaveGroup action
|
| 389 |
+
if (action === "leaveGroup") {
|
| 390 |
+
const resolvedChatGuid = await resolveChatGuid();
|
| 391 |
+
|
| 392 |
+
await leaveBlueBubblesChat(resolvedChatGuid, opts);
|
| 393 |
+
|
| 394 |
+
return jsonResult({ ok: true, left: resolvedChatGuid });
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
// Handle sendAttachment action
|
| 398 |
+
if (action === "sendAttachment") {
|
| 399 |
+
const to = readStringParam(params, "to", { required: true });
|
| 400 |
+
const filename = readStringParam(params, "filename", { required: true });
|
| 401 |
+
const caption = readStringParam(params, "caption");
|
| 402 |
+
const contentType =
|
| 403 |
+
readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
|
| 404 |
+
const asVoice = readBooleanParam(params, "asVoice");
|
| 405 |
+
|
| 406 |
+
// Buffer can come from params.buffer (base64) or params.path (file path)
|
| 407 |
+
const base64Buffer = readStringParam(params, "buffer");
|
| 408 |
+
const filePath = readStringParam(params, "path") ?? readStringParam(params, "filePath");
|
| 409 |
+
|
| 410 |
+
let buffer: Uint8Array;
|
| 411 |
+
if (base64Buffer) {
|
| 412 |
+
// Decode base64 to buffer
|
| 413 |
+
buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0));
|
| 414 |
+
} else if (filePath) {
|
| 415 |
+
// Read file from path (will be handled by caller providing buffer)
|
| 416 |
+
throw new Error(
|
| 417 |
+
"BlueBubbles sendAttachment: filePath not supported in action, provide buffer as base64.",
|
| 418 |
+
);
|
| 419 |
+
} else {
|
| 420 |
+
throw new Error("BlueBubbles sendAttachment requires buffer (base64) parameter.");
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
const result = await sendBlueBubblesAttachment({
|
| 424 |
+
to,
|
| 425 |
+
buffer,
|
| 426 |
+
filename,
|
| 427 |
+
contentType: contentType ?? undefined,
|
| 428 |
+
caption: caption ?? undefined,
|
| 429 |
+
asVoice: asVoice ?? undefined,
|
| 430 |
+
opts,
|
| 431 |
+
});
|
| 432 |
+
|
| 433 |
+
return jsonResult({ ok: true, messageId: result.messageId });
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
|
| 437 |
+
},
|
| 438 |
+
};
|
extensions/bluebubbles/src/attachments.test.ts
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
| 2 |
+
import type { BlueBubblesAttachment } from "./types.js";
|
| 3 |
+
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
|
| 4 |
+
|
| 5 |
+
vi.mock("./accounts.js", () => ({
|
| 6 |
+
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
|
| 7 |
+
const config = cfg?.channels?.bluebubbles ?? {};
|
| 8 |
+
return {
|
| 9 |
+
accountId: accountId ?? "default",
|
| 10 |
+
enabled: config.enabled !== false,
|
| 11 |
+
configured: Boolean(config.serverUrl && config.password),
|
| 12 |
+
config,
|
| 13 |
+
};
|
| 14 |
+
}),
|
| 15 |
+
}));
|
| 16 |
+
|
| 17 |
+
const mockFetch = vi.fn();
|
| 18 |
+
|
| 19 |
+
describe("downloadBlueBubblesAttachment", () => {
|
| 20 |
+
beforeEach(() => {
|
| 21 |
+
vi.stubGlobal("fetch", mockFetch);
|
| 22 |
+
mockFetch.mockReset();
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
afterEach(() => {
|
| 26 |
+
vi.unstubAllGlobals();
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
it("throws when guid is missing", async () => {
|
| 30 |
+
const attachment: BlueBubblesAttachment = {};
|
| 31 |
+
await expect(
|
| 32 |
+
downloadBlueBubblesAttachment(attachment, {
|
| 33 |
+
serverUrl: "http://localhost:1234",
|
| 34 |
+
password: "test-password",
|
| 35 |
+
}),
|
| 36 |
+
).rejects.toThrow("guid is required");
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
it("throws when guid is empty string", async () => {
|
| 40 |
+
const attachment: BlueBubblesAttachment = { guid: " " };
|
| 41 |
+
await expect(
|
| 42 |
+
downloadBlueBubblesAttachment(attachment, {
|
| 43 |
+
serverUrl: "http://localhost:1234",
|
| 44 |
+
password: "test-password",
|
| 45 |
+
}),
|
| 46 |
+
).rejects.toThrow("guid is required");
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
it("throws when serverUrl is missing", async () => {
|
| 50 |
+
const attachment: BlueBubblesAttachment = { guid: "att-123" };
|
| 51 |
+
await expect(downloadBlueBubblesAttachment(attachment, {})).rejects.toThrow(
|
| 52 |
+
"serverUrl is required",
|
| 53 |
+
);
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
it("throws when password is missing", async () => {
|
| 57 |
+
const attachment: BlueBubblesAttachment = { guid: "att-123" };
|
| 58 |
+
await expect(
|
| 59 |
+
downloadBlueBubblesAttachment(attachment, {
|
| 60 |
+
serverUrl: "http://localhost:1234",
|
| 61 |
+
}),
|
| 62 |
+
).rejects.toThrow("password is required");
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
it("downloads attachment successfully", async () => {
|
| 66 |
+
const mockBuffer = new Uint8Array([1, 2, 3, 4]);
|
| 67 |
+
mockFetch.mockResolvedValueOnce({
|
| 68 |
+
ok: true,
|
| 69 |
+
headers: new Headers({ "content-type": "image/png" }),
|
| 70 |
+
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
|
| 71 |
+
});
|
| 72 |
+
|
| 73 |
+
const attachment: BlueBubblesAttachment = { guid: "att-123" };
|
| 74 |
+
const result = await downloadBlueBubblesAttachment(attachment, {
|
| 75 |
+
serverUrl: "http://localhost:1234",
|
| 76 |
+
password: "test-password",
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
expect(result.buffer).toEqual(mockBuffer);
|
| 80 |
+
expect(result.contentType).toBe("image/png");
|
| 81 |
+
expect(mockFetch).toHaveBeenCalledWith(
|
| 82 |
+
expect.stringContaining("/api/v1/attachment/att-123/download"),
|
| 83 |
+
expect.objectContaining({ method: "GET" }),
|
| 84 |
+
);
|
| 85 |
+
});
|
| 86 |
+
|
| 87 |
+
it("includes password in URL query", async () => {
|
| 88 |
+
const mockBuffer = new Uint8Array([1, 2, 3, 4]);
|
| 89 |
+
mockFetch.mockResolvedValueOnce({
|
| 90 |
+
ok: true,
|
| 91 |
+
headers: new Headers({ "content-type": "image/jpeg" }),
|
| 92 |
+
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
const attachment: BlueBubblesAttachment = { guid: "att-456" };
|
| 96 |
+
await downloadBlueBubblesAttachment(attachment, {
|
| 97 |
+
serverUrl: "http://localhost:1234",
|
| 98 |
+
password: "my-secret-password",
|
| 99 |
+
});
|
| 100 |
+
|
| 101 |
+
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
| 102 |
+
expect(calledUrl).toContain("password=my-secret-password");
|
| 103 |
+
});
|
| 104 |
+
|
| 105 |
+
it("encodes guid in URL", async () => {
|
| 106 |
+
const mockBuffer = new Uint8Array([1]);
|
| 107 |
+
mockFetch.mockResolvedValueOnce({
|
| 108 |
+
ok: true,
|
| 109 |
+
headers: new Headers(),
|
| 110 |
+
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
|
| 111 |
+
});
|
| 112 |
+
|
| 113 |
+
const attachment: BlueBubblesAttachment = { guid: "att/with/special chars" };
|
| 114 |
+
await downloadBlueBubblesAttachment(attachment, {
|
| 115 |
+
serverUrl: "http://localhost:1234",
|
| 116 |
+
password: "test",
|
| 117 |
+
});
|
| 118 |
+
|
| 119 |
+
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
| 120 |
+
expect(calledUrl).toContain("att%2Fwith%2Fspecial%20chars");
|
| 121 |
+
});
|
| 122 |
+
|
| 123 |
+
it("throws on non-ok response", async () => {
|
| 124 |
+
mockFetch.mockResolvedValueOnce({
|
| 125 |
+
ok: false,
|
| 126 |
+
status: 404,
|
| 127 |
+
text: () => Promise.resolve("Attachment not found"),
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
const attachment: BlueBubblesAttachment = { guid: "att-missing" };
|
| 131 |
+
await expect(
|
| 132 |
+
downloadBlueBubblesAttachment(attachment, {
|
| 133 |
+
serverUrl: "http://localhost:1234",
|
| 134 |
+
password: "test",
|
| 135 |
+
}),
|
| 136 |
+
).rejects.toThrow("download failed (404): Attachment not found");
|
| 137 |
+
});
|
| 138 |
+
|
| 139 |
+
it("throws when attachment exceeds max bytes", async () => {
|
| 140 |
+
const largeBuffer = new Uint8Array(10 * 1024 * 1024);
|
| 141 |
+
mockFetch.mockResolvedValueOnce({
|
| 142 |
+
ok: true,
|
| 143 |
+
headers: new Headers(),
|
| 144 |
+
arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
|
| 145 |
+
});
|
| 146 |
+
|
| 147 |
+
const attachment: BlueBubblesAttachment = { guid: "att-large" };
|
| 148 |
+
await expect(
|
| 149 |
+
downloadBlueBubblesAttachment(attachment, {
|
| 150 |
+
serverUrl: "http://localhost:1234",
|
| 151 |
+
password: "test",
|
| 152 |
+
maxBytes: 5 * 1024 * 1024,
|
| 153 |
+
}),
|
| 154 |
+
).rejects.toThrow("too large");
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
it("uses default max bytes when not specified", async () => {
|
| 158 |
+
const largeBuffer = new Uint8Array(9 * 1024 * 1024);
|
| 159 |
+
mockFetch.mockResolvedValueOnce({
|
| 160 |
+
ok: true,
|
| 161 |
+
headers: new Headers(),
|
| 162 |
+
arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
|
| 163 |
+
});
|
| 164 |
+
|
| 165 |
+
const attachment: BlueBubblesAttachment = { guid: "att-large" };
|
| 166 |
+
await expect(
|
| 167 |
+
downloadBlueBubblesAttachment(attachment, {
|
| 168 |
+
serverUrl: "http://localhost:1234",
|
| 169 |
+
password: "test",
|
| 170 |
+
}),
|
| 171 |
+
).rejects.toThrow("too large");
|
| 172 |
+
});
|
| 173 |
+
|
| 174 |
+
it("uses attachment mimeType as fallback when response has no content-type", async () => {
|
| 175 |
+
const mockBuffer = new Uint8Array([1, 2, 3]);
|
| 176 |
+
mockFetch.mockResolvedValueOnce({
|
| 177 |
+
ok: true,
|
| 178 |
+
headers: new Headers(),
|
| 179 |
+
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
|
| 180 |
+
});
|
| 181 |
+
|
| 182 |
+
const attachment: BlueBubblesAttachment = {
|
| 183 |
+
guid: "att-789",
|
| 184 |
+
mimeType: "video/mp4",
|
| 185 |
+
};
|
| 186 |
+
const result = await downloadBlueBubblesAttachment(attachment, {
|
| 187 |
+
serverUrl: "http://localhost:1234",
|
| 188 |
+
password: "test",
|
| 189 |
+
});
|
| 190 |
+
|
| 191 |
+
expect(result.contentType).toBe("video/mp4");
|
| 192 |
+
});
|
| 193 |
+
|
| 194 |
+
it("prefers response content-type over attachment mimeType", async () => {
|
| 195 |
+
const mockBuffer = new Uint8Array([1, 2, 3]);
|
| 196 |
+
mockFetch.mockResolvedValueOnce({
|
| 197 |
+
ok: true,
|
| 198 |
+
headers: new Headers({ "content-type": "image/webp" }),
|
| 199 |
+
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
|
| 200 |
+
});
|
| 201 |
+
|
| 202 |
+
const attachment: BlueBubblesAttachment = {
|
| 203 |
+
guid: "att-xyz",
|
| 204 |
+
mimeType: "image/png",
|
| 205 |
+
};
|
| 206 |
+
const result = await downloadBlueBubblesAttachment(attachment, {
|
| 207 |
+
serverUrl: "http://localhost:1234",
|
| 208 |
+
password: "test",
|
| 209 |
+
});
|
| 210 |
+
|
| 211 |
+
expect(result.contentType).toBe("image/webp");
|
| 212 |
+
});
|
| 213 |
+
|
| 214 |
+
it("resolves credentials from config when opts not provided", async () => {
|
| 215 |
+
const mockBuffer = new Uint8Array([1]);
|
| 216 |
+
mockFetch.mockResolvedValueOnce({
|
| 217 |
+
ok: true,
|
| 218 |
+
headers: new Headers(),
|
| 219 |
+
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
|
| 220 |
+
});
|
| 221 |
+
|
| 222 |
+
const attachment: BlueBubblesAttachment = { guid: "att-config" };
|
| 223 |
+
const result = await downloadBlueBubblesAttachment(attachment, {
|
| 224 |
+
cfg: {
|
| 225 |
+
channels: {
|
| 226 |
+
bluebubbles: {
|
| 227 |
+
serverUrl: "http://config-server:5678",
|
| 228 |
+
password: "config-password",
|
| 229 |
+
},
|
| 230 |
+
},
|
| 231 |
+
},
|
| 232 |
+
});
|
| 233 |
+
|
| 234 |
+
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
| 235 |
+
expect(calledUrl).toContain("config-server:5678");
|
| 236 |
+
expect(calledUrl).toContain("password=config-password");
|
| 237 |
+
expect(result.buffer).toEqual(new Uint8Array([1]));
|
| 238 |
+
});
|
| 239 |
+
});
|
| 240 |
+
|
| 241 |
+
describe("sendBlueBubblesAttachment", () => {
|
| 242 |
+
beforeEach(() => {
|
| 243 |
+
vi.stubGlobal("fetch", mockFetch);
|
| 244 |
+
mockFetch.mockReset();
|
| 245 |
+
});
|
| 246 |
+
|
| 247 |
+
afterEach(() => {
|
| 248 |
+
vi.unstubAllGlobals();
|
| 249 |
+
});
|
| 250 |
+
|
| 251 |
+
function decodeBody(body: Uint8Array) {
|
| 252 |
+
return Buffer.from(body).toString("utf8");
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
it("marks voice memos when asVoice is true and mp3 is provided", async () => {
|
| 256 |
+
mockFetch.mockResolvedValueOnce({
|
| 257 |
+
ok: true,
|
| 258 |
+
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-1" })),
|
| 259 |
+
});
|
| 260 |
+
|
| 261 |
+
await sendBlueBubblesAttachment({
|
| 262 |
+
to: "chat_guid:iMessage;-;+15551234567",
|
| 263 |
+
buffer: new Uint8Array([1, 2, 3]),
|
| 264 |
+
filename: "voice.mp3",
|
| 265 |
+
contentType: "audio/mpeg",
|
| 266 |
+
asVoice: true,
|
| 267 |
+
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
| 268 |
+
});
|
| 269 |
+
|
| 270 |
+
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
|
| 271 |
+
const bodyText = decodeBody(body);
|
| 272 |
+
expect(bodyText).toContain('name="isAudioMessage"');
|
| 273 |
+
expect(bodyText).toContain("true");
|
| 274 |
+
expect(bodyText).toContain('filename="voice.mp3"');
|
| 275 |
+
});
|
| 276 |
+
|
| 277 |
+
it("normalizes mp3 filenames for voice memos", async () => {
|
| 278 |
+
mockFetch.mockResolvedValueOnce({
|
| 279 |
+
ok: true,
|
| 280 |
+
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-2" })),
|
| 281 |
+
});
|
| 282 |
+
|
| 283 |
+
await sendBlueBubblesAttachment({
|
| 284 |
+
to: "chat_guid:iMessage;-;+15551234567",
|
| 285 |
+
buffer: new Uint8Array([1, 2, 3]),
|
| 286 |
+
filename: "voice",
|
| 287 |
+
contentType: "audio/mpeg",
|
| 288 |
+
asVoice: true,
|
| 289 |
+
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
| 290 |
+
});
|
| 291 |
+
|
| 292 |
+
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
|
| 293 |
+
const bodyText = decodeBody(body);
|
| 294 |
+
expect(bodyText).toContain('filename="voice.mp3"');
|
| 295 |
+
expect(bodyText).toContain('name="voice.mp3"');
|
| 296 |
+
});
|
| 297 |
+
|
| 298 |
+
it("throws when asVoice is true but media is not audio", async () => {
|
| 299 |
+
await expect(
|
| 300 |
+
sendBlueBubblesAttachment({
|
| 301 |
+
to: "chat_guid:iMessage;-;+15551234567",
|
| 302 |
+
buffer: new Uint8Array([1, 2, 3]),
|
| 303 |
+
filename: "image.png",
|
| 304 |
+
contentType: "image/png",
|
| 305 |
+
asVoice: true,
|
| 306 |
+
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
| 307 |
+
}),
|
| 308 |
+
).rejects.toThrow("voice messages require audio");
|
| 309 |
+
expect(mockFetch).not.toHaveBeenCalled();
|
| 310 |
+
});
|
| 311 |
+
|
| 312 |
+
it("throws when asVoice is true but audio is not mp3 or caf", async () => {
|
| 313 |
+
await expect(
|
| 314 |
+
sendBlueBubblesAttachment({
|
| 315 |
+
to: "chat_guid:iMessage;-;+15551234567",
|
| 316 |
+
buffer: new Uint8Array([1, 2, 3]),
|
| 317 |
+
filename: "voice.wav",
|
| 318 |
+
contentType: "audio/wav",
|
| 319 |
+
asVoice: true,
|
| 320 |
+
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
| 321 |
+
}),
|
| 322 |
+
).rejects.toThrow("require mp3 or caf");
|
| 323 |
+
expect(mockFetch).not.toHaveBeenCalled();
|
| 324 |
+
});
|
| 325 |
+
|
| 326 |
+
it("sanitizes filenames before sending", async () => {
|
| 327 |
+
mockFetch.mockResolvedValueOnce({
|
| 328 |
+
ok: true,
|
| 329 |
+
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-3" })),
|
| 330 |
+
});
|
| 331 |
+
|
| 332 |
+
await sendBlueBubblesAttachment({
|
| 333 |
+
to: "chat_guid:iMessage;-;+15551234567",
|
| 334 |
+
buffer: new Uint8Array([1, 2, 3]),
|
| 335 |
+
filename: "../evil.mp3",
|
| 336 |
+
contentType: "audio/mpeg",
|
| 337 |
+
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
| 338 |
+
});
|
| 339 |
+
|
| 340 |
+
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
|
| 341 |
+
const bodyText = decodeBody(body);
|
| 342 |
+
expect(bodyText).toContain('filename="evil.mp3"');
|
| 343 |
+
expect(bodyText).toContain('name="evil.mp3"');
|
| 344 |
+
});
|
| 345 |
+
});
|
extensions/bluebubbles/src/attachments.ts
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
| 2 |
+
import crypto from "node:crypto";
|
| 3 |
+
import path from "node:path";
|
| 4 |
+
import { resolveBlueBubblesAccount } from "./accounts.js";
|
| 5 |
+
import { resolveChatGuidForTarget } from "./send.js";
|
| 6 |
+
import { parseBlueBubblesTarget, normalizeBlueBubblesHandle } from "./targets.js";
|
| 7 |
+
import {
|
| 8 |
+
blueBubblesFetchWithTimeout,
|
| 9 |
+
buildBlueBubblesApiUrl,
|
| 10 |
+
type BlueBubblesAttachment,
|
| 11 |
+
type BlueBubblesSendTarget,
|
| 12 |
+
} from "./types.js";
|
| 13 |
+
|
| 14 |
+
export type BlueBubblesAttachmentOpts = {
|
| 15 |
+
serverUrl?: string;
|
| 16 |
+
password?: string;
|
| 17 |
+
accountId?: string;
|
| 18 |
+
timeoutMs?: number;
|
| 19 |
+
cfg?: OpenClawConfig;
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024;
|
| 23 |
+
const AUDIO_MIME_MP3 = new Set(["audio/mpeg", "audio/mp3"]);
|
| 24 |
+
const AUDIO_MIME_CAF = new Set(["audio/x-caf", "audio/caf"]);
|
| 25 |
+
|
| 26 |
+
function sanitizeFilename(input: string | undefined, fallback: string): string {
|
| 27 |
+
const trimmed = input?.trim() ?? "";
|
| 28 |
+
const base = trimmed ? path.basename(trimmed) : "";
|
| 29 |
+
return base || fallback;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function ensureExtension(filename: string, extension: string, fallbackBase: string): string {
|
| 33 |
+
const currentExt = path.extname(filename);
|
| 34 |
+
if (currentExt.toLowerCase() === extension) {
|
| 35 |
+
return filename;
|
| 36 |
+
}
|
| 37 |
+
const base = currentExt ? filename.slice(0, -currentExt.length) : filename;
|
| 38 |
+
return `${base || fallbackBase}${extension}`;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function resolveVoiceInfo(filename: string, contentType?: string) {
|
| 42 |
+
const normalizedType = contentType?.trim().toLowerCase();
|
| 43 |
+
const extension = path.extname(filename).toLowerCase();
|
| 44 |
+
const isMp3 =
|
| 45 |
+
extension === ".mp3" || (normalizedType ? AUDIO_MIME_MP3.has(normalizedType) : false);
|
| 46 |
+
const isCaf =
|
| 47 |
+
extension === ".caf" || (normalizedType ? AUDIO_MIME_CAF.has(normalizedType) : false);
|
| 48 |
+
const isAudio = isMp3 || isCaf || Boolean(normalizedType?.startsWith("audio/"));
|
| 49 |
+
return { isAudio, isMp3, isCaf };
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
function resolveAccount(params: BlueBubblesAttachmentOpts) {
|
| 53 |
+
const account = resolveBlueBubblesAccount({
|
| 54 |
+
cfg: params.cfg ?? {},
|
| 55 |
+
accountId: params.accountId,
|
| 56 |
+
});
|
| 57 |
+
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
|
| 58 |
+
const password = params.password?.trim() || account.config.password?.trim();
|
| 59 |
+
if (!baseUrl) {
|
| 60 |
+
throw new Error("BlueBubbles serverUrl is required");
|
| 61 |
+
}
|
| 62 |
+
if (!password) {
|
| 63 |
+
throw new Error("BlueBubbles password is required");
|
| 64 |
+
}
|
| 65 |
+
return { baseUrl, password };
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
export async function downloadBlueBubblesAttachment(
|
| 69 |
+
attachment: BlueBubblesAttachment,
|
| 70 |
+
opts: BlueBubblesAttachmentOpts & { maxBytes?: number } = {},
|
| 71 |
+
): Promise<{ buffer: Uint8Array; contentType?: string }> {
|
| 72 |
+
const guid = attachment.guid?.trim();
|
| 73 |
+
if (!guid) {
|
| 74 |
+
throw new Error("BlueBubbles attachment guid is required");
|
| 75 |
+
}
|
| 76 |
+
const { baseUrl, password } = resolveAccount(opts);
|
| 77 |
+
const url = buildBlueBubblesApiUrl({
|
| 78 |
+
baseUrl,
|
| 79 |
+
path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`,
|
| 80 |
+
password,
|
| 81 |
+
});
|
| 82 |
+
const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, opts.timeoutMs);
|
| 83 |
+
if (!res.ok) {
|
| 84 |
+
const errorText = await res.text().catch(() => "");
|
| 85 |
+
throw new Error(
|
| 86 |
+
`BlueBubbles attachment download failed (${res.status}): ${errorText || "unknown"}`,
|
| 87 |
+
);
|
| 88 |
+
}
|
| 89 |
+
const contentType = res.headers.get("content-type") ?? undefined;
|
| 90 |
+
const buf = new Uint8Array(await res.arrayBuffer());
|
| 91 |
+
const maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES;
|
| 92 |
+
if (buf.byteLength > maxBytes) {
|
| 93 |
+
throw new Error(`BlueBubbles attachment too large (${buf.byteLength} bytes)`);
|
| 94 |
+
}
|
| 95 |
+
return { buffer: buf, contentType: contentType ?? attachment.mimeType ?? undefined };
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
export type SendBlueBubblesAttachmentResult = {
|
| 99 |
+
messageId: string;
|
| 100 |
+
};
|
| 101 |
+
|
| 102 |
+
function resolveSendTarget(raw: string): BlueBubblesSendTarget {
|
| 103 |
+
const parsed = parseBlueBubblesTarget(raw);
|
| 104 |
+
if (parsed.kind === "handle") {
|
| 105 |
+
return {
|
| 106 |
+
kind: "handle",
|
| 107 |
+
address: normalizeBlueBubblesHandle(parsed.to),
|
| 108 |
+
service: parsed.service,
|
| 109 |
+
};
|
| 110 |
+
}
|
| 111 |
+
if (parsed.kind === "chat_id") {
|
| 112 |
+
return { kind: "chat_id", chatId: parsed.chatId };
|
| 113 |
+
}
|
| 114 |
+
if (parsed.kind === "chat_guid") {
|
| 115 |
+
return { kind: "chat_guid", chatGuid: parsed.chatGuid };
|
| 116 |
+
}
|
| 117 |
+
return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
function extractMessageId(payload: unknown): string {
|
| 121 |
+
if (!payload || typeof payload !== "object") {
|
| 122 |
+
return "unknown";
|
| 123 |
+
}
|
| 124 |
+
const record = payload as Record<string, unknown>;
|
| 125 |
+
const data =
|
| 126 |
+
record.data && typeof record.data === "object"
|
| 127 |
+
? (record.data as Record<string, unknown>)
|
| 128 |
+
: null;
|
| 129 |
+
const candidates = [
|
| 130 |
+
record.messageId,
|
| 131 |
+
record.guid,
|
| 132 |
+
record.id,
|
| 133 |
+
data?.messageId,
|
| 134 |
+
data?.guid,
|
| 135 |
+
data?.id,
|
| 136 |
+
];
|
| 137 |
+
for (const candidate of candidates) {
|
| 138 |
+
if (typeof candidate === "string" && candidate.trim()) {
|
| 139 |
+
return candidate.trim();
|
| 140 |
+
}
|
| 141 |
+
if (typeof candidate === "number" && Number.isFinite(candidate)) {
|
| 142 |
+
return String(candidate);
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
return "unknown";
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
/**
|
| 149 |
+
* Send an attachment via BlueBubbles API.
|
| 150 |
+
* Supports sending media files (images, videos, audio, documents) to a chat.
|
| 151 |
+
* When asVoice is true, expects MP3/CAF audio and marks it as an iMessage voice memo.
|
| 152 |
+
*/
|
| 153 |
+
export async function sendBlueBubblesAttachment(params: {
|
| 154 |
+
to: string;
|
| 155 |
+
buffer: Uint8Array;
|
| 156 |
+
filename: string;
|
| 157 |
+
contentType?: string;
|
| 158 |
+
caption?: string;
|
| 159 |
+
replyToMessageGuid?: string;
|
| 160 |
+
replyToPartIndex?: number;
|
| 161 |
+
asVoice?: boolean;
|
| 162 |
+
opts?: BlueBubblesAttachmentOpts;
|
| 163 |
+
}): Promise<SendBlueBubblesAttachmentResult> {
|
| 164 |
+
const { to, caption, replyToMessageGuid, replyToPartIndex, asVoice, opts = {} } = params;
|
| 165 |
+
let { buffer, filename, contentType } = params;
|
| 166 |
+
const wantsVoice = asVoice === true;
|
| 167 |
+
const fallbackName = wantsVoice ? "Audio Message" : "attachment";
|
| 168 |
+
filename = sanitizeFilename(filename, fallbackName);
|
| 169 |
+
contentType = contentType?.trim() || undefined;
|
| 170 |
+
const { baseUrl, password } = resolveAccount(opts);
|
| 171 |
+
|
| 172 |
+
// Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage).
|
| 173 |
+
const isAudioMessage = wantsVoice;
|
| 174 |
+
if (isAudioMessage) {
|
| 175 |
+
const voiceInfo = resolveVoiceInfo(filename, contentType);
|
| 176 |
+
if (!voiceInfo.isAudio) {
|
| 177 |
+
throw new Error("BlueBubbles voice messages require audio media (mp3 or caf).");
|
| 178 |
+
}
|
| 179 |
+
if (voiceInfo.isMp3) {
|
| 180 |
+
filename = ensureExtension(filename, ".mp3", fallbackName);
|
| 181 |
+
contentType = contentType ?? "audio/mpeg";
|
| 182 |
+
} else if (voiceInfo.isCaf) {
|
| 183 |
+
filename = ensureExtension(filename, ".caf", fallbackName);
|
| 184 |
+
contentType = contentType ?? "audio/x-caf";
|
| 185 |
+
} else {
|
| 186 |
+
throw new Error(
|
| 187 |
+
"BlueBubbles voice messages require mp3 or caf audio (convert before sending).",
|
| 188 |
+
);
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
const target = resolveSendTarget(to);
|
| 193 |
+
const chatGuid = await resolveChatGuidForTarget({
|
| 194 |
+
baseUrl,
|
| 195 |
+
password,
|
| 196 |
+
timeoutMs: opts.timeoutMs,
|
| 197 |
+
target,
|
| 198 |
+
});
|
| 199 |
+
if (!chatGuid) {
|
| 200 |
+
throw new Error(
|
| 201 |
+
"BlueBubbles attachment send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
|
| 202 |
+
);
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
const url = buildBlueBubblesApiUrl({
|
| 206 |
+
baseUrl,
|
| 207 |
+
path: "/api/v1/message/attachment",
|
| 208 |
+
password,
|
| 209 |
+
});
|
| 210 |
+
|
| 211 |
+
// Build FormData with the attachment
|
| 212 |
+
const boundary = `----BlueBubblesFormBoundary${crypto.randomUUID().replace(/-/g, "")}`;
|
| 213 |
+
const parts: Uint8Array[] = [];
|
| 214 |
+
const encoder = new TextEncoder();
|
| 215 |
+
|
| 216 |
+
// Helper to add a form field
|
| 217 |
+
const addField = (name: string, value: string) => {
|
| 218 |
+
parts.push(encoder.encode(`--${boundary}\r\n`));
|
| 219 |
+
parts.push(encoder.encode(`Content-Disposition: form-data; name="${name}"\r\n\r\n`));
|
| 220 |
+
parts.push(encoder.encode(`${value}\r\n`));
|
| 221 |
+
};
|
| 222 |
+
|
| 223 |
+
// Helper to add a file field
|
| 224 |
+
const addFile = (name: string, fileBuffer: Uint8Array, fileName: string, mimeType?: string) => {
|
| 225 |
+
parts.push(encoder.encode(`--${boundary}\r\n`));
|
| 226 |
+
parts.push(
|
| 227 |
+
encoder.encode(`Content-Disposition: form-data; name="${name}"; filename="${fileName}"\r\n`),
|
| 228 |
+
);
|
| 229 |
+
parts.push(encoder.encode(`Content-Type: ${mimeType ?? "application/octet-stream"}\r\n\r\n`));
|
| 230 |
+
parts.push(fileBuffer);
|
| 231 |
+
parts.push(encoder.encode("\r\n"));
|
| 232 |
+
};
|
| 233 |
+
|
| 234 |
+
// Add required fields
|
| 235 |
+
addFile("attachment", buffer, filename, contentType);
|
| 236 |
+
addField("chatGuid", chatGuid);
|
| 237 |
+
addField("name", filename);
|
| 238 |
+
addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
|
| 239 |
+
addField("method", "private-api");
|
| 240 |
+
|
| 241 |
+
// Add isAudioMessage flag for voice memos
|
| 242 |
+
if (isAudioMessage) {
|
| 243 |
+
addField("isAudioMessage", "true");
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
const trimmedReplyTo = replyToMessageGuid?.trim();
|
| 247 |
+
if (trimmedReplyTo) {
|
| 248 |
+
addField("selectedMessageGuid", trimmedReplyTo);
|
| 249 |
+
addField("partIndex", typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0");
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
// Add optional caption
|
| 253 |
+
if (caption) {
|
| 254 |
+
addField("message", caption);
|
| 255 |
+
addField("text", caption);
|
| 256 |
+
addField("caption", caption);
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
// Close the multipart body
|
| 260 |
+
parts.push(encoder.encode(`--${boundary}--\r\n`));
|
| 261 |
+
|
| 262 |
+
// Combine all parts into a single buffer
|
| 263 |
+
const totalLength = parts.reduce((acc, part) => acc + part.length, 0);
|
| 264 |
+
const body = new Uint8Array(totalLength);
|
| 265 |
+
let offset = 0;
|
| 266 |
+
for (const part of parts) {
|
| 267 |
+
body.set(part, offset);
|
| 268 |
+
offset += part.length;
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
const res = await blueBubblesFetchWithTimeout(
|
| 272 |
+
url,
|
| 273 |
+
{
|
| 274 |
+
method: "POST",
|
| 275 |
+
headers: {
|
| 276 |
+
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
| 277 |
+
},
|
| 278 |
+
body,
|
| 279 |
+
},
|
| 280 |
+
opts.timeoutMs ?? 60_000, // longer timeout for file uploads
|
| 281 |
+
);
|
| 282 |
+
|
| 283 |
+
if (!res.ok) {
|
| 284 |
+
const errorText = await res.text();
|
| 285 |
+
throw new Error(
|
| 286 |
+
`BlueBubbles attachment send failed (${res.status}): ${errorText || "unknown"}`,
|
| 287 |
+
);
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
const responseBody = await res.text();
|
| 291 |
+
if (!responseBody) {
|
| 292 |
+
return { messageId: "ok" };
|
| 293 |
+
}
|
| 294 |
+
try {
|
| 295 |
+
const parsed = JSON.parse(responseBody) as unknown;
|
| 296 |
+
return { messageId: extractMessageId(parsed) };
|
| 297 |
+
} catch {
|
| 298 |
+
return { messageId: "ok" };
|
| 299 |
+
}
|
| 300 |
+
}
|
extensions/bluebubbles/src/channel.ts
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ChannelAccountSnapshot, ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk";
|
| 2 |
+
import {
|
| 3 |
+
applyAccountNameToChannelSection,
|
| 4 |
+
buildChannelConfigSchema,
|
| 5 |
+
collectBlueBubblesStatusIssues,
|
| 6 |
+
DEFAULT_ACCOUNT_ID,
|
| 7 |
+
deleteAccountFromConfigSection,
|
| 8 |
+
formatPairingApproveHint,
|
| 9 |
+
migrateBaseNameToDefaultAccount,
|
| 10 |
+
normalizeAccountId,
|
| 11 |
+
PAIRING_APPROVED_MESSAGE,
|
| 12 |
+
resolveBlueBubblesGroupRequireMention,
|
| 13 |
+
resolveBlueBubblesGroupToolPolicy,
|
| 14 |
+
setAccountEnabledInConfigSection,
|
| 15 |
+
} from "openclaw/plugin-sdk";
|
| 16 |
+
import {
|
| 17 |
+
listBlueBubblesAccountIds,
|
| 18 |
+
type ResolvedBlueBubblesAccount,
|
| 19 |
+
resolveBlueBubblesAccount,
|
| 20 |
+
resolveDefaultBlueBubblesAccountId,
|
| 21 |
+
} from "./accounts.js";
|
| 22 |
+
import { bluebubblesMessageActions } from "./actions.js";
|
| 23 |
+
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
| 24 |
+
import { sendBlueBubblesMedia } from "./media-send.js";
|
| 25 |
+
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
| 26 |
+
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
|
| 27 |
+
import { blueBubblesOnboardingAdapter } from "./onboarding.js";
|
| 28 |
+
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
|
| 29 |
+
import { sendMessageBlueBubbles } from "./send.js";
|
| 30 |
+
import {
|
| 31 |
+
extractHandleFromChatGuid,
|
| 32 |
+
looksLikeBlueBubblesTargetId,
|
| 33 |
+
normalizeBlueBubblesHandle,
|
| 34 |
+
normalizeBlueBubblesMessagingTarget,
|
| 35 |
+
parseBlueBubblesTarget,
|
| 36 |
+
} from "./targets.js";
|
| 37 |
+
|
| 38 |
+
const meta = {
|
| 39 |
+
id: "bluebubbles",
|
| 40 |
+
label: "BlueBubbles",
|
| 41 |
+
selectionLabel: "BlueBubbles (macOS app)",
|
| 42 |
+
detailLabel: "BlueBubbles",
|
| 43 |
+
docsPath: "/channels/bluebubbles",
|
| 44 |
+
docsLabel: "bluebubbles",
|
| 45 |
+
blurb: "iMessage via the BlueBubbles mac app + REST API.",
|
| 46 |
+
systemImage: "bubble.left.and.text.bubble.right",
|
| 47 |
+
aliases: ["bb"],
|
| 48 |
+
order: 75,
|
| 49 |
+
preferOver: ["imessage"],
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
| 53 |
+
id: "bluebubbles",
|
| 54 |
+
meta,
|
| 55 |
+
capabilities: {
|
| 56 |
+
chatTypes: ["direct", "group"],
|
| 57 |
+
media: true,
|
| 58 |
+
reactions: true,
|
| 59 |
+
edit: true,
|
| 60 |
+
unsend: true,
|
| 61 |
+
reply: true,
|
| 62 |
+
effects: true,
|
| 63 |
+
groupManagement: true,
|
| 64 |
+
},
|
| 65 |
+
groups: {
|
| 66 |
+
resolveRequireMention: resolveBlueBubblesGroupRequireMention,
|
| 67 |
+
resolveToolPolicy: resolveBlueBubblesGroupToolPolicy,
|
| 68 |
+
},
|
| 69 |
+
threading: {
|
| 70 |
+
buildToolContext: ({ context, hasRepliedRef }) => ({
|
| 71 |
+
currentChannelId: context.To?.trim() || undefined,
|
| 72 |
+
currentThreadTs: context.ReplyToIdFull ?? context.ReplyToId,
|
| 73 |
+
hasRepliedRef,
|
| 74 |
+
}),
|
| 75 |
+
},
|
| 76 |
+
reload: { configPrefixes: ["channels.bluebubbles"] },
|
| 77 |
+
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
|
| 78 |
+
onboarding: blueBubblesOnboardingAdapter,
|
| 79 |
+
config: {
|
| 80 |
+
listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg),
|
| 81 |
+
resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg: cfg, accountId }),
|
| 82 |
+
defaultAccountId: (cfg) => resolveDefaultBlueBubblesAccountId(cfg),
|
| 83 |
+
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
| 84 |
+
setAccountEnabledInConfigSection({
|
| 85 |
+
cfg: cfg,
|
| 86 |
+
sectionKey: "bluebubbles",
|
| 87 |
+
accountId,
|
| 88 |
+
enabled,
|
| 89 |
+
allowTopLevel: true,
|
| 90 |
+
}),
|
| 91 |
+
deleteAccount: ({ cfg, accountId }) =>
|
| 92 |
+
deleteAccountFromConfigSection({
|
| 93 |
+
cfg: cfg,
|
| 94 |
+
sectionKey: "bluebubbles",
|
| 95 |
+
accountId,
|
| 96 |
+
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
|
| 97 |
+
}),
|
| 98 |
+
isConfigured: (account) => account.configured,
|
| 99 |
+
describeAccount: (account): ChannelAccountSnapshot => ({
|
| 100 |
+
accountId: account.accountId,
|
| 101 |
+
name: account.name,
|
| 102 |
+
enabled: account.enabled,
|
| 103 |
+
configured: account.configured,
|
| 104 |
+
baseUrl: account.baseUrl,
|
| 105 |
+
}),
|
| 106 |
+
resolveAllowFrom: ({ cfg, accountId }) =>
|
| 107 |
+
(resolveBlueBubblesAccount({ cfg: cfg, accountId }).config.allowFrom ?? []).map((entry) =>
|
| 108 |
+
String(entry),
|
| 109 |
+
),
|
| 110 |
+
formatAllowFrom: ({ allowFrom }) =>
|
| 111 |
+
allowFrom
|
| 112 |
+
.map((entry) => String(entry).trim())
|
| 113 |
+
.filter(Boolean)
|
| 114 |
+
.map((entry) => entry.replace(/^bluebubbles:/i, ""))
|
| 115 |
+
.map((entry) => normalizeBlueBubblesHandle(entry)),
|
| 116 |
+
},
|
| 117 |
+
actions: bluebubblesMessageActions,
|
| 118 |
+
security: {
|
| 119 |
+
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
| 120 |
+
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
| 121 |
+
const useAccountPath = Boolean(cfg.channels?.bluebubbles?.accounts?.[resolvedAccountId]);
|
| 122 |
+
const basePath = useAccountPath
|
| 123 |
+
? `channels.bluebubbles.accounts.${resolvedAccountId}.`
|
| 124 |
+
: "channels.bluebubbles.";
|
| 125 |
+
return {
|
| 126 |
+
policy: account.config.dmPolicy ?? "pairing",
|
| 127 |
+
allowFrom: account.config.allowFrom ?? [],
|
| 128 |
+
policyPath: `${basePath}dmPolicy`,
|
| 129 |
+
allowFromPath: basePath,
|
| 130 |
+
approveHint: formatPairingApproveHint("bluebubbles"),
|
| 131 |
+
normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")),
|
| 132 |
+
};
|
| 133 |
+
},
|
| 134 |
+
collectWarnings: ({ account }) => {
|
| 135 |
+
const groupPolicy = account.config.groupPolicy ?? "allowlist";
|
| 136 |
+
if (groupPolicy !== "open") {
|
| 137 |
+
return [];
|
| 138 |
+
}
|
| 139 |
+
return [
|
| 140 |
+
`- BlueBubbles groups: groupPolicy="open" allows any member to trigger the bot. Set channels.bluebubbles.groupPolicy="allowlist" + channels.bluebubbles.groupAllowFrom to restrict senders.`,
|
| 141 |
+
];
|
| 142 |
+
},
|
| 143 |
+
},
|
| 144 |
+
messaging: {
|
| 145 |
+
normalizeTarget: normalizeBlueBubblesMessagingTarget,
|
| 146 |
+
targetResolver: {
|
| 147 |
+
looksLikeId: looksLikeBlueBubblesTargetId,
|
| 148 |
+
hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>",
|
| 149 |
+
},
|
| 150 |
+
formatTargetDisplay: ({ target, display }) => {
|
| 151 |
+
const shouldParseDisplay = (value: string): boolean => {
|
| 152 |
+
if (looksLikeBlueBubblesTargetId(value)) {
|
| 153 |
+
return true;
|
| 154 |
+
}
|
| 155 |
+
return /^(bluebubbles:|chat_guid:|chat_id:|chat_identifier:)/i.test(value);
|
| 156 |
+
};
|
| 157 |
+
|
| 158 |
+
// Helper to extract a clean handle from any BlueBubbles target format
|
| 159 |
+
const extractCleanDisplay = (value: string | undefined): string | null => {
|
| 160 |
+
const trimmed = value?.trim();
|
| 161 |
+
if (!trimmed) {
|
| 162 |
+
return null;
|
| 163 |
+
}
|
| 164 |
+
try {
|
| 165 |
+
const parsed = parseBlueBubblesTarget(trimmed);
|
| 166 |
+
if (parsed.kind === "chat_guid") {
|
| 167 |
+
const handle = extractHandleFromChatGuid(parsed.chatGuid);
|
| 168 |
+
if (handle) {
|
| 169 |
+
return handle;
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
if (parsed.kind === "handle") {
|
| 173 |
+
return normalizeBlueBubblesHandle(parsed.to);
|
| 174 |
+
}
|
| 175 |
+
} catch {
|
| 176 |
+
// Fall through
|
| 177 |
+
}
|
| 178 |
+
// Strip common prefixes and try raw extraction
|
| 179 |
+
const stripped = trimmed
|
| 180 |
+
.replace(/^bluebubbles:/i, "")
|
| 181 |
+
.replace(/^chat_guid:/i, "")
|
| 182 |
+
.replace(/^chat_id:/i, "")
|
| 183 |
+
.replace(/^chat_identifier:/i, "");
|
| 184 |
+
const handle = extractHandleFromChatGuid(stripped);
|
| 185 |
+
if (handle) {
|
| 186 |
+
return handle;
|
| 187 |
+
}
|
| 188 |
+
// Don't return raw chat_guid formats - they contain internal routing info
|
| 189 |
+
if (stripped.includes(";-;") || stripped.includes(";+;")) {
|
| 190 |
+
return null;
|
| 191 |
+
}
|
| 192 |
+
return stripped;
|
| 193 |
+
};
|
| 194 |
+
|
| 195 |
+
// Try to get a clean display from the display parameter first
|
| 196 |
+
const trimmedDisplay = display?.trim();
|
| 197 |
+
if (trimmedDisplay) {
|
| 198 |
+
if (!shouldParseDisplay(trimmedDisplay)) {
|
| 199 |
+
return trimmedDisplay;
|
| 200 |
+
}
|
| 201 |
+
const cleanDisplay = extractCleanDisplay(trimmedDisplay);
|
| 202 |
+
if (cleanDisplay) {
|
| 203 |
+
return cleanDisplay;
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
// Fall back to extracting from target
|
| 208 |
+
const cleanTarget = extractCleanDisplay(target);
|
| 209 |
+
if (cleanTarget) {
|
| 210 |
+
return cleanTarget;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
// Last resort: return display or target as-is
|
| 214 |
+
return display?.trim() || target?.trim() || "";
|
| 215 |
+
},
|
| 216 |
+
},
|
| 217 |
+
setup: {
|
| 218 |
+
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
| 219 |
+
applyAccountName: ({ cfg, accountId, name }) =>
|
| 220 |
+
applyAccountNameToChannelSection({
|
| 221 |
+
cfg: cfg,
|
| 222 |
+
channelKey: "bluebubbles",
|
| 223 |
+
accountId,
|
| 224 |
+
name,
|
| 225 |
+
}),
|
| 226 |
+
validateInput: ({ input }) => {
|
| 227 |
+
if (!input.httpUrl && !input.password) {
|
| 228 |
+
return "BlueBubbles requires --http-url and --password.";
|
| 229 |
+
}
|
| 230 |
+
if (!input.httpUrl) {
|
| 231 |
+
return "BlueBubbles requires --http-url.";
|
| 232 |
+
}
|
| 233 |
+
if (!input.password) {
|
| 234 |
+
return "BlueBubbles requires --password.";
|
| 235 |
+
}
|
| 236 |
+
return null;
|
| 237 |
+
},
|
| 238 |
+
applyAccountConfig: ({ cfg, accountId, input }) => {
|
| 239 |
+
const namedConfig = applyAccountNameToChannelSection({
|
| 240 |
+
cfg: cfg,
|
| 241 |
+
channelKey: "bluebubbles",
|
| 242 |
+
accountId,
|
| 243 |
+
name: input.name,
|
| 244 |
+
});
|
| 245 |
+
const next =
|
| 246 |
+
accountId !== DEFAULT_ACCOUNT_ID
|
| 247 |
+
? migrateBaseNameToDefaultAccount({
|
| 248 |
+
cfg: namedConfig,
|
| 249 |
+
channelKey: "bluebubbles",
|
| 250 |
+
})
|
| 251 |
+
: namedConfig;
|
| 252 |
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
| 253 |
+
return {
|
| 254 |
+
...next,
|
| 255 |
+
channels: {
|
| 256 |
+
...next.channels,
|
| 257 |
+
bluebubbles: {
|
| 258 |
+
...next.channels?.bluebubbles,
|
| 259 |
+
enabled: true,
|
| 260 |
+
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
|
| 261 |
+
...(input.password ? { password: input.password } : {}),
|
| 262 |
+
...(input.webhookPath ? { webhookPath: input.webhookPath } : {}),
|
| 263 |
+
},
|
| 264 |
+
},
|
| 265 |
+
} as OpenClawConfig;
|
| 266 |
+
}
|
| 267 |
+
return {
|
| 268 |
+
...next,
|
| 269 |
+
channels: {
|
| 270 |
+
...next.channels,
|
| 271 |
+
bluebubbles: {
|
| 272 |
+
...next.channels?.bluebubbles,
|
| 273 |
+
enabled: true,
|
| 274 |
+
accounts: {
|
| 275 |
+
...next.channels?.bluebubbles?.accounts,
|
| 276 |
+
[accountId]: {
|
| 277 |
+
...next.channels?.bluebubbles?.accounts?.[accountId],
|
| 278 |
+
enabled: true,
|
| 279 |
+
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
|
| 280 |
+
...(input.password ? { password: input.password } : {}),
|
| 281 |
+
...(input.webhookPath ? { webhookPath: input.webhookPath } : {}),
|
| 282 |
+
},
|
| 283 |
+
},
|
| 284 |
+
},
|
| 285 |
+
},
|
| 286 |
+
} as OpenClawConfig;
|
| 287 |
+
},
|
| 288 |
+
},
|
| 289 |
+
pairing: {
|
| 290 |
+
idLabel: "bluebubblesSenderId",
|
| 291 |
+
normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
|
| 292 |
+
notifyApproval: async ({ cfg, id }) => {
|
| 293 |
+
await sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, {
|
| 294 |
+
cfg: cfg,
|
| 295 |
+
});
|
| 296 |
+
},
|
| 297 |
+
},
|
| 298 |
+
outbound: {
|
| 299 |
+
deliveryMode: "direct",
|
| 300 |
+
textChunkLimit: 4000,
|
| 301 |
+
resolveTarget: ({ to }) => {
|
| 302 |
+
const trimmed = to?.trim();
|
| 303 |
+
if (!trimmed) {
|
| 304 |
+
return {
|
| 305 |
+
ok: false,
|
| 306 |
+
error: new Error("Delivering to BlueBubbles requires --to <handle|chat_guid:GUID>"),
|
| 307 |
+
};
|
| 308 |
+
}
|
| 309 |
+
return { ok: true, to: trimmed };
|
| 310 |
+
},
|
| 311 |
+
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
|
| 312 |
+
const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : "";
|
| 313 |
+
// Resolve short ID (e.g., "5") to full UUID
|
| 314 |
+
const replyToMessageGuid = rawReplyToId
|
| 315 |
+
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
|
| 316 |
+
: "";
|
| 317 |
+
const result = await sendMessageBlueBubbles(to, text, {
|
| 318 |
+
cfg: cfg,
|
| 319 |
+
accountId: accountId ?? undefined,
|
| 320 |
+
replyToMessageGuid: replyToMessageGuid || undefined,
|
| 321 |
+
});
|
| 322 |
+
return { channel: "bluebubbles", ...result };
|
| 323 |
+
},
|
| 324 |
+
sendMedia: async (ctx) => {
|
| 325 |
+
const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx;
|
| 326 |
+
const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as {
|
| 327 |
+
mediaPath?: string;
|
| 328 |
+
mediaBuffer?: Uint8Array;
|
| 329 |
+
contentType?: string;
|
| 330 |
+
filename?: string;
|
| 331 |
+
caption?: string;
|
| 332 |
+
};
|
| 333 |
+
const resolvedCaption = caption ?? text;
|
| 334 |
+
const result = await sendBlueBubblesMedia({
|
| 335 |
+
cfg: cfg,
|
| 336 |
+
to,
|
| 337 |
+
mediaUrl,
|
| 338 |
+
mediaPath,
|
| 339 |
+
mediaBuffer,
|
| 340 |
+
contentType,
|
| 341 |
+
filename,
|
| 342 |
+
caption: resolvedCaption ?? undefined,
|
| 343 |
+
replyToId: replyToId ?? null,
|
| 344 |
+
accountId: accountId ?? undefined,
|
| 345 |
+
});
|
| 346 |
+
|
| 347 |
+
return { channel: "bluebubbles", ...result };
|
| 348 |
+
},
|
| 349 |
+
},
|
| 350 |
+
status: {
|
| 351 |
+
defaultRuntime: {
|
| 352 |
+
accountId: DEFAULT_ACCOUNT_ID,
|
| 353 |
+
running: false,
|
| 354 |
+
lastStartAt: null,
|
| 355 |
+
lastStopAt: null,
|
| 356 |
+
lastError: null,
|
| 357 |
+
},
|
| 358 |
+
collectStatusIssues: collectBlueBubblesStatusIssues,
|
| 359 |
+
buildChannelSummary: ({ snapshot }) => ({
|
| 360 |
+
configured: snapshot.configured ?? false,
|
| 361 |
+
baseUrl: snapshot.baseUrl ?? null,
|
| 362 |
+
running: snapshot.running ?? false,
|
| 363 |
+
lastStartAt: snapshot.lastStartAt ?? null,
|
| 364 |
+
lastStopAt: snapshot.lastStopAt ?? null,
|
| 365 |
+
lastError: snapshot.lastError ?? null,
|
| 366 |
+
probe: snapshot.probe,
|
| 367 |
+
lastProbeAt: snapshot.lastProbeAt ?? null,
|
| 368 |
+
}),
|
| 369 |
+
probeAccount: async ({ account, timeoutMs }) =>
|
| 370 |
+
probeBlueBubbles({
|
| 371 |
+
baseUrl: account.baseUrl,
|
| 372 |
+
password: account.config.password ?? null,
|
| 373 |
+
timeoutMs,
|
| 374 |
+
}),
|
| 375 |
+
buildAccountSnapshot: ({ account, runtime, probe }) => {
|
| 376 |
+
const running = runtime?.running ?? false;
|
| 377 |
+
const probeOk = (probe as BlueBubblesProbe | undefined)?.ok;
|
| 378 |
+
return {
|
| 379 |
+
accountId: account.accountId,
|
| 380 |
+
name: account.name,
|
| 381 |
+
enabled: account.enabled,
|
| 382 |
+
configured: account.configured,
|
| 383 |
+
baseUrl: account.baseUrl,
|
| 384 |
+
running,
|
| 385 |
+
connected: probeOk ?? running,
|
| 386 |
+
lastStartAt: runtime?.lastStartAt ?? null,
|
| 387 |
+
lastStopAt: runtime?.lastStopAt ?? null,
|
| 388 |
+
lastError: runtime?.lastError ?? null,
|
| 389 |
+
probe,
|
| 390 |
+
lastInboundAt: runtime?.lastInboundAt ?? null,
|
| 391 |
+
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
| 392 |
+
};
|
| 393 |
+
},
|
| 394 |
+
},
|
| 395 |
+
gateway: {
|
| 396 |
+
startAccount: async (ctx) => {
|
| 397 |
+
const account = ctx.account;
|
| 398 |
+
const webhookPath = resolveWebhookPathFromConfig(account.config);
|
| 399 |
+
ctx.setStatus({
|
| 400 |
+
accountId: account.accountId,
|
| 401 |
+
baseUrl: account.baseUrl,
|
| 402 |
+
});
|
| 403 |
+
ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`);
|
| 404 |
+
return monitorBlueBubblesProvider({
|
| 405 |
+
account,
|
| 406 |
+
config: ctx.cfg,
|
| 407 |
+
runtime: ctx.runtime,
|
| 408 |
+
abortSignal: ctx.abortSignal,
|
| 409 |
+
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
| 410 |
+
webhookPath,
|
| 411 |
+
});
|
| 412 |
+
},
|
| 413 |
+
},
|
| 414 |
+
};
|
extensions/bluebubbles/src/chat.test.ts
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
| 2 |
+
import { markBlueBubblesChatRead, sendBlueBubblesTyping, setGroupIconBlueBubbles } from "./chat.js";
|
| 3 |
+
|
| 4 |
+
vi.mock("./accounts.js", () => ({
|
| 5 |
+
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
|
| 6 |
+
const config = cfg?.channels?.bluebubbles ?? {};
|
| 7 |
+
return {
|
| 8 |
+
accountId: accountId ?? "default",
|
| 9 |
+
enabled: config.enabled !== false,
|
| 10 |
+
configured: Boolean(config.serverUrl && config.password),
|
| 11 |
+
config,
|
| 12 |
+
};
|
| 13 |
+
}),
|
| 14 |
+
}));
|
| 15 |
+
|
| 16 |
+
const mockFetch = vi.fn();
|
| 17 |
+
|
| 18 |
+
describe("chat", () => {
|
| 19 |
+
beforeEach(() => {
|
| 20 |
+
vi.stubGlobal("fetch", mockFetch);
|
| 21 |
+
mockFetch.mockReset();
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
afterEach(() => {
|
| 25 |
+
vi.unstubAllGlobals();
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
describe("markBlueBubblesChatRead", () => {
|
| 29 |
+
it("does nothing when chatGuid is empty", async () => {
|
| 30 |
+
await markBlueBubblesChatRead("", {
|
| 31 |
+
serverUrl: "http://localhost:1234",
|
| 32 |
+
password: "test",
|
| 33 |
+
});
|
| 34 |
+
expect(mockFetch).not.toHaveBeenCalled();
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
it("does nothing when chatGuid is whitespace", async () => {
|
| 38 |
+
await markBlueBubblesChatRead(" ", {
|
| 39 |
+
serverUrl: "http://localhost:1234",
|
| 40 |
+
password: "test",
|
| 41 |
+
});
|
| 42 |
+
expect(mockFetch).not.toHaveBeenCalled();
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
it("throws when serverUrl is missing", async () => {
|
| 46 |
+
await expect(markBlueBubblesChatRead("chat-guid", {})).rejects.toThrow(
|
| 47 |
+
"serverUrl is required",
|
| 48 |
+
);
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
it("throws when password is missing", async () => {
|
| 52 |
+
await expect(
|
| 53 |
+
markBlueBubblesChatRead("chat-guid", {
|
| 54 |
+
serverUrl: "http://localhost:1234",
|
| 55 |
+
}),
|
| 56 |
+
).rejects.toThrow("password is required");
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
it("marks chat as read successfully", async () => {
|
| 60 |
+
mockFetch.mockResolvedValueOnce({
|
| 61 |
+
ok: true,
|
| 62 |
+
text: () => Promise.resolve(""),
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
await markBlueBubblesChatRead("iMessage;-;+15551234567", {
|
| 66 |
+
serverUrl: "http://localhost:1234",
|
| 67 |
+
password: "test-password",
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
expect(mockFetch).toHaveBeenCalledWith(
|
| 71 |
+
expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/read"),
|
| 72 |
+
expect.objectContaining({ method: "POST" }),
|
| 73 |
+
);
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
it("includes password in URL query", async () => {
|
| 77 |
+
mockFetch.mockResolvedValueOnce({
|
| 78 |
+
ok: true,
|
| 79 |
+
text: () => Promise.resolve(""),
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
await markBlueBubblesChatRead("chat-123", {
|
| 83 |
+
serverUrl: "http://localhost:1234",
|
| 84 |
+
password: "my-secret",
|
| 85 |
+
});
|
| 86 |
+
|
| 87 |
+
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
| 88 |
+
expect(calledUrl).toContain("password=my-secret");
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
it("throws on non-ok response", async () => {
|
| 92 |
+
mockFetch.mockResolvedValueOnce({
|
| 93 |
+
ok: false,
|
| 94 |
+
status: 404,
|
| 95 |
+
text: () => Promise.resolve("Chat not found"),
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
await expect(
|
| 99 |
+
markBlueBubblesChatRead("missing-chat", {
|
| 100 |
+
serverUrl: "http://localhost:1234",
|
| 101 |
+
password: "test",
|
| 102 |
+
}),
|
| 103 |
+
).rejects.toThrow("read failed (404): Chat not found");
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
+
it("trims chatGuid before using", async () => {
|
| 107 |
+
mockFetch.mockResolvedValueOnce({
|
| 108 |
+
ok: true,
|
| 109 |
+
text: () => Promise.resolve(""),
|
| 110 |
+
});
|
| 111 |
+
|
| 112 |
+
await markBlueBubblesChatRead(" chat-with-spaces ", {
|
| 113 |
+
serverUrl: "http://localhost:1234",
|
| 114 |
+
password: "test",
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
+
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
| 118 |
+
expect(calledUrl).toContain("/api/v1/chat/chat-with-spaces/read");
|
| 119 |
+
expect(calledUrl).not.toContain("%20chat");
|
| 120 |
+
});
|
| 121 |
+
|
| 122 |
+
it("resolves credentials from config", async () => {
|
| 123 |
+
mockFetch.mockResolvedValueOnce({
|
| 124 |
+
ok: true,
|
| 125 |
+
text: () => Promise.resolve(""),
|
| 126 |
+
});
|
| 127 |
+
|
| 128 |
+
await markBlueBubblesChatRead("chat-123", {
|
| 129 |
+
cfg: {
|
| 130 |
+
channels: {
|
| 131 |
+
bluebubbles: {
|
| 132 |
+
serverUrl: "http://config-server:9999",
|
| 133 |
+
password: "config-pass",
|
| 134 |
+
},
|
| 135 |
+
},
|
| 136 |
+
},
|
| 137 |
+
});
|
| 138 |
+
|
| 139 |
+
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
| 140 |
+
expect(calledUrl).toContain("config-server:9999");
|
| 141 |
+
expect(calledUrl).toContain("password=config-pass");
|
| 142 |
+
});
|
| 143 |
+
});
|
| 144 |
+
|
| 145 |
+
describe("sendBlueBubblesTyping", () => {
|
| 146 |
+
it("does nothing when chatGuid is empty", async () => {
|
| 147 |
+
await sendBlueBubblesTyping("", true, {
|
| 148 |
+
serverUrl: "http://localhost:1234",
|
| 149 |
+
password: "test",
|
| 150 |
+
});
|
| 151 |
+
expect(mockFetch).not.toHaveBeenCalled();
|
| 152 |
+
});
|
| 153 |
+
|
| 154 |
+
it("does nothing when chatGuid is whitespace", async () => {
|
| 155 |
+
await sendBlueBubblesTyping(" ", false, {
|
| 156 |
+
serverUrl: "http://localhost:1234",
|
| 157 |
+
password: "test",
|
| 158 |
+
});
|
| 159 |
+
expect(mockFetch).not.toHaveBeenCalled();
|
| 160 |
+
});
|
| 161 |
+
|
| 162 |
+
it("throws when serverUrl is missing", async () => {
|
| 163 |
+
await expect(sendBlueBubblesTyping("chat-guid", true, {})).rejects.toThrow(
|
| 164 |
+
"serverUrl is required",
|
| 165 |
+
);
|
| 166 |
+
});
|
| 167 |
+
|
| 168 |
+
it("throws when password is missing", async () => {
|
| 169 |
+
await expect(
|
| 170 |
+
sendBlueBubblesTyping("chat-guid", true, {
|
| 171 |
+
serverUrl: "http://localhost:1234",
|
| 172 |
+
}),
|
| 173 |
+
).rejects.toThrow("password is required");
|
| 174 |
+
});
|
| 175 |
+
|
| 176 |
+
it("sends typing start with POST method", async () => {
|
| 177 |
+
mockFetch.mockResolvedValueOnce({
|
| 178 |
+
ok: true,
|
| 179 |
+
text: () => Promise.resolve(""),
|
| 180 |
+
});
|
| 181 |
+
|
| 182 |
+
await sendBlueBubblesTyping("iMessage;-;+15551234567", true, {
|
| 183 |
+
serverUrl: "http://localhost:1234",
|
| 184 |
+
password: "test",
|
| 185 |
+
});
|
| 186 |
+
|
| 187 |
+
expect(mockFetch).toHaveBeenCalledWith(
|
| 188 |
+
expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing"),
|
| 189 |
+
expect.objectContaining({ method: "POST" }),
|
| 190 |
+
);
|
| 191 |
+
});
|
| 192 |
+
|
| 193 |
+
it("sends typing stop with DELETE method", async () => {
|
| 194 |
+
mockFetch.mockResolvedValueOnce({
|
| 195 |
+
ok: true,
|
| 196 |
+
text: () => Promise.resolve(""),
|
| 197 |
+
});
|
| 198 |
+
|
| 199 |
+
await sendBlueBubblesTyping("iMessage;-;+15551234567", false, {
|
| 200 |
+
serverUrl: "http://localhost:1234",
|
| 201 |
+
password: "test",
|
| 202 |
+
});
|
| 203 |
+
|
| 204 |
+
expect(mockFetch).toHaveBeenCalledWith(
|
| 205 |
+
expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing"),
|
| 206 |
+
expect.objectContaining({ method: "DELETE" }),
|
| 207 |
+
);
|
| 208 |
+
});
|
| 209 |
+
|
| 210 |
+
it("includes password in URL query", async () => {
|
| 211 |
+
mockFetch.mockResolvedValueOnce({
|
| 212 |
+
ok: true,
|
| 213 |
+
text: () => Promise.resolve(""),
|
| 214 |
+
});
|
| 215 |
+
|
| 216 |
+
await sendBlueBubblesTyping("chat-123", true, {
|
| 217 |
+
serverUrl: "http://localhost:1234",
|
| 218 |
+
password: "typing-secret",
|
| 219 |
+
});
|
| 220 |
+
|
| 221 |
+
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
| 222 |
+
expect(calledUrl).toContain("password=typing-secret");
|
| 223 |
+
});
|
| 224 |
+
|
| 225 |
+
it("throws on non-ok response", async () => {
|
| 226 |
+
mockFetch.mockResolvedValueOnce({
|
| 227 |
+
ok: false,
|
| 228 |
+
status: 500,
|
| 229 |
+
text: () => Promise.resolve("Internal error"),
|
| 230 |
+
});
|
| 231 |
+
|
| 232 |
+
await expect(
|
| 233 |
+
sendBlueBubblesTyping("chat-123", true, {
|
| 234 |
+
serverUrl: "http://localhost:1234",
|
| 235 |
+
password: "test",
|
| 236 |
+
}),
|
| 237 |
+
).rejects.toThrow("typing failed (500): Internal error");
|
| 238 |
+
});
|
| 239 |
+
|
| 240 |
+
it("trims chatGuid before using", async () => {
|
| 241 |
+
mockFetch.mockResolvedValueOnce({
|
| 242 |
+
ok: true,
|
| 243 |
+
text: () => Promise.resolve(""),
|
| 244 |
+
});
|
| 245 |
+
|
| 246 |
+
await sendBlueBubblesTyping(" trimmed-chat ", true, {
|
| 247 |
+
serverUrl: "http://localhost:1234",
|
| 248 |
+
password: "test",
|
| 249 |
+
});
|
| 250 |
+
|
| 251 |
+
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
| 252 |
+
expect(calledUrl).toContain("/api/v1/chat/trimmed-chat/typing");
|
| 253 |
+
});
|
| 254 |
+
|
| 255 |
+
it("encodes special characters in chatGuid", async () => {
|
| 256 |
+
mockFetch.mockResolvedValueOnce({
|
| 257 |
+
ok: true,
|
| 258 |
+
text: () => Promise.resolve(""),
|
| 259 |
+
});
|
| 260 |
+
|
| 261 |
+
await sendBlueBubblesTyping("iMessage;+;group@chat.com", true, {
|
| 262 |
+
serverUrl: "http://localhost:1234",
|
| 263 |
+
password: "test",
|
| 264 |
+
});
|
| 265 |
+
|
| 266 |
+
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
| 267 |
+
expect(calledUrl).toContain("iMessage%3B%2B%3Bgroup%40chat.com");
|
| 268 |
+
});
|
| 269 |
+
|
| 270 |
+
it("resolves credentials from config", async () => {
|
| 271 |
+
mockFetch.mockResolvedValueOnce({
|
| 272 |
+
ok: true,
|
| 273 |
+
text: () => Promise.resolve(""),
|
| 274 |
+
});
|
| 275 |
+
|
| 276 |
+
await sendBlueBubblesTyping("chat-123", true, {
|
| 277 |
+
cfg: {
|
| 278 |
+
channels: {
|
| 279 |
+
bluebubbles: {
|
| 280 |
+
serverUrl: "http://typing-server:8888",
|
| 281 |
+
password: "typing-pass",
|
| 282 |
+
},
|
| 283 |
+
},
|
| 284 |
+
},
|
| 285 |
+
});
|
| 286 |
+
|
| 287 |
+
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
| 288 |
+
expect(calledUrl).toContain("typing-server:8888");
|
| 289 |
+
expect(calledUrl).toContain("password=typing-pass");
|
| 290 |
+
});
|
| 291 |
+
|
| 292 |
+
it("can start and stop typing in sequence", async () => {
|
| 293 |
+
mockFetch
|
| 294 |
+
.mockResolvedValueOnce({
|
| 295 |
+
ok: true,
|
| 296 |
+
text: () => Promise.resolve(""),
|
| 297 |
+
})
|
| 298 |
+
.mockResolvedValueOnce({
|
| 299 |
+
ok: true,
|
| 300 |
+
text: () => Promise.resolve(""),
|
| 301 |
+
});
|
| 302 |
+
|
| 303 |
+
await sendBlueBubblesTyping("chat-123", true, {
|
| 304 |
+
serverUrl: "http://localhost:1234",
|
| 305 |
+
password: "test",
|
| 306 |
+
});
|
| 307 |
+
await sendBlueBubblesTyping("chat-123", false, {
|
| 308 |
+
serverUrl: "http://localhost:1234",
|
| 309 |
+
password: "test",
|
| 310 |
+
});
|
| 311 |
+
|
| 312 |
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
| 313 |
+
expect(mockFetch.mock.calls[0][1].method).toBe("POST");
|
| 314 |
+
expect(mockFetch.mock.calls[1][1].method).toBe("DELETE");
|
| 315 |
+
});
|
| 316 |
+
});
|
| 317 |
+
|
| 318 |
+
describe("setGroupIconBlueBubbles", () => {
|
| 319 |
+
it("throws when chatGuid is empty", async () => {
|
| 320 |
+
await expect(
|
| 321 |
+
setGroupIconBlueBubbles("", new Uint8Array([1, 2, 3]), "icon.png", {
|
| 322 |
+
serverUrl: "http://localhost:1234",
|
| 323 |
+
password: "test",
|
| 324 |
+
}),
|
| 325 |
+
).rejects.toThrow("chatGuid");
|
| 326 |
+
});
|
| 327 |
+
|
| 328 |
+
it("throws when buffer is empty", async () => {
|
| 329 |
+
await expect(
|
| 330 |
+
setGroupIconBlueBubbles("chat-guid", new Uint8Array(0), "icon.png", {
|
| 331 |
+
serverUrl: "http://localhost:1234",
|
| 332 |
+
password: "test",
|
| 333 |
+
}),
|
| 334 |
+
).rejects.toThrow("image buffer");
|
| 335 |
+
});
|
| 336 |
+
|
| 337 |
+
it("throws when serverUrl is missing", async () => {
|
| 338 |
+
await expect(
|
| 339 |
+
setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {}),
|
| 340 |
+
).rejects.toThrow("serverUrl is required");
|
| 341 |
+
});
|
| 342 |
+
|
| 343 |
+
it("throws when password is missing", async () => {
|
| 344 |
+
await expect(
|
| 345 |
+
setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {
|
| 346 |
+
serverUrl: "http://localhost:1234",
|
| 347 |
+
}),
|
| 348 |
+
).rejects.toThrow("password is required");
|
| 349 |
+
});
|
| 350 |
+
|
| 351 |
+
it("sets group icon successfully", async () => {
|
| 352 |
+
mockFetch.mockResolvedValueOnce({
|
| 353 |
+
ok: true,
|
| 354 |
+
text: () => Promise.resolve(""),
|
| 355 |
+
});
|
| 356 |
+
|
| 357 |
+
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG magic bytes
|
| 358 |
+
await setGroupIconBlueBubbles("iMessage;-;chat-guid", buffer, "icon.png", {
|
| 359 |
+
serverUrl: "http://localhost:1234",
|
| 360 |
+
password: "test-password",
|
| 361 |
+
contentType: "image/png",
|
| 362 |
+
});
|
| 363 |
+
|
| 364 |
+
expect(mockFetch).toHaveBeenCalledWith(
|
| 365 |
+
expect.stringContaining("/api/v1/chat/iMessage%3B-%3Bchat-guid/icon"),
|
| 366 |
+
expect.objectContaining({
|
| 367 |
+
method: "POST",
|
| 368 |
+
headers: expect.objectContaining({
|
| 369 |
+
"Content-Type": expect.stringContaining("multipart/form-data"),
|
| 370 |
+
}),
|
| 371 |
+
}),
|
| 372 |
+
);
|
| 373 |
+
});
|
| 374 |
+
|
| 375 |
+
it("includes password in URL query", async () => {
|
| 376 |
+
mockFetch.mockResolvedValueOnce({
|
| 377 |
+
ok: true,
|
| 378 |
+
text: () => Promise.resolve(""),
|
| 379 |
+
});
|
| 380 |
+
|
| 381 |
+
await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
|
| 382 |
+
serverUrl: "http://localhost:1234",
|
| 383 |
+
password: "my-secret",
|
| 384 |
+
});
|
| 385 |
+
|
| 386 |
+
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
| 387 |
+
expect(calledUrl).toContain("password=my-secret");
|
| 388 |
+
});
|
| 389 |
+
|
| 390 |
+
it("throws on non-ok response", async () => {
|
| 391 |
+
mockFetch.mockResolvedValueOnce({
|
| 392 |
+
ok: false,
|
| 393 |
+
status: 500,
|
| 394 |
+
text: () => Promise.resolve("Internal error"),
|
| 395 |
+
});
|
| 396 |
+
|
| 397 |
+
await expect(
|
| 398 |
+
setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
|
| 399 |
+
serverUrl: "http://localhost:1234",
|
| 400 |
+
password: "test",
|
| 401 |
+
}),
|
| 402 |
+
).rejects.toThrow("setGroupIcon failed (500): Internal error");
|
| 403 |
+
});
|
| 404 |
+
|
| 405 |
+
it("trims chatGuid before using", async () => {
|
| 406 |
+
mockFetch.mockResolvedValueOnce({
|
| 407 |
+
ok: true,
|
| 408 |
+
text: () => Promise.resolve(""),
|
| 409 |
+
});
|
| 410 |
+
|
| 411 |
+
await setGroupIconBlueBubbles(" chat-with-spaces ", new Uint8Array([1]), "icon.png", {
|
| 412 |
+
serverUrl: "http://localhost:1234",
|
| 413 |
+
password: "test",
|
| 414 |
+
});
|
| 415 |
+
|
| 416 |
+
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
| 417 |
+
expect(calledUrl).toContain("/api/v1/chat/chat-with-spaces/icon");
|
| 418 |
+
expect(calledUrl).not.toContain("%20chat");
|
| 419 |
+
});
|
| 420 |
+
|
| 421 |
+
it("resolves credentials from config", async () => {
|
| 422 |
+
mockFetch.mockResolvedValueOnce({
|
| 423 |
+
ok: true,
|
| 424 |
+
text: () => Promise.resolve(""),
|
| 425 |
+
});
|
| 426 |
+
|
| 427 |
+
await setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", {
|
| 428 |
+
cfg: {
|
| 429 |
+
channels: {
|
| 430 |
+
bluebubbles: {
|
| 431 |
+
serverUrl: "http://config-server:9999",
|
| 432 |
+
password: "config-pass",
|
| 433 |
+
},
|
| 434 |
+
},
|
| 435 |
+
},
|
| 436 |
+
});
|
| 437 |
+
|
| 438 |
+
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
| 439 |
+
expect(calledUrl).toContain("config-server:9999");
|
| 440 |
+
expect(calledUrl).toContain("password=config-pass");
|
| 441 |
+
});
|
| 442 |
+
|
| 443 |
+
it("includes filename in multipart body", async () => {
|
| 444 |
+
mockFetch.mockResolvedValueOnce({
|
| 445 |
+
ok: true,
|
| 446 |
+
text: () => Promise.resolve(""),
|
| 447 |
+
});
|
| 448 |
+
|
| 449 |
+
await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "custom-icon.jpg", {
|
| 450 |
+
serverUrl: "http://localhost:1234",
|
| 451 |
+
password: "test",
|
| 452 |
+
contentType: "image/jpeg",
|
| 453 |
+
});
|
| 454 |
+
|
| 455 |
+
const body = mockFetch.mock.calls[0][1].body as Uint8Array;
|
| 456 |
+
const bodyString = new TextDecoder().decode(body);
|
| 457 |
+
expect(bodyString).toContain('filename="custom-icon.jpg"');
|
| 458 |
+
expect(bodyString).toContain("image/jpeg");
|
| 459 |
+
});
|
| 460 |
+
});
|
| 461 |
+
});
|
extensions/bluebubbles/src/chat.ts
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
| 2 |
+
import crypto from "node:crypto";
|
| 3 |
+
import { resolveBlueBubblesAccount } from "./accounts.js";
|
| 4 |
+
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
| 5 |
+
|
| 6 |
+
export type BlueBubblesChatOpts = {
|
| 7 |
+
serverUrl?: string;
|
| 8 |
+
password?: string;
|
| 9 |
+
accountId?: string;
|
| 10 |
+
timeoutMs?: number;
|
| 11 |
+
cfg?: OpenClawConfig;
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
function resolveAccount(params: BlueBubblesChatOpts) {
|
| 15 |
+
const account = resolveBlueBubblesAccount({
|
| 16 |
+
cfg: params.cfg ?? {},
|
| 17 |
+
accountId: params.accountId,
|
| 18 |
+
});
|
| 19 |
+
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
|
| 20 |
+
const password = params.password?.trim() || account.config.password?.trim();
|
| 21 |
+
if (!baseUrl) {
|
| 22 |
+
throw new Error("BlueBubbles serverUrl is required");
|
| 23 |
+
}
|
| 24 |
+
if (!password) {
|
| 25 |
+
throw new Error("BlueBubbles password is required");
|
| 26 |
+
}
|
| 27 |
+
return { baseUrl, password };
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
export async function markBlueBubblesChatRead(
|
| 31 |
+
chatGuid: string,
|
| 32 |
+
opts: BlueBubblesChatOpts = {},
|
| 33 |
+
): Promise<void> {
|
| 34 |
+
const trimmed = chatGuid.trim();
|
| 35 |
+
if (!trimmed) {
|
| 36 |
+
return;
|
| 37 |
+
}
|
| 38 |
+
const { baseUrl, password } = resolveAccount(opts);
|
| 39 |
+
const url = buildBlueBubblesApiUrl({
|
| 40 |
+
baseUrl,
|
| 41 |
+
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/read`,
|
| 42 |
+
password,
|
| 43 |
+
});
|
| 44 |
+
const res = await blueBubblesFetchWithTimeout(url, { method: "POST" }, opts.timeoutMs);
|
| 45 |
+
if (!res.ok) {
|
| 46 |
+
const errorText = await res.text().catch(() => "");
|
| 47 |
+
throw new Error(`BlueBubbles read failed (${res.status}): ${errorText || "unknown"}`);
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export async function sendBlueBubblesTyping(
|
| 52 |
+
chatGuid: string,
|
| 53 |
+
typing: boolean,
|
| 54 |
+
opts: BlueBubblesChatOpts = {},
|
| 55 |
+
): Promise<void> {
|
| 56 |
+
const trimmed = chatGuid.trim();
|
| 57 |
+
if (!trimmed) {
|
| 58 |
+
return;
|
| 59 |
+
}
|
| 60 |
+
const { baseUrl, password } = resolveAccount(opts);
|
| 61 |
+
const url = buildBlueBubblesApiUrl({
|
| 62 |
+
baseUrl,
|
| 63 |
+
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/typing`,
|
| 64 |
+
password,
|
| 65 |
+
});
|
| 66 |
+
const res = await blueBubblesFetchWithTimeout(
|
| 67 |
+
url,
|
| 68 |
+
{ method: typing ? "POST" : "DELETE" },
|
| 69 |
+
opts.timeoutMs,
|
| 70 |
+
);
|
| 71 |
+
if (!res.ok) {
|
| 72 |
+
const errorText = await res.text().catch(() => "");
|
| 73 |
+
throw new Error(`BlueBubbles typing failed (${res.status}): ${errorText || "unknown"}`);
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
/**
|
| 78 |
+
* Edit a message via BlueBubbles API.
|
| 79 |
+
* Requires macOS 13 (Ventura) or higher with Private API enabled.
|
| 80 |
+
*/
|
| 81 |
+
export async function editBlueBubblesMessage(
|
| 82 |
+
messageGuid: string,
|
| 83 |
+
newText: string,
|
| 84 |
+
opts: BlueBubblesChatOpts & { partIndex?: number; backwardsCompatMessage?: string } = {},
|
| 85 |
+
): Promise<void> {
|
| 86 |
+
const trimmedGuid = messageGuid.trim();
|
| 87 |
+
if (!trimmedGuid) {
|
| 88 |
+
throw new Error("BlueBubbles edit requires messageGuid");
|
| 89 |
+
}
|
| 90 |
+
const trimmedText = newText.trim();
|
| 91 |
+
if (!trimmedText) {
|
| 92 |
+
throw new Error("BlueBubbles edit requires newText");
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
const { baseUrl, password } = resolveAccount(opts);
|
| 96 |
+
const url = buildBlueBubblesApiUrl({
|
| 97 |
+
baseUrl,
|
| 98 |
+
path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/edit`,
|
| 99 |
+
password,
|
| 100 |
+
});
|
| 101 |
+
|
| 102 |
+
const payload = {
|
| 103 |
+
editedMessage: trimmedText,
|
| 104 |
+
backwardsCompatibilityMessage: opts.backwardsCompatMessage ?? `Edited to: ${trimmedText}`,
|
| 105 |
+
partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0,
|
| 106 |
+
};
|
| 107 |
+
|
| 108 |
+
const res = await blueBubblesFetchWithTimeout(
|
| 109 |
+
url,
|
| 110 |
+
{
|
| 111 |
+
method: "POST",
|
| 112 |
+
headers: { "Content-Type": "application/json" },
|
| 113 |
+
body: JSON.stringify(payload),
|
| 114 |
+
},
|
| 115 |
+
opts.timeoutMs,
|
| 116 |
+
);
|
| 117 |
+
|
| 118 |
+
if (!res.ok) {
|
| 119 |
+
const errorText = await res.text().catch(() => "");
|
| 120 |
+
throw new Error(`BlueBubbles edit failed (${res.status}): ${errorText || "unknown"}`);
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
/**
|
| 125 |
+
* Unsend (retract) a message via BlueBubbles API.
|
| 126 |
+
* Requires macOS 13 (Ventura) or higher with Private API enabled.
|
| 127 |
+
*/
|
| 128 |
+
export async function unsendBlueBubblesMessage(
|
| 129 |
+
messageGuid: string,
|
| 130 |
+
opts: BlueBubblesChatOpts & { partIndex?: number } = {},
|
| 131 |
+
): Promise<void> {
|
| 132 |
+
const trimmedGuid = messageGuid.trim();
|
| 133 |
+
if (!trimmedGuid) {
|
| 134 |
+
throw new Error("BlueBubbles unsend requires messageGuid");
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
const { baseUrl, password } = resolveAccount(opts);
|
| 138 |
+
const url = buildBlueBubblesApiUrl({
|
| 139 |
+
baseUrl,
|
| 140 |
+
path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/unsend`,
|
| 141 |
+
password,
|
| 142 |
+
});
|
| 143 |
+
|
| 144 |
+
const payload = {
|
| 145 |
+
partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0,
|
| 146 |
+
};
|
| 147 |
+
|
| 148 |
+
const res = await blueBubblesFetchWithTimeout(
|
| 149 |
+
url,
|
| 150 |
+
{
|
| 151 |
+
method: "POST",
|
| 152 |
+
headers: { "Content-Type": "application/json" },
|
| 153 |
+
body: JSON.stringify(payload),
|
| 154 |
+
},
|
| 155 |
+
opts.timeoutMs,
|
| 156 |
+
);
|
| 157 |
+
|
| 158 |
+
if (!res.ok) {
|
| 159 |
+
const errorText = await res.text().catch(() => "");
|
| 160 |
+
throw new Error(`BlueBubbles unsend failed (${res.status}): ${errorText || "unknown"}`);
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
/**
|
| 165 |
+
* Rename a group chat via BlueBubbles API.
|
| 166 |
+
*/
|
| 167 |
+
export async function renameBlueBubblesChat(
|
| 168 |
+
chatGuid: string,
|
| 169 |
+
displayName: string,
|
| 170 |
+
opts: BlueBubblesChatOpts = {},
|
| 171 |
+
): Promise<void> {
|
| 172 |
+
const trimmedGuid = chatGuid.trim();
|
| 173 |
+
if (!trimmedGuid) {
|
| 174 |
+
throw new Error("BlueBubbles rename requires chatGuid");
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
const { baseUrl, password } = resolveAccount(opts);
|
| 178 |
+
const url = buildBlueBubblesApiUrl({
|
| 179 |
+
baseUrl,
|
| 180 |
+
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}`,
|
| 181 |
+
password,
|
| 182 |
+
});
|
| 183 |
+
|
| 184 |
+
const res = await blueBubblesFetchWithTimeout(
|
| 185 |
+
url,
|
| 186 |
+
{
|
| 187 |
+
method: "PUT",
|
| 188 |
+
headers: { "Content-Type": "application/json" },
|
| 189 |
+
body: JSON.stringify({ displayName }),
|
| 190 |
+
},
|
| 191 |
+
opts.timeoutMs,
|
| 192 |
+
);
|
| 193 |
+
|
| 194 |
+
if (!res.ok) {
|
| 195 |
+
const errorText = await res.text().catch(() => "");
|
| 196 |
+
throw new Error(`BlueBubbles rename failed (${res.status}): ${errorText || "unknown"}`);
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
/**
|
| 201 |
+
* Add a participant to a group chat via BlueBubbles API.
|
| 202 |
+
*/
|
| 203 |
+
export async function addBlueBubblesParticipant(
|
| 204 |
+
chatGuid: string,
|
| 205 |
+
address: string,
|
| 206 |
+
opts: BlueBubblesChatOpts = {},
|
| 207 |
+
): Promise<void> {
|
| 208 |
+
const trimmedGuid = chatGuid.trim();
|
| 209 |
+
if (!trimmedGuid) {
|
| 210 |
+
throw new Error("BlueBubbles addParticipant requires chatGuid");
|
| 211 |
+
}
|
| 212 |
+
const trimmedAddress = address.trim();
|
| 213 |
+
if (!trimmedAddress) {
|
| 214 |
+
throw new Error("BlueBubbles addParticipant requires address");
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
const { baseUrl, password } = resolveAccount(opts);
|
| 218 |
+
const url = buildBlueBubblesApiUrl({
|
| 219 |
+
baseUrl,
|
| 220 |
+
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
|
| 221 |
+
password,
|
| 222 |
+
});
|
| 223 |
+
|
| 224 |
+
const res = await blueBubblesFetchWithTimeout(
|
| 225 |
+
url,
|
| 226 |
+
{
|
| 227 |
+
method: "POST",
|
| 228 |
+
headers: { "Content-Type": "application/json" },
|
| 229 |
+
body: JSON.stringify({ address: trimmedAddress }),
|
| 230 |
+
},
|
| 231 |
+
opts.timeoutMs,
|
| 232 |
+
);
|
| 233 |
+
|
| 234 |
+
if (!res.ok) {
|
| 235 |
+
const errorText = await res.text().catch(() => "");
|
| 236 |
+
throw new Error(`BlueBubbles addParticipant failed (${res.status}): ${errorText || "unknown"}`);
|
| 237 |
+
}
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
/**
|
| 241 |
+
* Remove a participant from a group chat via BlueBubbles API.
|
| 242 |
+
*/
|
| 243 |
+
export async function removeBlueBubblesParticipant(
|
| 244 |
+
chatGuid: string,
|
| 245 |
+
address: string,
|
| 246 |
+
opts: BlueBubblesChatOpts = {},
|
| 247 |
+
): Promise<void> {
|
| 248 |
+
const trimmedGuid = chatGuid.trim();
|
| 249 |
+
if (!trimmedGuid) {
|
| 250 |
+
throw new Error("BlueBubbles removeParticipant requires chatGuid");
|
| 251 |
+
}
|
| 252 |
+
const trimmedAddress = address.trim();
|
| 253 |
+
if (!trimmedAddress) {
|
| 254 |
+
throw new Error("BlueBubbles removeParticipant requires address");
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
const { baseUrl, password } = resolveAccount(opts);
|
| 258 |
+
const url = buildBlueBubblesApiUrl({
|
| 259 |
+
baseUrl,
|
| 260 |
+
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
|
| 261 |
+
password,
|
| 262 |
+
});
|
| 263 |
+
|
| 264 |
+
const res = await blueBubblesFetchWithTimeout(
|
| 265 |
+
url,
|
| 266 |
+
{
|
| 267 |
+
method: "DELETE",
|
| 268 |
+
headers: { "Content-Type": "application/json" },
|
| 269 |
+
body: JSON.stringify({ address: trimmedAddress }),
|
| 270 |
+
},
|
| 271 |
+
opts.timeoutMs,
|
| 272 |
+
);
|
| 273 |
+
|
| 274 |
+
if (!res.ok) {
|
| 275 |
+
const errorText = await res.text().catch(() => "");
|
| 276 |
+
throw new Error(
|
| 277 |
+
`BlueBubbles removeParticipant failed (${res.status}): ${errorText || "unknown"}`,
|
| 278 |
+
);
|
| 279 |
+
}
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
/**
|
| 283 |
+
* Leave a group chat via BlueBubbles API.
|
| 284 |
+
*/
|
| 285 |
+
export async function leaveBlueBubblesChat(
|
| 286 |
+
chatGuid: string,
|
| 287 |
+
opts: BlueBubblesChatOpts = {},
|
| 288 |
+
): Promise<void> {
|
| 289 |
+
const trimmedGuid = chatGuid.trim();
|
| 290 |
+
if (!trimmedGuid) {
|
| 291 |
+
throw new Error("BlueBubbles leaveChat requires chatGuid");
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
const { baseUrl, password } = resolveAccount(opts);
|
| 295 |
+
const url = buildBlueBubblesApiUrl({
|
| 296 |
+
baseUrl,
|
| 297 |
+
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/leave`,
|
| 298 |
+
password,
|
| 299 |
+
});
|
| 300 |
+
|
| 301 |
+
const res = await blueBubblesFetchWithTimeout(url, { method: "POST" }, opts.timeoutMs);
|
| 302 |
+
|
| 303 |
+
if (!res.ok) {
|
| 304 |
+
const errorText = await res.text().catch(() => "");
|
| 305 |
+
throw new Error(`BlueBubbles leaveChat failed (${res.status}): ${errorText || "unknown"}`);
|
| 306 |
+
}
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
/**
|
| 310 |
+
* Set a group chat's icon/photo via BlueBubbles API.
|
| 311 |
+
* Requires Private API to be enabled.
|
| 312 |
+
*/
|
| 313 |
+
export async function setGroupIconBlueBubbles(
|
| 314 |
+
chatGuid: string,
|
| 315 |
+
buffer: Uint8Array,
|
| 316 |
+
filename: string,
|
| 317 |
+
opts: BlueBubblesChatOpts & { contentType?: string } = {},
|
| 318 |
+
): Promise<void> {
|
| 319 |
+
const trimmedGuid = chatGuid.trim();
|
| 320 |
+
if (!trimmedGuid) {
|
| 321 |
+
throw new Error("BlueBubbles setGroupIcon requires chatGuid");
|
| 322 |
+
}
|
| 323 |
+
if (!buffer || buffer.length === 0) {
|
| 324 |
+
throw new Error("BlueBubbles setGroupIcon requires image buffer");
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
const { baseUrl, password } = resolveAccount(opts);
|
| 328 |
+
const url = buildBlueBubblesApiUrl({
|
| 329 |
+
baseUrl,
|
| 330 |
+
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/icon`,
|
| 331 |
+
password,
|
| 332 |
+
});
|
| 333 |
+
|
| 334 |
+
// Build multipart form-data
|
| 335 |
+
const boundary = `----BlueBubblesFormBoundary${crypto.randomUUID().replace(/-/g, "")}`;
|
| 336 |
+
const parts: Uint8Array[] = [];
|
| 337 |
+
const encoder = new TextEncoder();
|
| 338 |
+
|
| 339 |
+
// Add file field named "icon" as per API spec
|
| 340 |
+
parts.push(encoder.encode(`--${boundary}\r\n`));
|
| 341 |
+
parts.push(
|
| 342 |
+
encoder.encode(`Content-Disposition: form-data; name="icon"; filename="${filename}"\r\n`),
|
| 343 |
+
);
|
| 344 |
+
parts.push(
|
| 345 |
+
encoder.encode(`Content-Type: ${opts.contentType ?? "application/octet-stream"}\r\n\r\n`),
|
| 346 |
+
);
|
| 347 |
+
parts.push(buffer);
|
| 348 |
+
parts.push(encoder.encode("\r\n"));
|
| 349 |
+
|
| 350 |
+
// Close multipart body
|
| 351 |
+
parts.push(encoder.encode(`--${boundary}--\r\n`));
|
| 352 |
+
|
| 353 |
+
// Combine into single buffer
|
| 354 |
+
const totalLength = parts.reduce((acc, part) => acc + part.length, 0);
|
| 355 |
+
const body = new Uint8Array(totalLength);
|
| 356 |
+
let offset = 0;
|
| 357 |
+
for (const part of parts) {
|
| 358 |
+
body.set(part, offset);
|
| 359 |
+
offset += part.length;
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
const res = await blueBubblesFetchWithTimeout(
|
| 363 |
+
url,
|
| 364 |
+
{
|
| 365 |
+
method: "POST",
|
| 366 |
+
headers: {
|
| 367 |
+
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
| 368 |
+
},
|
| 369 |
+
body,
|
| 370 |
+
},
|
| 371 |
+
opts.timeoutMs ?? 60_000, // longer timeout for file uploads
|
| 372 |
+
);
|
| 373 |
+
|
| 374 |
+
if (!res.ok) {
|
| 375 |
+
const errorText = await res.text().catch(() => "");
|
| 376 |
+
throw new Error(`BlueBubbles setGroupIcon failed (${res.status}): ${errorText || "unknown"}`);
|
| 377 |
+
}
|
| 378 |
+
}
|
extensions/bluebubbles/src/config-schema.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk";
|
| 2 |
+
import { z } from "zod";
|
| 3 |
+
|
| 4 |
+
const allowFromEntry = z.union([z.string(), z.number()]);
|
| 5 |
+
|
| 6 |
+
const bluebubblesActionSchema = z
|
| 7 |
+
.object({
|
| 8 |
+
reactions: z.boolean().default(true),
|
| 9 |
+
edit: z.boolean().default(true),
|
| 10 |
+
unsend: z.boolean().default(true),
|
| 11 |
+
reply: z.boolean().default(true),
|
| 12 |
+
sendWithEffect: z.boolean().default(true),
|
| 13 |
+
renameGroup: z.boolean().default(true),
|
| 14 |
+
setGroupIcon: z.boolean().default(true),
|
| 15 |
+
addParticipant: z.boolean().default(true),
|
| 16 |
+
removeParticipant: z.boolean().default(true),
|
| 17 |
+
leaveGroup: z.boolean().default(true),
|
| 18 |
+
sendAttachment: z.boolean().default(true),
|
| 19 |
+
})
|
| 20 |
+
.optional();
|
| 21 |
+
|
| 22 |
+
const bluebubblesGroupConfigSchema = z.object({
|
| 23 |
+
requireMention: z.boolean().optional(),
|
| 24 |
+
tools: ToolPolicySchema,
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
const bluebubblesAccountSchema = z.object({
|
| 28 |
+
name: z.string().optional(),
|
| 29 |
+
enabled: z.boolean().optional(),
|
| 30 |
+
markdown: MarkdownConfigSchema,
|
| 31 |
+
serverUrl: z.string().optional(),
|
| 32 |
+
password: z.string().optional(),
|
| 33 |
+
webhookPath: z.string().optional(),
|
| 34 |
+
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
| 35 |
+
allowFrom: z.array(allowFromEntry).optional(),
|
| 36 |
+
groupAllowFrom: z.array(allowFromEntry).optional(),
|
| 37 |
+
groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
|
| 38 |
+
historyLimit: z.number().int().min(0).optional(),
|
| 39 |
+
dmHistoryLimit: z.number().int().min(0).optional(),
|
| 40 |
+
textChunkLimit: z.number().int().positive().optional(),
|
| 41 |
+
chunkMode: z.enum(["length", "newline"]).optional(),
|
| 42 |
+
mediaMaxMb: z.number().int().positive().optional(),
|
| 43 |
+
sendReadReceipts: z.boolean().optional(),
|
| 44 |
+
blockStreaming: z.boolean().optional(),
|
| 45 |
+
groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(),
|
| 46 |
+
});
|
| 47 |
+
|
| 48 |
+
export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({
|
| 49 |
+
accounts: z.object({}).catchall(bluebubblesAccountSchema).optional(),
|
| 50 |
+
actions: bluebubblesActionSchema,
|
| 51 |
+
});
|
extensions/bluebubbles/src/media-send.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import path from "node:path";
|
| 2 |
+
import { fileURLToPath } from "node:url";
|
| 3 |
+
import { resolveChannelMediaMaxBytes, type OpenClawConfig } from "openclaw/plugin-sdk";
|
| 4 |
+
import { sendBlueBubblesAttachment } from "./attachments.js";
|
| 5 |
+
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
| 6 |
+
import { getBlueBubblesRuntime } from "./runtime.js";
|
| 7 |
+
import { sendMessageBlueBubbles } from "./send.js";
|
| 8 |
+
|
| 9 |
+
const HTTP_URL_RE = /^https?:\/\//i;
|
| 10 |
+
const MB = 1024 * 1024;
|
| 11 |
+
|
| 12 |
+
function assertMediaWithinLimit(sizeBytes: number, maxBytes?: number): void {
|
| 13 |
+
if (typeof maxBytes !== "number" || maxBytes <= 0) {
|
| 14 |
+
return;
|
| 15 |
+
}
|
| 16 |
+
if (sizeBytes <= maxBytes) {
|
| 17 |
+
return;
|
| 18 |
+
}
|
| 19 |
+
const maxLabel = (maxBytes / MB).toFixed(0);
|
| 20 |
+
const sizeLabel = (sizeBytes / MB).toFixed(2);
|
| 21 |
+
throw new Error(`Media exceeds ${maxLabel}MB limit (got ${sizeLabel}MB)`);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function resolveLocalMediaPath(source: string): string {
|
| 25 |
+
if (!source.startsWith("file://")) {
|
| 26 |
+
return source;
|
| 27 |
+
}
|
| 28 |
+
try {
|
| 29 |
+
return fileURLToPath(source);
|
| 30 |
+
} catch {
|
| 31 |
+
throw new Error(`Invalid file:// URL: ${source}`);
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
function resolveFilenameFromSource(source?: string): string | undefined {
|
| 36 |
+
if (!source) {
|
| 37 |
+
return undefined;
|
| 38 |
+
}
|
| 39 |
+
if (source.startsWith("file://")) {
|
| 40 |
+
try {
|
| 41 |
+
return path.basename(fileURLToPath(source)) || undefined;
|
| 42 |
+
} catch {
|
| 43 |
+
return undefined;
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
if (HTTP_URL_RE.test(source)) {
|
| 47 |
+
try {
|
| 48 |
+
return path.basename(new URL(source).pathname) || undefined;
|
| 49 |
+
} catch {
|
| 50 |
+
return undefined;
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
const base = path.basename(source);
|
| 54 |
+
return base || undefined;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
export async function sendBlueBubblesMedia(params: {
|
| 58 |
+
cfg: OpenClawConfig;
|
| 59 |
+
to: string;
|
| 60 |
+
mediaUrl?: string;
|
| 61 |
+
mediaPath?: string;
|
| 62 |
+
mediaBuffer?: Uint8Array;
|
| 63 |
+
contentType?: string;
|
| 64 |
+
filename?: string;
|
| 65 |
+
caption?: string;
|
| 66 |
+
replyToId?: string | null;
|
| 67 |
+
accountId?: string;
|
| 68 |
+
asVoice?: boolean;
|
| 69 |
+
}) {
|
| 70 |
+
const {
|
| 71 |
+
cfg,
|
| 72 |
+
to,
|
| 73 |
+
mediaUrl,
|
| 74 |
+
mediaPath,
|
| 75 |
+
mediaBuffer,
|
| 76 |
+
contentType,
|
| 77 |
+
filename,
|
| 78 |
+
caption,
|
| 79 |
+
replyToId,
|
| 80 |
+
accountId,
|
| 81 |
+
asVoice,
|
| 82 |
+
} = params;
|
| 83 |
+
const core = getBlueBubblesRuntime();
|
| 84 |
+
const maxBytes = resolveChannelMediaMaxBytes({
|
| 85 |
+
cfg,
|
| 86 |
+
resolveChannelLimitMb: ({ cfg, accountId }) =>
|
| 87 |
+
cfg.channels?.bluebubbles?.accounts?.[accountId]?.mediaMaxMb ??
|
| 88 |
+
cfg.channels?.bluebubbles?.mediaMaxMb,
|
| 89 |
+
accountId,
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
let buffer: Uint8Array;
|
| 93 |
+
let resolvedContentType = contentType ?? undefined;
|
| 94 |
+
let resolvedFilename = filename ?? undefined;
|
| 95 |
+
|
| 96 |
+
if (mediaBuffer) {
|
| 97 |
+
assertMediaWithinLimit(mediaBuffer.byteLength, maxBytes);
|
| 98 |
+
buffer = mediaBuffer;
|
| 99 |
+
if (!resolvedContentType) {
|
| 100 |
+
const hint = mediaPath ?? mediaUrl;
|
| 101 |
+
const detected = await core.media.detectMime({
|
| 102 |
+
buffer: Buffer.isBuffer(mediaBuffer) ? mediaBuffer : Buffer.from(mediaBuffer),
|
| 103 |
+
filePath: hint,
|
| 104 |
+
});
|
| 105 |
+
resolvedContentType = detected ?? undefined;
|
| 106 |
+
}
|
| 107 |
+
if (!resolvedFilename) {
|
| 108 |
+
resolvedFilename = resolveFilenameFromSource(mediaPath ?? mediaUrl);
|
| 109 |
+
}
|
| 110 |
+
} else {
|
| 111 |
+
const source = mediaPath ?? mediaUrl;
|
| 112 |
+
if (!source) {
|
| 113 |
+
throw new Error("BlueBubbles media delivery requires mediaUrl, mediaPath, or mediaBuffer.");
|
| 114 |
+
}
|
| 115 |
+
if (HTTP_URL_RE.test(source)) {
|
| 116 |
+
const fetched = await core.channel.media.fetchRemoteMedia({
|
| 117 |
+
url: source,
|
| 118 |
+
maxBytes: typeof maxBytes === "number" && maxBytes > 0 ? maxBytes : undefined,
|
| 119 |
+
});
|
| 120 |
+
buffer = fetched.buffer;
|
| 121 |
+
resolvedContentType = resolvedContentType ?? fetched.contentType ?? undefined;
|
| 122 |
+
resolvedFilename = resolvedFilename ?? fetched.fileName;
|
| 123 |
+
} else {
|
| 124 |
+
const localPath = resolveLocalMediaPath(source);
|
| 125 |
+
const fs = await import("node:fs/promises");
|
| 126 |
+
if (typeof maxBytes === "number" && maxBytes > 0) {
|
| 127 |
+
const stats = await fs.stat(localPath);
|
| 128 |
+
assertMediaWithinLimit(stats.size, maxBytes);
|
| 129 |
+
}
|
| 130 |
+
const data = await fs.readFile(localPath);
|
| 131 |
+
assertMediaWithinLimit(data.byteLength, maxBytes);
|
| 132 |
+
buffer = new Uint8Array(data);
|
| 133 |
+
if (!resolvedContentType) {
|
| 134 |
+
const detected = await core.media.detectMime({
|
| 135 |
+
buffer: data,
|
| 136 |
+
filePath: localPath,
|
| 137 |
+
});
|
| 138 |
+
resolvedContentType = detected ?? undefined;
|
| 139 |
+
}
|
| 140 |
+
if (!resolvedFilename) {
|
| 141 |
+
resolvedFilename = resolveFilenameFromSource(localPath);
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
// Resolve short ID (e.g., "5") to full UUID
|
| 147 |
+
const replyToMessageGuid = replyToId?.trim()
|
| 148 |
+
? resolveBlueBubblesMessageId(replyToId.trim(), { requireKnownShortId: true })
|
| 149 |
+
: undefined;
|
| 150 |
+
|
| 151 |
+
const attachmentResult = await sendBlueBubblesAttachment({
|
| 152 |
+
to,
|
| 153 |
+
buffer,
|
| 154 |
+
filename: resolvedFilename ?? "attachment",
|
| 155 |
+
contentType: resolvedContentType ?? undefined,
|
| 156 |
+
replyToMessageGuid,
|
| 157 |
+
asVoice,
|
| 158 |
+
opts: {
|
| 159 |
+
cfg,
|
| 160 |
+
accountId,
|
| 161 |
+
},
|
| 162 |
+
});
|
| 163 |
+
|
| 164 |
+
const trimmedCaption = caption?.trim();
|
| 165 |
+
if (trimmedCaption) {
|
| 166 |
+
await sendMessageBlueBubbles(to, trimmedCaption, {
|
| 167 |
+
cfg,
|
| 168 |
+
accountId,
|
| 169 |
+
replyToMessageGuid,
|
| 170 |
+
});
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
return attachmentResult;
|
| 174 |
+
}
|
extensions/bluebubbles/src/monitor.test.ts
ADDED
|
@@ -0,0 +1,2340 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
| 2 |
+
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
| 3 |
+
import { EventEmitter } from "node:events";
|
| 4 |
+
import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk";
|
| 5 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 6 |
+
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
| 7 |
+
import {
|
| 8 |
+
handleBlueBubblesWebhookRequest,
|
| 9 |
+
registerBlueBubblesWebhookTarget,
|
| 10 |
+
resolveBlueBubblesMessageId,
|
| 11 |
+
_resetBlueBubblesShortIdState,
|
| 12 |
+
} from "./monitor.js";
|
| 13 |
+
import { setBlueBubblesRuntime } from "./runtime.js";
|
| 14 |
+
|
| 15 |
+
// Mock dependencies
|
| 16 |
+
vi.mock("./send.js", () => ({
|
| 17 |
+
resolveChatGuidForTarget: vi.fn().mockResolvedValue("iMessage;-;+15551234567"),
|
| 18 |
+
sendMessageBlueBubbles: vi.fn().mockResolvedValue({ messageId: "msg-123" }),
|
| 19 |
+
}));
|
| 20 |
+
|
| 21 |
+
vi.mock("./chat.js", () => ({
|
| 22 |
+
markBlueBubblesChatRead: vi.fn().mockResolvedValue(undefined),
|
| 23 |
+
sendBlueBubblesTyping: vi.fn().mockResolvedValue(undefined),
|
| 24 |
+
}));
|
| 25 |
+
|
| 26 |
+
vi.mock("./attachments.js", () => ({
|
| 27 |
+
downloadBlueBubblesAttachment: vi.fn().mockResolvedValue({
|
| 28 |
+
buffer: Buffer.from("test"),
|
| 29 |
+
contentType: "image/jpeg",
|
| 30 |
+
}),
|
| 31 |
+
}));
|
| 32 |
+
|
| 33 |
+
vi.mock("./reactions.js", async () => {
|
| 34 |
+
const actual = await vi.importActual<typeof import("./reactions.js")>("./reactions.js");
|
| 35 |
+
return {
|
| 36 |
+
...actual,
|
| 37 |
+
sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined),
|
| 38 |
+
};
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
// Mock runtime
|
| 42 |
+
const mockEnqueueSystemEvent = vi.fn();
|
| 43 |
+
const mockBuildPairingReply = vi.fn(() => "Pairing code: TESTCODE");
|
| 44 |
+
const mockReadAllowFromStore = vi.fn().mockResolvedValue([]);
|
| 45 |
+
const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "TESTCODE", created: true });
|
| 46 |
+
const mockResolveAgentRoute = vi.fn(() => ({
|
| 47 |
+
agentId: "main",
|
| 48 |
+
accountId: "default",
|
| 49 |
+
sessionKey: "agent:main:bluebubbles:dm:+15551234567",
|
| 50 |
+
}));
|
| 51 |
+
const mockBuildMentionRegexes = vi.fn(() => [/\bbert\b/i]);
|
| 52 |
+
const mockMatchesMentionPatterns = vi.fn((text: string, regexes: RegExp[]) =>
|
| 53 |
+
regexes.some((r) => r.test(text)),
|
| 54 |
+
);
|
| 55 |
+
const mockResolveRequireMention = vi.fn(() => false);
|
| 56 |
+
const mockResolveGroupPolicy = vi.fn(() => "open");
|
| 57 |
+
const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn(async () => undefined);
|
| 58 |
+
const mockHasControlCommand = vi.fn(() => false);
|
| 59 |
+
const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
|
| 60 |
+
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
|
| 61 |
+
path: "/tmp/test-media.jpg",
|
| 62 |
+
contentType: "image/jpeg",
|
| 63 |
+
});
|
| 64 |
+
const mockResolveStorePath = vi.fn(() => "/tmp/sessions.json");
|
| 65 |
+
const mockReadSessionUpdatedAt = vi.fn(() => undefined);
|
| 66 |
+
const mockResolveEnvelopeFormatOptions = vi.fn(() => ({
|
| 67 |
+
template: "channel+name+time",
|
| 68 |
+
}));
|
| 69 |
+
const mockFormatAgentEnvelope = vi.fn((opts: { body: string }) => opts.body);
|
| 70 |
+
const mockChunkMarkdownText = vi.fn((text: string) => [text]);
|
| 71 |
+
|
| 72 |
+
function createMockRuntime(): PluginRuntime {
|
| 73 |
+
return {
|
| 74 |
+
version: "1.0.0",
|
| 75 |
+
config: {
|
| 76 |
+
loadConfig: vi.fn(() => ({})) as unknown as PluginRuntime["config"]["loadConfig"],
|
| 77 |
+
writeConfigFile: vi.fn() as unknown as PluginRuntime["config"]["writeConfigFile"],
|
| 78 |
+
},
|
| 79 |
+
system: {
|
| 80 |
+
enqueueSystemEvent:
|
| 81 |
+
mockEnqueueSystemEvent as unknown as PluginRuntime["system"]["enqueueSystemEvent"],
|
| 82 |
+
runCommandWithTimeout: vi.fn() as unknown as PluginRuntime["system"]["runCommandWithTimeout"],
|
| 83 |
+
},
|
| 84 |
+
media: {
|
| 85 |
+
loadWebMedia: vi.fn() as unknown as PluginRuntime["media"]["loadWebMedia"],
|
| 86 |
+
detectMime: vi.fn() as unknown as PluginRuntime["media"]["detectMime"],
|
| 87 |
+
mediaKindFromMime: vi.fn() as unknown as PluginRuntime["media"]["mediaKindFromMime"],
|
| 88 |
+
isVoiceCompatibleAudio:
|
| 89 |
+
vi.fn() as unknown as PluginRuntime["media"]["isVoiceCompatibleAudio"],
|
| 90 |
+
getImageMetadata: vi.fn() as unknown as PluginRuntime["media"]["getImageMetadata"],
|
| 91 |
+
resizeToJpeg: vi.fn() as unknown as PluginRuntime["media"]["resizeToJpeg"],
|
| 92 |
+
},
|
| 93 |
+
tools: {
|
| 94 |
+
createMemoryGetTool: vi.fn() as unknown as PluginRuntime["tools"]["createMemoryGetTool"],
|
| 95 |
+
createMemorySearchTool:
|
| 96 |
+
vi.fn() as unknown as PluginRuntime["tools"]["createMemorySearchTool"],
|
| 97 |
+
registerMemoryCli: vi.fn() as unknown as PluginRuntime["tools"]["registerMemoryCli"],
|
| 98 |
+
},
|
| 99 |
+
channel: {
|
| 100 |
+
text: {
|
| 101 |
+
chunkMarkdownText:
|
| 102 |
+
mockChunkMarkdownText as unknown as PluginRuntime["channel"]["text"]["chunkMarkdownText"],
|
| 103 |
+
chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"],
|
| 104 |
+
resolveTextChunkLimit: vi.fn(
|
| 105 |
+
() => 4000,
|
| 106 |
+
) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"],
|
| 107 |
+
hasControlCommand:
|
| 108 |
+
mockHasControlCommand as unknown as PluginRuntime["channel"]["text"]["hasControlCommand"],
|
| 109 |
+
resolveMarkdownTableMode: vi.fn(
|
| 110 |
+
() => "code",
|
| 111 |
+
) as unknown as PluginRuntime["channel"]["text"]["resolveMarkdownTableMode"],
|
| 112 |
+
convertMarkdownTables: vi.fn(
|
| 113 |
+
(text: string) => text,
|
| 114 |
+
) as unknown as PluginRuntime["channel"]["text"]["convertMarkdownTables"],
|
| 115 |
+
},
|
| 116 |
+
reply: {
|
| 117 |
+
dispatchReplyWithBufferedBlockDispatcher:
|
| 118 |
+
mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
|
| 119 |
+
createReplyDispatcherWithTyping:
|
| 120 |
+
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["createReplyDispatcherWithTyping"],
|
| 121 |
+
resolveEffectiveMessagesConfig:
|
| 122 |
+
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveEffectiveMessagesConfig"],
|
| 123 |
+
resolveHumanDelayConfig:
|
| 124 |
+
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveHumanDelayConfig"],
|
| 125 |
+
dispatchReplyFromConfig:
|
| 126 |
+
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"],
|
| 127 |
+
finalizeInboundContext:
|
| 128 |
+
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
| 129 |
+
formatAgentEnvelope:
|
| 130 |
+
mockFormatAgentEnvelope as unknown as PluginRuntime["channel"]["reply"]["formatAgentEnvelope"],
|
| 131 |
+
formatInboundEnvelope:
|
| 132 |
+
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["formatInboundEnvelope"],
|
| 133 |
+
resolveEnvelopeFormatOptions:
|
| 134 |
+
mockResolveEnvelopeFormatOptions as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
|
| 135 |
+
},
|
| 136 |
+
routing: {
|
| 137 |
+
resolveAgentRoute:
|
| 138 |
+
mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
|
| 139 |
+
},
|
| 140 |
+
pairing: {
|
| 141 |
+
buildPairingReply:
|
| 142 |
+
mockBuildPairingReply as unknown as PluginRuntime["channel"]["pairing"]["buildPairingReply"],
|
| 143 |
+
readAllowFromStore:
|
| 144 |
+
mockReadAllowFromStore as unknown as PluginRuntime["channel"]["pairing"]["readAllowFromStore"],
|
| 145 |
+
upsertPairingRequest:
|
| 146 |
+
mockUpsertPairingRequest as unknown as PluginRuntime["channel"]["pairing"]["upsertPairingRequest"],
|
| 147 |
+
},
|
| 148 |
+
media: {
|
| 149 |
+
fetchRemoteMedia:
|
| 150 |
+
vi.fn() as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
|
| 151 |
+
saveMediaBuffer:
|
| 152 |
+
mockSaveMediaBuffer as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
|
| 153 |
+
},
|
| 154 |
+
session: {
|
| 155 |
+
resolveStorePath:
|
| 156 |
+
mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
|
| 157 |
+
readSessionUpdatedAt:
|
| 158 |
+
mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
|
| 159 |
+
recordInboundSession:
|
| 160 |
+
vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"],
|
| 161 |
+
recordSessionMetaFromInbound:
|
| 162 |
+
vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordSessionMetaFromInbound"],
|
| 163 |
+
updateLastRoute:
|
| 164 |
+
vi.fn() as unknown as PluginRuntime["channel"]["session"]["updateLastRoute"],
|
| 165 |
+
},
|
| 166 |
+
mentions: {
|
| 167 |
+
buildMentionRegexes:
|
| 168 |
+
mockBuildMentionRegexes as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"],
|
| 169 |
+
matchesMentionPatterns:
|
| 170 |
+
mockMatchesMentionPatterns as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"],
|
| 171 |
+
},
|
| 172 |
+
reactions: {
|
| 173 |
+
shouldAckReaction,
|
| 174 |
+
removeAckReactionAfterReply,
|
| 175 |
+
},
|
| 176 |
+
groups: {
|
| 177 |
+
resolveGroupPolicy:
|
| 178 |
+
mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
|
| 179 |
+
resolveRequireMention:
|
| 180 |
+
mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"],
|
| 181 |
+
},
|
| 182 |
+
debounce: {
|
| 183 |
+
// Create a pass-through debouncer that immediately calls onFlush
|
| 184 |
+
createInboundDebouncer: vi.fn(
|
| 185 |
+
(params: { onFlush: (items: unknown[]) => Promise<void> }) => ({
|
| 186 |
+
enqueue: async (item: unknown) => {
|
| 187 |
+
await params.onFlush([item]);
|
| 188 |
+
},
|
| 189 |
+
flushKey: vi.fn(),
|
| 190 |
+
}),
|
| 191 |
+
) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"],
|
| 192 |
+
resolveInboundDebounceMs: vi.fn(
|
| 193 |
+
() => 0,
|
| 194 |
+
) as unknown as PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"],
|
| 195 |
+
},
|
| 196 |
+
commands: {
|
| 197 |
+
resolveCommandAuthorizedFromAuthorizers:
|
| 198 |
+
mockResolveCommandAuthorizedFromAuthorizers as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"],
|
| 199 |
+
isControlCommandMessage:
|
| 200 |
+
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["isControlCommandMessage"],
|
| 201 |
+
shouldComputeCommandAuthorized:
|
| 202 |
+
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"],
|
| 203 |
+
shouldHandleTextCommands:
|
| 204 |
+
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldHandleTextCommands"],
|
| 205 |
+
},
|
| 206 |
+
discord: {} as PluginRuntime["channel"]["discord"],
|
| 207 |
+
slack: {} as PluginRuntime["channel"]["slack"],
|
| 208 |
+
telegram: {} as PluginRuntime["channel"]["telegram"],
|
| 209 |
+
signal: {} as PluginRuntime["channel"]["signal"],
|
| 210 |
+
imessage: {} as PluginRuntime["channel"]["imessage"],
|
| 211 |
+
whatsapp: {} as PluginRuntime["channel"]["whatsapp"],
|
| 212 |
+
},
|
| 213 |
+
logging: {
|
| 214 |
+
shouldLogVerbose: vi.fn(
|
| 215 |
+
() => false,
|
| 216 |
+
) as unknown as PluginRuntime["logging"]["shouldLogVerbose"],
|
| 217 |
+
getChildLogger: vi.fn(() => ({
|
| 218 |
+
info: vi.fn(),
|
| 219 |
+
warn: vi.fn(),
|
| 220 |
+
error: vi.fn(),
|
| 221 |
+
debug: vi.fn(),
|
| 222 |
+
})) as unknown as PluginRuntime["logging"]["getChildLogger"],
|
| 223 |
+
},
|
| 224 |
+
state: {
|
| 225 |
+
resolveStateDir: vi.fn(
|
| 226 |
+
() => "/tmp/openclaw",
|
| 227 |
+
) as unknown as PluginRuntime["state"]["resolveStateDir"],
|
| 228 |
+
},
|
| 229 |
+
};
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
function createMockAccount(
|
| 233 |
+
overrides: Partial<ResolvedBlueBubblesAccount["config"]> = {},
|
| 234 |
+
): ResolvedBlueBubblesAccount {
|
| 235 |
+
return {
|
| 236 |
+
accountId: "default",
|
| 237 |
+
enabled: true,
|
| 238 |
+
configured: true,
|
| 239 |
+
config: {
|
| 240 |
+
serverUrl: "http://localhost:1234",
|
| 241 |
+
password: "test-password",
|
| 242 |
+
dmPolicy: "open",
|
| 243 |
+
groupPolicy: "open",
|
| 244 |
+
allowFrom: [],
|
| 245 |
+
groupAllowFrom: [],
|
| 246 |
+
...overrides,
|
| 247 |
+
},
|
| 248 |
+
};
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
function createMockRequest(
|
| 252 |
+
method: string,
|
| 253 |
+
url: string,
|
| 254 |
+
body: unknown,
|
| 255 |
+
headers: Record<string, string> = {},
|
| 256 |
+
): IncomingMessage {
|
| 257 |
+
const req = new EventEmitter() as IncomingMessage;
|
| 258 |
+
req.method = method;
|
| 259 |
+
req.url = url;
|
| 260 |
+
req.headers = headers;
|
| 261 |
+
(req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "127.0.0.1" };
|
| 262 |
+
|
| 263 |
+
// Emit body data after a microtask
|
| 264 |
+
// oxlint-disable-next-line no-floating-promises
|
| 265 |
+
Promise.resolve().then(() => {
|
| 266 |
+
const bodyStr = typeof body === "string" ? body : JSON.stringify(body);
|
| 267 |
+
req.emit("data", Buffer.from(bodyStr));
|
| 268 |
+
req.emit("end");
|
| 269 |
+
});
|
| 270 |
+
|
| 271 |
+
return req;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
function createMockResponse(): ServerResponse & { body: string; statusCode: number } {
|
| 275 |
+
const res = {
|
| 276 |
+
statusCode: 200,
|
| 277 |
+
body: "",
|
| 278 |
+
setHeader: vi.fn(),
|
| 279 |
+
end: vi.fn((data?: string) => {
|
| 280 |
+
res.body = data ?? "";
|
| 281 |
+
}),
|
| 282 |
+
} as unknown as ServerResponse & { body: string; statusCode: number };
|
| 283 |
+
return res;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
const flushAsync = async () => {
|
| 287 |
+
for (let i = 0; i < 2; i += 1) {
|
| 288 |
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
| 289 |
+
}
|
| 290 |
+
};
|
| 291 |
+
|
| 292 |
+
describe("BlueBubbles webhook monitor", () => {
|
| 293 |
+
let unregister: () => void;
|
| 294 |
+
|
| 295 |
+
beforeEach(() => {
|
| 296 |
+
vi.clearAllMocks();
|
| 297 |
+
// Reset short ID state between tests for predictable behavior
|
| 298 |
+
_resetBlueBubblesShortIdState();
|
| 299 |
+
mockReadAllowFromStore.mockResolvedValue([]);
|
| 300 |
+
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true });
|
| 301 |
+
mockResolveRequireMention.mockReturnValue(false);
|
| 302 |
+
mockHasControlCommand.mockReturnValue(false);
|
| 303 |
+
mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(false);
|
| 304 |
+
mockBuildMentionRegexes.mockReturnValue([/\bbert\b/i]);
|
| 305 |
+
|
| 306 |
+
setBlueBubblesRuntime(createMockRuntime());
|
| 307 |
+
});
|
| 308 |
+
|
| 309 |
+
afterEach(() => {
|
| 310 |
+
unregister?.();
|
| 311 |
+
});
|
| 312 |
+
|
| 313 |
+
describe("webhook parsing + auth handling", () => {
|
| 314 |
+
it("rejects non-POST requests", async () => {
|
| 315 |
+
const account = createMockAccount();
|
| 316 |
+
const config: OpenClawConfig = {};
|
| 317 |
+
const core = createMockRuntime();
|
| 318 |
+
setBlueBubblesRuntime(core);
|
| 319 |
+
|
| 320 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 321 |
+
account,
|
| 322 |
+
config,
|
| 323 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 324 |
+
core,
|
| 325 |
+
path: "/bluebubbles-webhook",
|
| 326 |
+
});
|
| 327 |
+
|
| 328 |
+
const req = createMockRequest("GET", "/bluebubbles-webhook", {});
|
| 329 |
+
const res = createMockResponse();
|
| 330 |
+
|
| 331 |
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
| 332 |
+
|
| 333 |
+
expect(handled).toBe(true);
|
| 334 |
+
expect(res.statusCode).toBe(405);
|
| 335 |
+
});
|
| 336 |
+
|
| 337 |
+
it("accepts POST requests with valid JSON payload", async () => {
|
| 338 |
+
const account = createMockAccount();
|
| 339 |
+
const config: OpenClawConfig = {};
|
| 340 |
+
const core = createMockRuntime();
|
| 341 |
+
setBlueBubblesRuntime(core);
|
| 342 |
+
|
| 343 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 344 |
+
account,
|
| 345 |
+
config,
|
| 346 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 347 |
+
core,
|
| 348 |
+
path: "/bluebubbles-webhook",
|
| 349 |
+
});
|
| 350 |
+
|
| 351 |
+
const payload = {
|
| 352 |
+
type: "new-message",
|
| 353 |
+
data: {
|
| 354 |
+
text: "hello",
|
| 355 |
+
handle: { address: "+15551234567" },
|
| 356 |
+
isGroup: false,
|
| 357 |
+
isFromMe: false,
|
| 358 |
+
guid: "msg-1",
|
| 359 |
+
date: Date.now(),
|
| 360 |
+
},
|
| 361 |
+
};
|
| 362 |
+
|
| 363 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 364 |
+
const res = createMockResponse();
|
| 365 |
+
|
| 366 |
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
| 367 |
+
|
| 368 |
+
expect(handled).toBe(true);
|
| 369 |
+
expect(res.statusCode).toBe(200);
|
| 370 |
+
expect(res.body).toBe("ok");
|
| 371 |
+
});
|
| 372 |
+
|
| 373 |
+
it("rejects requests with invalid JSON", async () => {
|
| 374 |
+
const account = createMockAccount();
|
| 375 |
+
const config: OpenClawConfig = {};
|
| 376 |
+
const core = createMockRuntime();
|
| 377 |
+
setBlueBubblesRuntime(core);
|
| 378 |
+
|
| 379 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 380 |
+
account,
|
| 381 |
+
config,
|
| 382 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 383 |
+
core,
|
| 384 |
+
path: "/bluebubbles-webhook",
|
| 385 |
+
});
|
| 386 |
+
|
| 387 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", "invalid json {{");
|
| 388 |
+
const res = createMockResponse();
|
| 389 |
+
|
| 390 |
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
| 391 |
+
|
| 392 |
+
expect(handled).toBe(true);
|
| 393 |
+
expect(res.statusCode).toBe(400);
|
| 394 |
+
});
|
| 395 |
+
|
| 396 |
+
it("authenticates via password query parameter", async () => {
|
| 397 |
+
const account = createMockAccount({ password: "secret-token" });
|
| 398 |
+
const config: OpenClawConfig = {};
|
| 399 |
+
const core = createMockRuntime();
|
| 400 |
+
setBlueBubblesRuntime(core);
|
| 401 |
+
|
| 402 |
+
// Mock non-localhost request
|
| 403 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
|
| 404 |
+
type: "new-message",
|
| 405 |
+
data: {
|
| 406 |
+
text: "hello",
|
| 407 |
+
handle: { address: "+15551234567" },
|
| 408 |
+
isGroup: false,
|
| 409 |
+
isFromMe: false,
|
| 410 |
+
guid: "msg-1",
|
| 411 |
+
},
|
| 412 |
+
});
|
| 413 |
+
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
| 414 |
+
remoteAddress: "192.168.1.100",
|
| 415 |
+
};
|
| 416 |
+
|
| 417 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 418 |
+
account,
|
| 419 |
+
config,
|
| 420 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 421 |
+
core,
|
| 422 |
+
path: "/bluebubbles-webhook",
|
| 423 |
+
});
|
| 424 |
+
|
| 425 |
+
const res = createMockResponse();
|
| 426 |
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
| 427 |
+
|
| 428 |
+
expect(handled).toBe(true);
|
| 429 |
+
expect(res.statusCode).toBe(200);
|
| 430 |
+
});
|
| 431 |
+
|
| 432 |
+
it("authenticates via x-password header", async () => {
|
| 433 |
+
const account = createMockAccount({ password: "secret-token" });
|
| 434 |
+
const config: OpenClawConfig = {};
|
| 435 |
+
const core = createMockRuntime();
|
| 436 |
+
setBlueBubblesRuntime(core);
|
| 437 |
+
|
| 438 |
+
const req = createMockRequest(
|
| 439 |
+
"POST",
|
| 440 |
+
"/bluebubbles-webhook",
|
| 441 |
+
{
|
| 442 |
+
type: "new-message",
|
| 443 |
+
data: {
|
| 444 |
+
text: "hello",
|
| 445 |
+
handle: { address: "+15551234567" },
|
| 446 |
+
isGroup: false,
|
| 447 |
+
isFromMe: false,
|
| 448 |
+
guid: "msg-1",
|
| 449 |
+
},
|
| 450 |
+
},
|
| 451 |
+
{ "x-password": "secret-token" },
|
| 452 |
+
);
|
| 453 |
+
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
| 454 |
+
remoteAddress: "192.168.1.100",
|
| 455 |
+
};
|
| 456 |
+
|
| 457 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 458 |
+
account,
|
| 459 |
+
config,
|
| 460 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 461 |
+
core,
|
| 462 |
+
path: "/bluebubbles-webhook",
|
| 463 |
+
});
|
| 464 |
+
|
| 465 |
+
const res = createMockResponse();
|
| 466 |
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
| 467 |
+
|
| 468 |
+
expect(handled).toBe(true);
|
| 469 |
+
expect(res.statusCode).toBe(200);
|
| 470 |
+
});
|
| 471 |
+
|
| 472 |
+
it("rejects unauthorized requests with wrong password", async () => {
|
| 473 |
+
const account = createMockAccount({ password: "secret-token" });
|
| 474 |
+
const config: OpenClawConfig = {};
|
| 475 |
+
const core = createMockRuntime();
|
| 476 |
+
setBlueBubblesRuntime(core);
|
| 477 |
+
|
| 478 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook?password=wrong-token", {
|
| 479 |
+
type: "new-message",
|
| 480 |
+
data: {
|
| 481 |
+
text: "hello",
|
| 482 |
+
handle: { address: "+15551234567" },
|
| 483 |
+
isGroup: false,
|
| 484 |
+
isFromMe: false,
|
| 485 |
+
guid: "msg-1",
|
| 486 |
+
},
|
| 487 |
+
});
|
| 488 |
+
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
| 489 |
+
remoteAddress: "192.168.1.100",
|
| 490 |
+
};
|
| 491 |
+
|
| 492 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 493 |
+
account,
|
| 494 |
+
config,
|
| 495 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 496 |
+
core,
|
| 497 |
+
path: "/bluebubbles-webhook",
|
| 498 |
+
});
|
| 499 |
+
|
| 500 |
+
const res = createMockResponse();
|
| 501 |
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
| 502 |
+
|
| 503 |
+
expect(handled).toBe(true);
|
| 504 |
+
expect(res.statusCode).toBe(401);
|
| 505 |
+
});
|
| 506 |
+
|
| 507 |
+
it("allows localhost requests without authentication", async () => {
|
| 508 |
+
const account = createMockAccount({ password: "secret-token" });
|
| 509 |
+
const config: OpenClawConfig = {};
|
| 510 |
+
const core = createMockRuntime();
|
| 511 |
+
setBlueBubblesRuntime(core);
|
| 512 |
+
|
| 513 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", {
|
| 514 |
+
type: "new-message",
|
| 515 |
+
data: {
|
| 516 |
+
text: "hello",
|
| 517 |
+
handle: { address: "+15551234567" },
|
| 518 |
+
isGroup: false,
|
| 519 |
+
isFromMe: false,
|
| 520 |
+
guid: "msg-1",
|
| 521 |
+
},
|
| 522 |
+
});
|
| 523 |
+
// Localhost address
|
| 524 |
+
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
| 525 |
+
remoteAddress: "127.0.0.1",
|
| 526 |
+
};
|
| 527 |
+
|
| 528 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 529 |
+
account,
|
| 530 |
+
config,
|
| 531 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 532 |
+
core,
|
| 533 |
+
path: "/bluebubbles-webhook",
|
| 534 |
+
});
|
| 535 |
+
|
| 536 |
+
const res = createMockResponse();
|
| 537 |
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
| 538 |
+
|
| 539 |
+
expect(handled).toBe(true);
|
| 540 |
+
expect(res.statusCode).toBe(200);
|
| 541 |
+
});
|
| 542 |
+
|
| 543 |
+
it("ignores unregistered webhook paths", async () => {
|
| 544 |
+
const req = createMockRequest("POST", "/unregistered-path", {});
|
| 545 |
+
const res = createMockResponse();
|
| 546 |
+
|
| 547 |
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
| 548 |
+
|
| 549 |
+
expect(handled).toBe(false);
|
| 550 |
+
});
|
| 551 |
+
|
| 552 |
+
it("parses chatId when provided as a string (webhook variant)", async () => {
|
| 553 |
+
const { resolveChatGuidForTarget } = await import("./send.js");
|
| 554 |
+
vi.mocked(resolveChatGuidForTarget).mockClear();
|
| 555 |
+
|
| 556 |
+
const account = createMockAccount({ groupPolicy: "open" });
|
| 557 |
+
const config: OpenClawConfig = {};
|
| 558 |
+
const core = createMockRuntime();
|
| 559 |
+
setBlueBubblesRuntime(core);
|
| 560 |
+
|
| 561 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 562 |
+
account,
|
| 563 |
+
config,
|
| 564 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 565 |
+
core,
|
| 566 |
+
path: "/bluebubbles-webhook",
|
| 567 |
+
});
|
| 568 |
+
|
| 569 |
+
const payload = {
|
| 570 |
+
type: "new-message",
|
| 571 |
+
data: {
|
| 572 |
+
text: "hello from group",
|
| 573 |
+
handle: { address: "+15551234567" },
|
| 574 |
+
isGroup: true,
|
| 575 |
+
isFromMe: false,
|
| 576 |
+
guid: "msg-1",
|
| 577 |
+
chatId: "123",
|
| 578 |
+
date: Date.now(),
|
| 579 |
+
},
|
| 580 |
+
};
|
| 581 |
+
|
| 582 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 583 |
+
const res = createMockResponse();
|
| 584 |
+
|
| 585 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 586 |
+
await flushAsync();
|
| 587 |
+
|
| 588 |
+
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
|
| 589 |
+
expect.objectContaining({
|
| 590 |
+
target: { kind: "chat_id", chatId: 123 },
|
| 591 |
+
}),
|
| 592 |
+
);
|
| 593 |
+
});
|
| 594 |
+
|
| 595 |
+
it("extracts chatGuid from nested chat object fields (webhook variant)", async () => {
|
| 596 |
+
const { sendMessageBlueBubbles, resolveChatGuidForTarget } = await import("./send.js");
|
| 597 |
+
vi.mocked(sendMessageBlueBubbles).mockClear();
|
| 598 |
+
vi.mocked(resolveChatGuidForTarget).mockClear();
|
| 599 |
+
|
| 600 |
+
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
|
| 601 |
+
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
|
| 602 |
+
});
|
| 603 |
+
|
| 604 |
+
const account = createMockAccount({ groupPolicy: "open" });
|
| 605 |
+
const config: OpenClawConfig = {};
|
| 606 |
+
const core = createMockRuntime();
|
| 607 |
+
setBlueBubblesRuntime(core);
|
| 608 |
+
|
| 609 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 610 |
+
account,
|
| 611 |
+
config,
|
| 612 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 613 |
+
core,
|
| 614 |
+
path: "/bluebubbles-webhook",
|
| 615 |
+
});
|
| 616 |
+
|
| 617 |
+
const payload = {
|
| 618 |
+
type: "new-message",
|
| 619 |
+
data: {
|
| 620 |
+
text: "hello from group",
|
| 621 |
+
handle: { address: "+15551234567" },
|
| 622 |
+
isGroup: true,
|
| 623 |
+
isFromMe: false,
|
| 624 |
+
guid: "msg-1",
|
| 625 |
+
chat: { chatGuid: "iMessage;+;chat123456" },
|
| 626 |
+
date: Date.now(),
|
| 627 |
+
},
|
| 628 |
+
};
|
| 629 |
+
|
| 630 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 631 |
+
const res = createMockResponse();
|
| 632 |
+
|
| 633 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 634 |
+
await flushAsync();
|
| 635 |
+
|
| 636 |
+
expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
|
| 637 |
+
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
|
| 638 |
+
"chat_guid:iMessage;+;chat123456",
|
| 639 |
+
expect.any(String),
|
| 640 |
+
expect.any(Object),
|
| 641 |
+
);
|
| 642 |
+
});
|
| 643 |
+
});
|
| 644 |
+
|
| 645 |
+
describe("DM pairing behavior vs allowFrom", () => {
|
| 646 |
+
it("allows DM from sender in allowFrom list", async () => {
|
| 647 |
+
const account = createMockAccount({
|
| 648 |
+
dmPolicy: "allowlist",
|
| 649 |
+
allowFrom: ["+15551234567"],
|
| 650 |
+
});
|
| 651 |
+
const config: OpenClawConfig = {};
|
| 652 |
+
const core = createMockRuntime();
|
| 653 |
+
setBlueBubblesRuntime(core);
|
| 654 |
+
|
| 655 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 656 |
+
account,
|
| 657 |
+
config,
|
| 658 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 659 |
+
core,
|
| 660 |
+
path: "/bluebubbles-webhook",
|
| 661 |
+
});
|
| 662 |
+
|
| 663 |
+
const payload = {
|
| 664 |
+
type: "new-message",
|
| 665 |
+
data: {
|
| 666 |
+
text: "hello from allowed sender",
|
| 667 |
+
handle: { address: "+15551234567" },
|
| 668 |
+
isGroup: false,
|
| 669 |
+
isFromMe: false,
|
| 670 |
+
guid: "msg-1",
|
| 671 |
+
date: Date.now(),
|
| 672 |
+
},
|
| 673 |
+
};
|
| 674 |
+
|
| 675 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 676 |
+
const res = createMockResponse();
|
| 677 |
+
|
| 678 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 679 |
+
|
| 680 |
+
// Wait for async processing
|
| 681 |
+
await flushAsync();
|
| 682 |
+
|
| 683 |
+
expect(res.statusCode).toBe(200);
|
| 684 |
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
| 685 |
+
});
|
| 686 |
+
|
| 687 |
+
it("blocks DM from sender not in allowFrom when dmPolicy=allowlist", async () => {
|
| 688 |
+
const account = createMockAccount({
|
| 689 |
+
dmPolicy: "allowlist",
|
| 690 |
+
allowFrom: ["+15559999999"], // Different number
|
| 691 |
+
});
|
| 692 |
+
const config: OpenClawConfig = {};
|
| 693 |
+
const core = createMockRuntime();
|
| 694 |
+
setBlueBubblesRuntime(core);
|
| 695 |
+
|
| 696 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 697 |
+
account,
|
| 698 |
+
config,
|
| 699 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 700 |
+
core,
|
| 701 |
+
path: "/bluebubbles-webhook",
|
| 702 |
+
});
|
| 703 |
+
|
| 704 |
+
const payload = {
|
| 705 |
+
type: "new-message",
|
| 706 |
+
data: {
|
| 707 |
+
text: "hello from blocked sender",
|
| 708 |
+
handle: { address: "+15551234567" },
|
| 709 |
+
isGroup: false,
|
| 710 |
+
isFromMe: false,
|
| 711 |
+
guid: "msg-1",
|
| 712 |
+
date: Date.now(),
|
| 713 |
+
},
|
| 714 |
+
};
|
| 715 |
+
|
| 716 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 717 |
+
const res = createMockResponse();
|
| 718 |
+
|
| 719 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 720 |
+
await flushAsync();
|
| 721 |
+
|
| 722 |
+
expect(res.statusCode).toBe(200);
|
| 723 |
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
| 724 |
+
});
|
| 725 |
+
|
| 726 |
+
it("triggers pairing flow for unknown sender when dmPolicy=pairing", async () => {
|
| 727 |
+
// Note: empty allowFrom = allow all. To trigger pairing, we need a non-empty
|
| 728 |
+
// allowlist that doesn't include the sender
|
| 729 |
+
const account = createMockAccount({
|
| 730 |
+
dmPolicy: "pairing",
|
| 731 |
+
allowFrom: ["+15559999999"], // Different number than sender
|
| 732 |
+
});
|
| 733 |
+
const config: OpenClawConfig = {};
|
| 734 |
+
const core = createMockRuntime();
|
| 735 |
+
setBlueBubblesRuntime(core);
|
| 736 |
+
|
| 737 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 738 |
+
account,
|
| 739 |
+
config,
|
| 740 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 741 |
+
core,
|
| 742 |
+
path: "/bluebubbles-webhook",
|
| 743 |
+
});
|
| 744 |
+
|
| 745 |
+
const payload = {
|
| 746 |
+
type: "new-message",
|
| 747 |
+
data: {
|
| 748 |
+
text: "hello",
|
| 749 |
+
handle: { address: "+15551234567" },
|
| 750 |
+
isGroup: false,
|
| 751 |
+
isFromMe: false,
|
| 752 |
+
guid: "msg-1",
|
| 753 |
+
date: Date.now(),
|
| 754 |
+
},
|
| 755 |
+
};
|
| 756 |
+
|
| 757 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 758 |
+
const res = createMockResponse();
|
| 759 |
+
|
| 760 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 761 |
+
await flushAsync();
|
| 762 |
+
|
| 763 |
+
expect(mockUpsertPairingRequest).toHaveBeenCalled();
|
| 764 |
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
| 765 |
+
});
|
| 766 |
+
|
| 767 |
+
it("does not resend pairing reply when request already exists", async () => {
|
| 768 |
+
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: false });
|
| 769 |
+
|
| 770 |
+
// Note: empty allowFrom = allow all. To trigger pairing, we need a non-empty
|
| 771 |
+
// allowlist that doesn't include the sender
|
| 772 |
+
const account = createMockAccount({
|
| 773 |
+
dmPolicy: "pairing",
|
| 774 |
+
allowFrom: ["+15559999999"], // Different number than sender
|
| 775 |
+
});
|
| 776 |
+
const config: OpenClawConfig = {};
|
| 777 |
+
const core = createMockRuntime();
|
| 778 |
+
setBlueBubblesRuntime(core);
|
| 779 |
+
|
| 780 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 781 |
+
account,
|
| 782 |
+
config,
|
| 783 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 784 |
+
core,
|
| 785 |
+
path: "/bluebubbles-webhook",
|
| 786 |
+
});
|
| 787 |
+
|
| 788 |
+
const payload = {
|
| 789 |
+
type: "new-message",
|
| 790 |
+
data: {
|
| 791 |
+
text: "hello again",
|
| 792 |
+
handle: { address: "+15551234567" },
|
| 793 |
+
isGroup: false,
|
| 794 |
+
isFromMe: false,
|
| 795 |
+
guid: "msg-2",
|
| 796 |
+
date: Date.now(),
|
| 797 |
+
},
|
| 798 |
+
};
|
| 799 |
+
|
| 800 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 801 |
+
const res = createMockResponse();
|
| 802 |
+
|
| 803 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 804 |
+
await flushAsync();
|
| 805 |
+
|
| 806 |
+
expect(mockUpsertPairingRequest).toHaveBeenCalled();
|
| 807 |
+
// Should not send pairing reply since created=false
|
| 808 |
+
const { sendMessageBlueBubbles } = await import("./send.js");
|
| 809 |
+
expect(sendMessageBlueBubbles).not.toHaveBeenCalled();
|
| 810 |
+
});
|
| 811 |
+
|
| 812 |
+
it("allows all DMs when dmPolicy=open", async () => {
|
| 813 |
+
const account = createMockAccount({
|
| 814 |
+
dmPolicy: "open",
|
| 815 |
+
allowFrom: [],
|
| 816 |
+
});
|
| 817 |
+
const config: OpenClawConfig = {};
|
| 818 |
+
const core = createMockRuntime();
|
| 819 |
+
setBlueBubblesRuntime(core);
|
| 820 |
+
|
| 821 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 822 |
+
account,
|
| 823 |
+
config,
|
| 824 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 825 |
+
core,
|
| 826 |
+
path: "/bluebubbles-webhook",
|
| 827 |
+
});
|
| 828 |
+
|
| 829 |
+
const payload = {
|
| 830 |
+
type: "new-message",
|
| 831 |
+
data: {
|
| 832 |
+
text: "hello from anyone",
|
| 833 |
+
handle: { address: "+15559999999" },
|
| 834 |
+
isGroup: false,
|
| 835 |
+
isFromMe: false,
|
| 836 |
+
guid: "msg-1",
|
| 837 |
+
date: Date.now(),
|
| 838 |
+
},
|
| 839 |
+
};
|
| 840 |
+
|
| 841 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 842 |
+
const res = createMockResponse();
|
| 843 |
+
|
| 844 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 845 |
+
await flushAsync();
|
| 846 |
+
|
| 847 |
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
| 848 |
+
});
|
| 849 |
+
|
| 850 |
+
it("blocks all DMs when dmPolicy=disabled", async () => {
|
| 851 |
+
const account = createMockAccount({
|
| 852 |
+
dmPolicy: "disabled",
|
| 853 |
+
});
|
| 854 |
+
const config: OpenClawConfig = {};
|
| 855 |
+
const core = createMockRuntime();
|
| 856 |
+
setBlueBubblesRuntime(core);
|
| 857 |
+
|
| 858 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 859 |
+
account,
|
| 860 |
+
config,
|
| 861 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 862 |
+
core,
|
| 863 |
+
path: "/bluebubbles-webhook",
|
| 864 |
+
});
|
| 865 |
+
|
| 866 |
+
const payload = {
|
| 867 |
+
type: "new-message",
|
| 868 |
+
data: {
|
| 869 |
+
text: "hello",
|
| 870 |
+
handle: { address: "+15551234567" },
|
| 871 |
+
isGroup: false,
|
| 872 |
+
isFromMe: false,
|
| 873 |
+
guid: "msg-1",
|
| 874 |
+
date: Date.now(),
|
| 875 |
+
},
|
| 876 |
+
};
|
| 877 |
+
|
| 878 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 879 |
+
const res = createMockResponse();
|
| 880 |
+
|
| 881 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 882 |
+
await flushAsync();
|
| 883 |
+
|
| 884 |
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
| 885 |
+
});
|
| 886 |
+
});
|
| 887 |
+
|
| 888 |
+
describe("group message gating", () => {
|
| 889 |
+
it("allows group messages when groupPolicy=open and no allowlist", async () => {
|
| 890 |
+
const account = createMockAccount({
|
| 891 |
+
groupPolicy: "open",
|
| 892 |
+
});
|
| 893 |
+
const config: OpenClawConfig = {};
|
| 894 |
+
const core = createMockRuntime();
|
| 895 |
+
setBlueBubblesRuntime(core);
|
| 896 |
+
|
| 897 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 898 |
+
account,
|
| 899 |
+
config,
|
| 900 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 901 |
+
core,
|
| 902 |
+
path: "/bluebubbles-webhook",
|
| 903 |
+
});
|
| 904 |
+
|
| 905 |
+
const payload = {
|
| 906 |
+
type: "new-message",
|
| 907 |
+
data: {
|
| 908 |
+
text: "hello from group",
|
| 909 |
+
handle: { address: "+15551234567" },
|
| 910 |
+
isGroup: true,
|
| 911 |
+
isFromMe: false,
|
| 912 |
+
guid: "msg-1",
|
| 913 |
+
chatGuid: "iMessage;+;chat123456",
|
| 914 |
+
date: Date.now(),
|
| 915 |
+
},
|
| 916 |
+
};
|
| 917 |
+
|
| 918 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 919 |
+
const res = createMockResponse();
|
| 920 |
+
|
| 921 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 922 |
+
await flushAsync();
|
| 923 |
+
|
| 924 |
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
| 925 |
+
});
|
| 926 |
+
|
| 927 |
+
it("blocks group messages when groupPolicy=disabled", async () => {
|
| 928 |
+
const account = createMockAccount({
|
| 929 |
+
groupPolicy: "disabled",
|
| 930 |
+
});
|
| 931 |
+
const config: OpenClawConfig = {};
|
| 932 |
+
const core = createMockRuntime();
|
| 933 |
+
setBlueBubblesRuntime(core);
|
| 934 |
+
|
| 935 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 936 |
+
account,
|
| 937 |
+
config,
|
| 938 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 939 |
+
core,
|
| 940 |
+
path: "/bluebubbles-webhook",
|
| 941 |
+
});
|
| 942 |
+
|
| 943 |
+
const payload = {
|
| 944 |
+
type: "new-message",
|
| 945 |
+
data: {
|
| 946 |
+
text: "hello from group",
|
| 947 |
+
handle: { address: "+15551234567" },
|
| 948 |
+
isGroup: true,
|
| 949 |
+
isFromMe: false,
|
| 950 |
+
guid: "msg-1",
|
| 951 |
+
chatGuid: "iMessage;+;chat123456",
|
| 952 |
+
date: Date.now(),
|
| 953 |
+
},
|
| 954 |
+
};
|
| 955 |
+
|
| 956 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 957 |
+
const res = createMockResponse();
|
| 958 |
+
|
| 959 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 960 |
+
await flushAsync();
|
| 961 |
+
|
| 962 |
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
| 963 |
+
});
|
| 964 |
+
|
| 965 |
+
it("treats chat_guid groups as group even when isGroup=false", async () => {
|
| 966 |
+
const account = createMockAccount({
|
| 967 |
+
groupPolicy: "allowlist",
|
| 968 |
+
dmPolicy: "open",
|
| 969 |
+
});
|
| 970 |
+
const config: OpenClawConfig = {};
|
| 971 |
+
const core = createMockRuntime();
|
| 972 |
+
setBlueBubblesRuntime(core);
|
| 973 |
+
|
| 974 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 975 |
+
account,
|
| 976 |
+
config,
|
| 977 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 978 |
+
core,
|
| 979 |
+
path: "/bluebubbles-webhook",
|
| 980 |
+
});
|
| 981 |
+
|
| 982 |
+
const payload = {
|
| 983 |
+
type: "new-message",
|
| 984 |
+
data: {
|
| 985 |
+
text: "hello from group",
|
| 986 |
+
handle: { address: "+15551234567" },
|
| 987 |
+
isGroup: false,
|
| 988 |
+
isFromMe: false,
|
| 989 |
+
guid: "msg-1",
|
| 990 |
+
chatGuid: "iMessage;+;chat123456",
|
| 991 |
+
date: Date.now(),
|
| 992 |
+
},
|
| 993 |
+
};
|
| 994 |
+
|
| 995 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 996 |
+
const res = createMockResponse();
|
| 997 |
+
|
| 998 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 999 |
+
await flushAsync();
|
| 1000 |
+
|
| 1001 |
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
| 1002 |
+
});
|
| 1003 |
+
|
| 1004 |
+
it("allows group messages from allowed chat_guid in groupAllowFrom", async () => {
|
| 1005 |
+
const account = createMockAccount({
|
| 1006 |
+
groupPolicy: "allowlist",
|
| 1007 |
+
groupAllowFrom: ["chat_guid:iMessage;+;chat123456"],
|
| 1008 |
+
});
|
| 1009 |
+
const config: OpenClawConfig = {};
|
| 1010 |
+
const core = createMockRuntime();
|
| 1011 |
+
setBlueBubblesRuntime(core);
|
| 1012 |
+
|
| 1013 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 1014 |
+
account,
|
| 1015 |
+
config,
|
| 1016 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 1017 |
+
core,
|
| 1018 |
+
path: "/bluebubbles-webhook",
|
| 1019 |
+
});
|
| 1020 |
+
|
| 1021 |
+
const payload = {
|
| 1022 |
+
type: "new-message",
|
| 1023 |
+
data: {
|
| 1024 |
+
text: "hello from allowed group",
|
| 1025 |
+
handle: { address: "+15551234567" },
|
| 1026 |
+
isGroup: true,
|
| 1027 |
+
isFromMe: false,
|
| 1028 |
+
guid: "msg-1",
|
| 1029 |
+
chatGuid: "iMessage;+;chat123456",
|
| 1030 |
+
date: Date.now(),
|
| 1031 |
+
},
|
| 1032 |
+
};
|
| 1033 |
+
|
| 1034 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 1035 |
+
const res = createMockResponse();
|
| 1036 |
+
|
| 1037 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 1038 |
+
await flushAsync();
|
| 1039 |
+
|
| 1040 |
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
| 1041 |
+
});
|
| 1042 |
+
});
|
| 1043 |
+
|
| 1044 |
+
describe("mention gating (group messages)", () => {
|
| 1045 |
+
it("processes group message when mentioned and requireMention=true", async () => {
|
| 1046 |
+
mockResolveRequireMention.mockReturnValue(true);
|
| 1047 |
+
mockMatchesMentionPatterns.mockReturnValue(true);
|
| 1048 |
+
|
| 1049 |
+
const account = createMockAccount({ groupPolicy: "open" });
|
| 1050 |
+
const config: OpenClawConfig = {};
|
| 1051 |
+
const core = createMockRuntime();
|
| 1052 |
+
setBlueBubblesRuntime(core);
|
| 1053 |
+
|
| 1054 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 1055 |
+
account,
|
| 1056 |
+
config,
|
| 1057 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 1058 |
+
core,
|
| 1059 |
+
path: "/bluebubbles-webhook",
|
| 1060 |
+
});
|
| 1061 |
+
|
| 1062 |
+
const payload = {
|
| 1063 |
+
type: "new-message",
|
| 1064 |
+
data: {
|
| 1065 |
+
text: "bert, can you help me?",
|
| 1066 |
+
handle: { address: "+15551234567" },
|
| 1067 |
+
isGroup: true,
|
| 1068 |
+
isFromMe: false,
|
| 1069 |
+
guid: "msg-1",
|
| 1070 |
+
chatGuid: "iMessage;+;chat123456",
|
| 1071 |
+
date: Date.now(),
|
| 1072 |
+
},
|
| 1073 |
+
};
|
| 1074 |
+
|
| 1075 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 1076 |
+
const res = createMockResponse();
|
| 1077 |
+
|
| 1078 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 1079 |
+
await flushAsync();
|
| 1080 |
+
|
| 1081 |
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
| 1082 |
+
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
| 1083 |
+
expect(callArgs.ctx.WasMentioned).toBe(true);
|
| 1084 |
+
});
|
| 1085 |
+
|
| 1086 |
+
it("skips group message when not mentioned and requireMention=true", async () => {
|
| 1087 |
+
mockResolveRequireMention.mockReturnValue(true);
|
| 1088 |
+
mockMatchesMentionPatterns.mockReturnValue(false);
|
| 1089 |
+
|
| 1090 |
+
const account = createMockAccount({ groupPolicy: "open" });
|
| 1091 |
+
const config: OpenClawConfig = {};
|
| 1092 |
+
const core = createMockRuntime();
|
| 1093 |
+
setBlueBubblesRuntime(core);
|
| 1094 |
+
|
| 1095 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 1096 |
+
account,
|
| 1097 |
+
config,
|
| 1098 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 1099 |
+
core,
|
| 1100 |
+
path: "/bluebubbles-webhook",
|
| 1101 |
+
});
|
| 1102 |
+
|
| 1103 |
+
const payload = {
|
| 1104 |
+
type: "new-message",
|
| 1105 |
+
data: {
|
| 1106 |
+
text: "hello everyone",
|
| 1107 |
+
handle: { address: "+15551234567" },
|
| 1108 |
+
isGroup: true,
|
| 1109 |
+
isFromMe: false,
|
| 1110 |
+
guid: "msg-1",
|
| 1111 |
+
chatGuid: "iMessage;+;chat123456",
|
| 1112 |
+
date: Date.now(),
|
| 1113 |
+
},
|
| 1114 |
+
};
|
| 1115 |
+
|
| 1116 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 1117 |
+
const res = createMockResponse();
|
| 1118 |
+
|
| 1119 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 1120 |
+
await flushAsync();
|
| 1121 |
+
|
| 1122 |
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
| 1123 |
+
});
|
| 1124 |
+
|
| 1125 |
+
it("processes group message without mention when requireMention=false", async () => {
|
| 1126 |
+
mockResolveRequireMention.mockReturnValue(false);
|
| 1127 |
+
|
| 1128 |
+
const account = createMockAccount({ groupPolicy: "open" });
|
| 1129 |
+
const config: OpenClawConfig = {};
|
| 1130 |
+
const core = createMockRuntime();
|
| 1131 |
+
setBlueBubblesRuntime(core);
|
| 1132 |
+
|
| 1133 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 1134 |
+
account,
|
| 1135 |
+
config,
|
| 1136 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 1137 |
+
core,
|
| 1138 |
+
path: "/bluebubbles-webhook",
|
| 1139 |
+
});
|
| 1140 |
+
|
| 1141 |
+
const payload = {
|
| 1142 |
+
type: "new-message",
|
| 1143 |
+
data: {
|
| 1144 |
+
text: "hello everyone",
|
| 1145 |
+
handle: { address: "+15551234567" },
|
| 1146 |
+
isGroup: true,
|
| 1147 |
+
isFromMe: false,
|
| 1148 |
+
guid: "msg-1",
|
| 1149 |
+
chatGuid: "iMessage;+;chat123456",
|
| 1150 |
+
date: Date.now(),
|
| 1151 |
+
},
|
| 1152 |
+
};
|
| 1153 |
+
|
| 1154 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 1155 |
+
const res = createMockResponse();
|
| 1156 |
+
|
| 1157 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 1158 |
+
await flushAsync();
|
| 1159 |
+
|
| 1160 |
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
| 1161 |
+
});
|
| 1162 |
+
});
|
| 1163 |
+
|
| 1164 |
+
describe("group metadata", () => {
|
| 1165 |
+
it("includes group subject + members in ctx", async () => {
|
| 1166 |
+
const account = createMockAccount({ groupPolicy: "open" });
|
| 1167 |
+
const config: OpenClawConfig = {};
|
| 1168 |
+
const core = createMockRuntime();
|
| 1169 |
+
setBlueBubblesRuntime(core);
|
| 1170 |
+
|
| 1171 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 1172 |
+
account,
|
| 1173 |
+
config,
|
| 1174 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 1175 |
+
core,
|
| 1176 |
+
path: "/bluebubbles-webhook",
|
| 1177 |
+
});
|
| 1178 |
+
|
| 1179 |
+
const payload = {
|
| 1180 |
+
type: "new-message",
|
| 1181 |
+
data: {
|
| 1182 |
+
text: "hello group",
|
| 1183 |
+
handle: { address: "+15551234567" },
|
| 1184 |
+
isGroup: true,
|
| 1185 |
+
isFromMe: false,
|
| 1186 |
+
guid: "msg-1",
|
| 1187 |
+
chatGuid: "iMessage;+;chat123456",
|
| 1188 |
+
chatName: "Family",
|
| 1189 |
+
participants: [
|
| 1190 |
+
{ address: "+15551234567", displayName: "Alice" },
|
| 1191 |
+
{ address: "+15557654321", displayName: "Bob" },
|
| 1192 |
+
],
|
| 1193 |
+
date: Date.now(),
|
| 1194 |
+
},
|
| 1195 |
+
};
|
| 1196 |
+
|
| 1197 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 1198 |
+
const res = createMockResponse();
|
| 1199 |
+
|
| 1200 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 1201 |
+
await flushAsync();
|
| 1202 |
+
|
| 1203 |
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
| 1204 |
+
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
| 1205 |
+
expect(callArgs.ctx.GroupSubject).toBe("Family");
|
| 1206 |
+
expect(callArgs.ctx.GroupMembers).toBe("Alice (+15551234567), Bob (+15557654321)");
|
| 1207 |
+
});
|
| 1208 |
+
});
|
| 1209 |
+
|
| 1210 |
+
describe("inbound debouncing", () => {
|
| 1211 |
+
it("coalesces text-only then attachment webhook events by messageId", async () => {
|
| 1212 |
+
vi.useFakeTimers();
|
| 1213 |
+
try {
|
| 1214 |
+
const account = createMockAccount({ dmPolicy: "open" });
|
| 1215 |
+
const config: OpenClawConfig = {};
|
| 1216 |
+
const core = createMockRuntime();
|
| 1217 |
+
|
| 1218 |
+
// Use a timing-aware debouncer test double that respects debounceMs/buildKey/shouldDebounce.
|
| 1219 |
+
core.channel.debounce.createInboundDebouncer = vi.fn((params: any) => {
|
| 1220 |
+
type Item = any;
|
| 1221 |
+
const buckets = new Map<
|
| 1222 |
+
string,
|
| 1223 |
+
{ items: Item[]; timer: ReturnType<typeof setTimeout> | null }
|
| 1224 |
+
>();
|
| 1225 |
+
|
| 1226 |
+
const flush = async (key: string) => {
|
| 1227 |
+
const bucket = buckets.get(key);
|
| 1228 |
+
if (!bucket) {
|
| 1229 |
+
return;
|
| 1230 |
+
}
|
| 1231 |
+
if (bucket.timer) {
|
| 1232 |
+
clearTimeout(bucket.timer);
|
| 1233 |
+
bucket.timer = null;
|
| 1234 |
+
}
|
| 1235 |
+
const items = bucket.items;
|
| 1236 |
+
bucket.items = [];
|
| 1237 |
+
if (items.length > 0) {
|
| 1238 |
+
try {
|
| 1239 |
+
await params.onFlush(items);
|
| 1240 |
+
} catch (err) {
|
| 1241 |
+
params.onError?.(err);
|
| 1242 |
+
throw err;
|
| 1243 |
+
}
|
| 1244 |
+
}
|
| 1245 |
+
};
|
| 1246 |
+
|
| 1247 |
+
return {
|
| 1248 |
+
enqueue: async (item: Item) => {
|
| 1249 |
+
if (params.shouldDebounce && !params.shouldDebounce(item)) {
|
| 1250 |
+
await params.onFlush([item]);
|
| 1251 |
+
return;
|
| 1252 |
+
}
|
| 1253 |
+
|
| 1254 |
+
const key = params.buildKey(item);
|
| 1255 |
+
const existing = buckets.get(key);
|
| 1256 |
+
const bucket = existing ?? { items: [], timer: null };
|
| 1257 |
+
bucket.items.push(item);
|
| 1258 |
+
if (bucket.timer) {
|
| 1259 |
+
clearTimeout(bucket.timer);
|
| 1260 |
+
}
|
| 1261 |
+
bucket.timer = setTimeout(async () => {
|
| 1262 |
+
await flush(key);
|
| 1263 |
+
}, params.debounceMs);
|
| 1264 |
+
buckets.set(key, bucket);
|
| 1265 |
+
},
|
| 1266 |
+
flushKey: vi.fn(async (key: string) => {
|
| 1267 |
+
await flush(key);
|
| 1268 |
+
}),
|
| 1269 |
+
};
|
| 1270 |
+
}) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"];
|
| 1271 |
+
|
| 1272 |
+
setBlueBubblesRuntime(core);
|
| 1273 |
+
|
| 1274 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 1275 |
+
account,
|
| 1276 |
+
config,
|
| 1277 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 1278 |
+
core,
|
| 1279 |
+
path: "/bluebubbles-webhook",
|
| 1280 |
+
});
|
| 1281 |
+
|
| 1282 |
+
const messageId = "race-msg-1";
|
| 1283 |
+
const chatGuid = "iMessage;-;+15551234567";
|
| 1284 |
+
|
| 1285 |
+
const payloadA = {
|
| 1286 |
+
type: "new-message",
|
| 1287 |
+
data: {
|
| 1288 |
+
text: "hello",
|
| 1289 |
+
handle: { address: "+15551234567" },
|
| 1290 |
+
isGroup: false,
|
| 1291 |
+
isFromMe: false,
|
| 1292 |
+
guid: messageId,
|
| 1293 |
+
chatGuid,
|
| 1294 |
+
date: Date.now(),
|
| 1295 |
+
},
|
| 1296 |
+
};
|
| 1297 |
+
|
| 1298 |
+
const payloadB = {
|
| 1299 |
+
type: "new-message",
|
| 1300 |
+
data: {
|
| 1301 |
+
text: "hello",
|
| 1302 |
+
handle: { address: "+15551234567" },
|
| 1303 |
+
isGroup: false,
|
| 1304 |
+
isFromMe: false,
|
| 1305 |
+
guid: messageId,
|
| 1306 |
+
chatGuid,
|
| 1307 |
+
attachments: [
|
| 1308 |
+
{
|
| 1309 |
+
guid: "att-1",
|
| 1310 |
+
mimeType: "image/jpeg",
|
| 1311 |
+
totalBytes: 1024,
|
| 1312 |
+
},
|
| 1313 |
+
],
|
| 1314 |
+
date: Date.now(),
|
| 1315 |
+
},
|
| 1316 |
+
};
|
| 1317 |
+
|
| 1318 |
+
await handleBlueBubblesWebhookRequest(
|
| 1319 |
+
createMockRequest("POST", "/bluebubbles-webhook", payloadA),
|
| 1320 |
+
createMockResponse(),
|
| 1321 |
+
);
|
| 1322 |
+
|
| 1323 |
+
// Simulate the real-world delay where the attachment-bearing webhook arrives shortly after.
|
| 1324 |
+
await vi.advanceTimersByTimeAsync(300);
|
| 1325 |
+
|
| 1326 |
+
await handleBlueBubblesWebhookRequest(
|
| 1327 |
+
createMockRequest("POST", "/bluebubbles-webhook", payloadB),
|
| 1328 |
+
createMockResponse(),
|
| 1329 |
+
);
|
| 1330 |
+
|
| 1331 |
+
// Not flushed yet; still within the debounce window.
|
| 1332 |
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
| 1333 |
+
|
| 1334 |
+
// After the debounce window, the combined message should be processed exactly once.
|
| 1335 |
+
await vi.advanceTimersByTimeAsync(600);
|
| 1336 |
+
|
| 1337 |
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
| 1338 |
+
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
| 1339 |
+
expect(callArgs.ctx.MediaPaths).toEqual(["/tmp/test-media.jpg"]);
|
| 1340 |
+
expect(callArgs.ctx.Body).toContain("hello");
|
| 1341 |
+
} finally {
|
| 1342 |
+
vi.useRealTimers();
|
| 1343 |
+
}
|
| 1344 |
+
});
|
| 1345 |
+
});
|
| 1346 |
+
|
| 1347 |
+
describe("reply metadata", () => {
|
| 1348 |
+
it("surfaces reply fields in ctx when provided", async () => {
|
| 1349 |
+
const account = createMockAccount({ dmPolicy: "open" });
|
| 1350 |
+
const config: OpenClawConfig = {};
|
| 1351 |
+
const core = createMockRuntime();
|
| 1352 |
+
setBlueBubblesRuntime(core);
|
| 1353 |
+
|
| 1354 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 1355 |
+
account,
|
| 1356 |
+
config,
|
| 1357 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 1358 |
+
core,
|
| 1359 |
+
path: "/bluebubbles-webhook",
|
| 1360 |
+
});
|
| 1361 |
+
|
| 1362 |
+
const payload = {
|
| 1363 |
+
type: "new-message",
|
| 1364 |
+
data: {
|
| 1365 |
+
text: "replying now",
|
| 1366 |
+
handle: { address: "+15551234567" },
|
| 1367 |
+
isGroup: false,
|
| 1368 |
+
isFromMe: false,
|
| 1369 |
+
guid: "msg-1",
|
| 1370 |
+
chatGuid: "iMessage;-;+15551234567",
|
| 1371 |
+
replyTo: {
|
| 1372 |
+
guid: "msg-0",
|
| 1373 |
+
text: "original message",
|
| 1374 |
+
handle: { address: "+15550000000", displayName: "Alice" },
|
| 1375 |
+
},
|
| 1376 |
+
date: Date.now(),
|
| 1377 |
+
},
|
| 1378 |
+
};
|
| 1379 |
+
|
| 1380 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 1381 |
+
const res = createMockResponse();
|
| 1382 |
+
|
| 1383 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 1384 |
+
await flushAsync();
|
| 1385 |
+
|
| 1386 |
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
| 1387 |
+
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
| 1388 |
+
// ReplyToId is the full UUID since it wasn't previously cached
|
| 1389 |
+
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
|
| 1390 |
+
expect(callArgs.ctx.ReplyToBody).toBe("original message");
|
| 1391 |
+
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
|
| 1392 |
+
// Body uses inline [[reply_to:N]] tag format
|
| 1393 |
+
expect(callArgs.ctx.Body).toContain("[[reply_to:msg-0]]");
|
| 1394 |
+
});
|
| 1395 |
+
|
| 1396 |
+
it("preserves part index prefixes in reply tags when short IDs are unavailable", async () => {
|
| 1397 |
+
const account = createMockAccount({ dmPolicy: "open" });
|
| 1398 |
+
const config: OpenClawConfig = {};
|
| 1399 |
+
const core = createMockRuntime();
|
| 1400 |
+
setBlueBubblesRuntime(core);
|
| 1401 |
+
|
| 1402 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 1403 |
+
account,
|
| 1404 |
+
config,
|
| 1405 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 1406 |
+
core,
|
| 1407 |
+
path: "/bluebubbles-webhook",
|
| 1408 |
+
});
|
| 1409 |
+
|
| 1410 |
+
const payload = {
|
| 1411 |
+
type: "new-message",
|
| 1412 |
+
data: {
|
| 1413 |
+
text: "replying now",
|
| 1414 |
+
handle: { address: "+15551234567" },
|
| 1415 |
+
isGroup: false,
|
| 1416 |
+
isFromMe: false,
|
| 1417 |
+
guid: "msg-1",
|
| 1418 |
+
chatGuid: "iMessage;-;+15551234567",
|
| 1419 |
+
replyTo: {
|
| 1420 |
+
guid: "p:1/msg-0",
|
| 1421 |
+
text: "original message",
|
| 1422 |
+
handle: { address: "+15550000000", displayName: "Alice" },
|
| 1423 |
+
},
|
| 1424 |
+
date: Date.now(),
|
| 1425 |
+
},
|
| 1426 |
+
};
|
| 1427 |
+
|
| 1428 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 1429 |
+
const res = createMockResponse();
|
| 1430 |
+
|
| 1431 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 1432 |
+
await flushAsync();
|
| 1433 |
+
|
| 1434 |
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
| 1435 |
+
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
| 1436 |
+
expect(callArgs.ctx.ReplyToId).toBe("p:1/msg-0");
|
| 1437 |
+
expect(callArgs.ctx.ReplyToIdFull).toBe("p:1/msg-0");
|
| 1438 |
+
expect(callArgs.ctx.Body).toContain("[[reply_to:p:1/msg-0]]");
|
| 1439 |
+
});
|
| 1440 |
+
|
| 1441 |
+
it("hydrates missing reply sender/body from the recent-message cache", async () => {
|
| 1442 |
+
const account = createMockAccount({ dmPolicy: "open", groupPolicy: "open" });
|
| 1443 |
+
const config: OpenClawConfig = {};
|
| 1444 |
+
const core = createMockRuntime();
|
| 1445 |
+
setBlueBubblesRuntime(core);
|
| 1446 |
+
|
| 1447 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 1448 |
+
account,
|
| 1449 |
+
config,
|
| 1450 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 1451 |
+
core,
|
| 1452 |
+
path: "/bluebubbles-webhook",
|
| 1453 |
+
});
|
| 1454 |
+
|
| 1455 |
+
const chatGuid = "iMessage;+;chat-reply-cache";
|
| 1456 |
+
|
| 1457 |
+
const originalPayload = {
|
| 1458 |
+
type: "new-message",
|
| 1459 |
+
data: {
|
| 1460 |
+
text: "original message (cached)",
|
| 1461 |
+
handle: { address: "+15550000000" },
|
| 1462 |
+
isGroup: true,
|
| 1463 |
+
isFromMe: false,
|
| 1464 |
+
guid: "cache-msg-0",
|
| 1465 |
+
chatGuid,
|
| 1466 |
+
date: Date.now(),
|
| 1467 |
+
},
|
| 1468 |
+
};
|
| 1469 |
+
|
| 1470 |
+
const originalReq = createMockRequest("POST", "/bluebubbles-webhook", originalPayload);
|
| 1471 |
+
const originalRes = createMockResponse();
|
| 1472 |
+
|
| 1473 |
+
await handleBlueBubblesWebhookRequest(originalReq, originalRes);
|
| 1474 |
+
await flushAsync();
|
| 1475 |
+
|
| 1476 |
+
// Only assert the reply message behavior below.
|
| 1477 |
+
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
|
| 1478 |
+
|
| 1479 |
+
const replyPayload = {
|
| 1480 |
+
type: "new-message",
|
| 1481 |
+
data: {
|
| 1482 |
+
text: "replying now",
|
| 1483 |
+
handle: { address: "+15551234567" },
|
| 1484 |
+
isGroup: true,
|
| 1485 |
+
isFromMe: false,
|
| 1486 |
+
guid: "cache-msg-1",
|
| 1487 |
+
chatGuid,
|
| 1488 |
+
// Only the GUID is provided; sender/body must be hydrated.
|
| 1489 |
+
replyToMessageGuid: "cache-msg-0",
|
| 1490 |
+
date: Date.now(),
|
| 1491 |
+
},
|
| 1492 |
+
};
|
| 1493 |
+
|
| 1494 |
+
const replyReq = createMockRequest("POST", "/bluebubbles-webhook", replyPayload);
|
| 1495 |
+
const replyRes = createMockResponse();
|
| 1496 |
+
|
| 1497 |
+
await handleBlueBubblesWebhookRequest(replyReq, replyRes);
|
| 1498 |
+
await flushAsync();
|
| 1499 |
+
|
| 1500 |
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
| 1501 |
+
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
| 1502 |
+
// ReplyToId uses short ID "1" (first cached message) for token savings
|
| 1503 |
+
expect(callArgs.ctx.ReplyToId).toBe("1");
|
| 1504 |
+
expect(callArgs.ctx.ReplyToIdFull).toBe("cache-msg-0");
|
| 1505 |
+
expect(callArgs.ctx.ReplyToBody).toBe("original message (cached)");
|
| 1506 |
+
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
|
| 1507 |
+
// Body uses inline [[reply_to:N]] tag format with short ID
|
| 1508 |
+
expect(callArgs.ctx.Body).toContain("[[reply_to:1]]");
|
| 1509 |
+
});
|
| 1510 |
+
|
| 1511 |
+
it("falls back to threadOriginatorGuid when reply metadata is absent", async () => {
|
| 1512 |
+
const account = createMockAccount({ dmPolicy: "open" });
|
| 1513 |
+
const config: OpenClawConfig = {};
|
| 1514 |
+
const core = createMockRuntime();
|
| 1515 |
+
setBlueBubblesRuntime(core);
|
| 1516 |
+
|
| 1517 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 1518 |
+
account,
|
| 1519 |
+
config,
|
| 1520 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 1521 |
+
core,
|
| 1522 |
+
path: "/bluebubbles-webhook",
|
| 1523 |
+
});
|
| 1524 |
+
|
| 1525 |
+
const payload = {
|
| 1526 |
+
type: "new-message",
|
| 1527 |
+
data: {
|
| 1528 |
+
text: "replying now",
|
| 1529 |
+
handle: { address: "+15551234567" },
|
| 1530 |
+
isGroup: false,
|
| 1531 |
+
isFromMe: false,
|
| 1532 |
+
guid: "msg-1",
|
| 1533 |
+
threadOriginatorGuid: "msg-0",
|
| 1534 |
+
chatGuid: "iMessage;-;+15551234567",
|
| 1535 |
+
date: Date.now(),
|
| 1536 |
+
},
|
| 1537 |
+
};
|
| 1538 |
+
|
| 1539 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 1540 |
+
const res = createMockResponse();
|
| 1541 |
+
|
| 1542 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 1543 |
+
await flushAsync();
|
| 1544 |
+
|
| 1545 |
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
| 1546 |
+
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
| 1547 |
+
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
|
| 1548 |
+
});
|
| 1549 |
+
});
|
| 1550 |
+
|
| 1551 |
+
describe("tapback text parsing", () => {
|
| 1552 |
+
it("does not rewrite tapback-like text without metadata", async () => {
|
| 1553 |
+
const account = createMockAccount({ dmPolicy: "open" });
|
| 1554 |
+
const config: OpenClawConfig = {};
|
| 1555 |
+
const core = createMockRuntime();
|
| 1556 |
+
setBlueBubblesRuntime(core);
|
| 1557 |
+
|
| 1558 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 1559 |
+
account,
|
| 1560 |
+
config,
|
| 1561 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 1562 |
+
core,
|
| 1563 |
+
path: "/bluebubbles-webhook",
|
| 1564 |
+
});
|
| 1565 |
+
|
| 1566 |
+
const payload = {
|
| 1567 |
+
type: "new-message",
|
| 1568 |
+
data: {
|
| 1569 |
+
text: "Loved this idea",
|
| 1570 |
+
handle: { address: "+15551234567" },
|
| 1571 |
+
isGroup: false,
|
| 1572 |
+
isFromMe: false,
|
| 1573 |
+
guid: "msg-1",
|
| 1574 |
+
chatGuid: "iMessage;-;+15551234567",
|
| 1575 |
+
date: Date.now(),
|
| 1576 |
+
},
|
| 1577 |
+
};
|
| 1578 |
+
|
| 1579 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 1580 |
+
const res = createMockResponse();
|
| 1581 |
+
|
| 1582 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 1583 |
+
await flushAsync();
|
| 1584 |
+
|
| 1585 |
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
| 1586 |
+
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
| 1587 |
+
expect(callArgs.ctx.RawBody).toBe("Loved this idea");
|
| 1588 |
+
expect(callArgs.ctx.Body).toContain("Loved this idea");
|
| 1589 |
+
expect(callArgs.ctx.Body).not.toContain("reacted with");
|
| 1590 |
+
});
|
| 1591 |
+
|
| 1592 |
+
it("parses tapback text with custom emoji when metadata is present", async () => {
|
| 1593 |
+
const account = createMockAccount({ dmPolicy: "open" });
|
| 1594 |
+
const config: OpenClawConfig = {};
|
| 1595 |
+
const core = createMockRuntime();
|
| 1596 |
+
setBlueBubblesRuntime(core);
|
| 1597 |
+
|
| 1598 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 1599 |
+
account,
|
| 1600 |
+
config,
|
| 1601 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 1602 |
+
core,
|
| 1603 |
+
path: "/bluebubbles-webhook",
|
| 1604 |
+
});
|
| 1605 |
+
|
| 1606 |
+
const payload = {
|
| 1607 |
+
type: "new-message",
|
| 1608 |
+
data: {
|
| 1609 |
+
text: 'Reacted 😅 to "nice one"',
|
| 1610 |
+
handle: { address: "+15551234567" },
|
| 1611 |
+
isGroup: false,
|
| 1612 |
+
isFromMe: false,
|
| 1613 |
+
guid: "msg-2",
|
| 1614 |
+
chatGuid: "iMessage;-;+15551234567",
|
| 1615 |
+
date: Date.now(),
|
| 1616 |
+
},
|
| 1617 |
+
};
|
| 1618 |
+
|
| 1619 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 1620 |
+
const res = createMockResponse();
|
| 1621 |
+
|
| 1622 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 1623 |
+
await flushAsync();
|
| 1624 |
+
|
| 1625 |
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
| 1626 |
+
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
| 1627 |
+
expect(callArgs.ctx.RawBody).toBe("reacted with 😅");
|
| 1628 |
+
expect(callArgs.ctx.Body).toContain("reacted with 😅");
|
| 1629 |
+
expect(callArgs.ctx.Body).not.toContain("[[reply_to:");
|
| 1630 |
+
});
|
| 1631 |
+
});
|
| 1632 |
+
|
| 1633 |
+
describe("ack reactions", () => {
|
| 1634 |
+
it("sends ack reaction when configured", async () => {
|
| 1635 |
+
const { sendBlueBubblesReaction } = await import("./reactions.js");
|
| 1636 |
+
vi.mocked(sendBlueBubblesReaction).mockClear();
|
| 1637 |
+
|
| 1638 |
+
const account = createMockAccount({ dmPolicy: "open" });
|
| 1639 |
+
const config: OpenClawConfig = {
|
| 1640 |
+
messages: {
|
| 1641 |
+
ackReaction: "❤️",
|
| 1642 |
+
ackReactionScope: "direct",
|
| 1643 |
+
},
|
| 1644 |
+
};
|
| 1645 |
+
const core = createMockRuntime();
|
| 1646 |
+
setBlueBubblesRuntime(core);
|
| 1647 |
+
|
| 1648 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 1649 |
+
account,
|
| 1650 |
+
config,
|
| 1651 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 1652 |
+
core,
|
| 1653 |
+
path: "/bluebubbles-webhook",
|
| 1654 |
+
});
|
| 1655 |
+
|
| 1656 |
+
const payload = {
|
| 1657 |
+
type: "new-message",
|
| 1658 |
+
data: {
|
| 1659 |
+
text: "hello",
|
| 1660 |
+
handle: { address: "+15551234567" },
|
| 1661 |
+
isGroup: false,
|
| 1662 |
+
isFromMe: false,
|
| 1663 |
+
guid: "msg-1",
|
| 1664 |
+
chatGuid: "iMessage;-;+15551234567",
|
| 1665 |
+
date: Date.now(),
|
| 1666 |
+
},
|
| 1667 |
+
};
|
| 1668 |
+
|
| 1669 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 1670 |
+
const res = createMockResponse();
|
| 1671 |
+
|
| 1672 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 1673 |
+
await flushAsync();
|
| 1674 |
+
|
| 1675 |
+
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
|
| 1676 |
+
expect.objectContaining({
|
| 1677 |
+
chatGuid: "iMessage;-;+15551234567",
|
| 1678 |
+
messageGuid: "msg-1",
|
| 1679 |
+
emoji: "❤️",
|
| 1680 |
+
opts: expect.objectContaining({ accountId: "default" }),
|
| 1681 |
+
}),
|
| 1682 |
+
);
|
| 1683 |
+
});
|
| 1684 |
+
});
|
| 1685 |
+
|
| 1686 |
+
describe("command gating", () => {
|
| 1687 |
+
it("allows control command to bypass mention gating when authorized", async () => {
|
| 1688 |
+
mockResolveRequireMention.mockReturnValue(true);
|
| 1689 |
+
mockMatchesMentionPatterns.mockReturnValue(false); // Not mentioned
|
| 1690 |
+
mockHasControlCommand.mockReturnValue(true); // Has control command
|
| 1691 |
+
mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(true); // Authorized
|
| 1692 |
+
|
| 1693 |
+
const account = createMockAccount({
|
| 1694 |
+
groupPolicy: "open",
|
| 1695 |
+
allowFrom: ["+15551234567"],
|
| 1696 |
+
});
|
| 1697 |
+
const config: OpenClawConfig = {};
|
| 1698 |
+
const core = createMockRuntime();
|
| 1699 |
+
setBlueBubblesRuntime(core);
|
| 1700 |
+
|
| 1701 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 1702 |
+
account,
|
| 1703 |
+
config,
|
| 1704 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 1705 |
+
core,
|
| 1706 |
+
path: "/bluebubbles-webhook",
|
| 1707 |
+
});
|
| 1708 |
+
|
| 1709 |
+
const payload = {
|
| 1710 |
+
type: "new-message",
|
| 1711 |
+
data: {
|
| 1712 |
+
text: "/status",
|
| 1713 |
+
handle: { address: "+15551234567" },
|
| 1714 |
+
isGroup: true,
|
| 1715 |
+
isFromMe: false,
|
| 1716 |
+
guid: "msg-1",
|
| 1717 |
+
chatGuid: "iMessage;+;chat123456",
|
| 1718 |
+
date: Date.now(),
|
| 1719 |
+
},
|
| 1720 |
+
};
|
| 1721 |
+
|
| 1722 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 1723 |
+
const res = createMockResponse();
|
| 1724 |
+
|
| 1725 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 1726 |
+
await flushAsync();
|
| 1727 |
+
|
| 1728 |
+
// Should process even without mention because it's an authorized control command
|
| 1729 |
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
| 1730 |
+
});
|
| 1731 |
+
|
| 1732 |
+
it("blocks control command from unauthorized sender in group", async () => {
|
| 1733 |
+
mockHasControlCommand.mockReturnValue(true);
|
| 1734 |
+
mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(false);
|
| 1735 |
+
|
| 1736 |
+
const account = createMockAccount({
|
| 1737 |
+
groupPolicy: "open",
|
| 1738 |
+
allowFrom: [], // No one authorized
|
| 1739 |
+
});
|
| 1740 |
+
const config: OpenClawConfig = {};
|
| 1741 |
+
const core = createMockRuntime();
|
| 1742 |
+
setBlueBubblesRuntime(core);
|
| 1743 |
+
|
| 1744 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 1745 |
+
account,
|
| 1746 |
+
config,
|
| 1747 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 1748 |
+
core,
|
| 1749 |
+
path: "/bluebubbles-webhook",
|
| 1750 |
+
});
|
| 1751 |
+
|
| 1752 |
+
const payload = {
|
| 1753 |
+
type: "new-message",
|
| 1754 |
+
data: {
|
| 1755 |
+
text: "/status",
|
| 1756 |
+
handle: { address: "+15559999999" },
|
| 1757 |
+
isGroup: true,
|
| 1758 |
+
isFromMe: false,
|
| 1759 |
+
guid: "msg-1",
|
| 1760 |
+
chatGuid: "iMessage;+;chat123456",
|
| 1761 |
+
date: Date.now(),
|
| 1762 |
+
},
|
| 1763 |
+
};
|
| 1764 |
+
|
| 1765 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 1766 |
+
const res = createMockResponse();
|
| 1767 |
+
|
| 1768 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 1769 |
+
await flushAsync();
|
| 1770 |
+
|
| 1771 |
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
| 1772 |
+
});
|
| 1773 |
+
});
|
| 1774 |
+
|
| 1775 |
+
describe("typing/read receipt toggles", () => {
|
| 1776 |
+
it("marks chat as read when sendReadReceipts=true (default)", async () => {
|
| 1777 |
+
const { markBlueBubblesChatRead } = await import("./chat.js");
|
| 1778 |
+
vi.mocked(markBlueBubblesChatRead).mockClear();
|
| 1779 |
+
|
| 1780 |
+
const account = createMockAccount({
|
| 1781 |
+
sendReadReceipts: true,
|
| 1782 |
+
});
|
| 1783 |
+
const config: OpenClawConfig = {};
|
| 1784 |
+
const core = createMockRuntime();
|
| 1785 |
+
setBlueBubblesRuntime(core);
|
| 1786 |
+
|
| 1787 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 1788 |
+
account,
|
| 1789 |
+
config,
|
| 1790 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 1791 |
+
core,
|
| 1792 |
+
path: "/bluebubbles-webhook",
|
| 1793 |
+
});
|
| 1794 |
+
|
| 1795 |
+
const payload = {
|
| 1796 |
+
type: "new-message",
|
| 1797 |
+
data: {
|
| 1798 |
+
text: "hello",
|
| 1799 |
+
handle: { address: "+15551234567" },
|
| 1800 |
+
isGroup: false,
|
| 1801 |
+
isFromMe: false,
|
| 1802 |
+
guid: "msg-1",
|
| 1803 |
+
chatGuid: "iMessage;-;+15551234567",
|
| 1804 |
+
date: Date.now(),
|
| 1805 |
+
},
|
| 1806 |
+
};
|
| 1807 |
+
|
| 1808 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 1809 |
+
const res = createMockResponse();
|
| 1810 |
+
|
| 1811 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 1812 |
+
await flushAsync();
|
| 1813 |
+
|
| 1814 |
+
expect(markBlueBubblesChatRead).toHaveBeenCalled();
|
| 1815 |
+
});
|
| 1816 |
+
|
| 1817 |
+
it("does not mark chat as read when sendReadReceipts=false", async () => {
|
| 1818 |
+
const { markBlueBubblesChatRead } = await import("./chat.js");
|
| 1819 |
+
vi.mocked(markBlueBubblesChatRead).mockClear();
|
| 1820 |
+
|
| 1821 |
+
const account = createMockAccount({
|
| 1822 |
+
sendReadReceipts: false,
|
| 1823 |
+
});
|
| 1824 |
+
const config: OpenClawConfig = {};
|
| 1825 |
+
const core = createMockRuntime();
|
| 1826 |
+
setBlueBubblesRuntime(core);
|
| 1827 |
+
|
| 1828 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 1829 |
+
account,
|
| 1830 |
+
config,
|
| 1831 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 1832 |
+
core,
|
| 1833 |
+
path: "/bluebubbles-webhook",
|
| 1834 |
+
});
|
| 1835 |
+
|
| 1836 |
+
const payload = {
|
| 1837 |
+
type: "new-message",
|
| 1838 |
+
data: {
|
| 1839 |
+
text: "hello",
|
| 1840 |
+
handle: { address: "+15551234567" },
|
| 1841 |
+
isGroup: false,
|
| 1842 |
+
isFromMe: false,
|
| 1843 |
+
guid: "msg-1",
|
| 1844 |
+
chatGuid: "iMessage;-;+15551234567",
|
| 1845 |
+
date: Date.now(),
|
| 1846 |
+
},
|
| 1847 |
+
};
|
| 1848 |
+
|
| 1849 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 1850 |
+
const res = createMockResponse();
|
| 1851 |
+
|
| 1852 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 1853 |
+
await flushAsync();
|
| 1854 |
+
|
| 1855 |
+
expect(markBlueBubblesChatRead).not.toHaveBeenCalled();
|
| 1856 |
+
});
|
| 1857 |
+
|
| 1858 |
+
it("sends typing indicator when processing message", async () => {
|
| 1859 |
+
const { sendBlueBubblesTyping } = await import("./chat.js");
|
| 1860 |
+
vi.mocked(sendBlueBubblesTyping).mockClear();
|
| 1861 |
+
|
| 1862 |
+
const account = createMockAccount();
|
| 1863 |
+
const config: OpenClawConfig = {};
|
| 1864 |
+
const core = createMockRuntime();
|
| 1865 |
+
setBlueBubblesRuntime(core);
|
| 1866 |
+
|
| 1867 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 1868 |
+
account,
|
| 1869 |
+
config,
|
| 1870 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 1871 |
+
core,
|
| 1872 |
+
path: "/bluebubbles-webhook",
|
| 1873 |
+
});
|
| 1874 |
+
|
| 1875 |
+
const payload = {
|
| 1876 |
+
type: "new-message",
|
| 1877 |
+
data: {
|
| 1878 |
+
text: "hello",
|
| 1879 |
+
handle: { address: "+15551234567" },
|
| 1880 |
+
isGroup: false,
|
| 1881 |
+
isFromMe: false,
|
| 1882 |
+
guid: "msg-1",
|
| 1883 |
+
chatGuid: "iMessage;-;+15551234567",
|
| 1884 |
+
date: Date.now(),
|
| 1885 |
+
},
|
| 1886 |
+
};
|
| 1887 |
+
|
| 1888 |
+
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
|
| 1889 |
+
await params.dispatcherOptions.onReplyStart?.();
|
| 1890 |
+
});
|
| 1891 |
+
|
| 1892 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 1893 |
+
const res = createMockResponse();
|
| 1894 |
+
|
| 1895 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 1896 |
+
await flushAsync();
|
| 1897 |
+
|
| 1898 |
+
// Should call typing start when reply flow triggers it.
|
| 1899 |
+
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
|
| 1900 |
+
expect.any(String),
|
| 1901 |
+
true,
|
| 1902 |
+
expect.any(Object),
|
| 1903 |
+
);
|
| 1904 |
+
});
|
| 1905 |
+
|
| 1906 |
+
it("stops typing on idle", async () => {
|
| 1907 |
+
const { sendBlueBubblesTyping } = await import("./chat.js");
|
| 1908 |
+
vi.mocked(sendBlueBubblesTyping).mockClear();
|
| 1909 |
+
|
| 1910 |
+
const account = createMockAccount();
|
| 1911 |
+
const config: OpenClawConfig = {};
|
| 1912 |
+
const core = createMockRuntime();
|
| 1913 |
+
setBlueBubblesRuntime(core);
|
| 1914 |
+
|
| 1915 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 1916 |
+
account,
|
| 1917 |
+
config,
|
| 1918 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 1919 |
+
core,
|
| 1920 |
+
path: "/bluebubbles-webhook",
|
| 1921 |
+
});
|
| 1922 |
+
|
| 1923 |
+
const payload = {
|
| 1924 |
+
type: "new-message",
|
| 1925 |
+
data: {
|
| 1926 |
+
text: "hello",
|
| 1927 |
+
handle: { address: "+15551234567" },
|
| 1928 |
+
isGroup: false,
|
| 1929 |
+
isFromMe: false,
|
| 1930 |
+
guid: "msg-1",
|
| 1931 |
+
chatGuid: "iMessage;-;+15551234567",
|
| 1932 |
+
date: Date.now(),
|
| 1933 |
+
},
|
| 1934 |
+
};
|
| 1935 |
+
|
| 1936 |
+
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
|
| 1937 |
+
await params.dispatcherOptions.onReplyStart?.();
|
| 1938 |
+
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
|
| 1939 |
+
await params.dispatcherOptions.onIdle?.();
|
| 1940 |
+
});
|
| 1941 |
+
|
| 1942 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 1943 |
+
const res = createMockResponse();
|
| 1944 |
+
|
| 1945 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 1946 |
+
await flushAsync();
|
| 1947 |
+
|
| 1948 |
+
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
|
| 1949 |
+
expect.any(String),
|
| 1950 |
+
false,
|
| 1951 |
+
expect.any(Object),
|
| 1952 |
+
);
|
| 1953 |
+
});
|
| 1954 |
+
|
| 1955 |
+
it("stops typing when no reply is sent", async () => {
|
| 1956 |
+
const { sendBlueBubblesTyping } = await import("./chat.js");
|
| 1957 |
+
vi.mocked(sendBlueBubblesTyping).mockClear();
|
| 1958 |
+
|
| 1959 |
+
const account = createMockAccount();
|
| 1960 |
+
const config: OpenClawConfig = {};
|
| 1961 |
+
const core = createMockRuntime();
|
| 1962 |
+
setBlueBubblesRuntime(core);
|
| 1963 |
+
|
| 1964 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 1965 |
+
account,
|
| 1966 |
+
config,
|
| 1967 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 1968 |
+
core,
|
| 1969 |
+
path: "/bluebubbles-webhook",
|
| 1970 |
+
});
|
| 1971 |
+
|
| 1972 |
+
const payload = {
|
| 1973 |
+
type: "new-message",
|
| 1974 |
+
data: {
|
| 1975 |
+
text: "hello",
|
| 1976 |
+
handle: { address: "+15551234567" },
|
| 1977 |
+
isGroup: false,
|
| 1978 |
+
isFromMe: false,
|
| 1979 |
+
guid: "msg-1",
|
| 1980 |
+
chatGuid: "iMessage;-;+15551234567",
|
| 1981 |
+
date: Date.now(),
|
| 1982 |
+
},
|
| 1983 |
+
};
|
| 1984 |
+
|
| 1985 |
+
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async () => undefined);
|
| 1986 |
+
|
| 1987 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 1988 |
+
const res = createMockResponse();
|
| 1989 |
+
|
| 1990 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 1991 |
+
await flushAsync();
|
| 1992 |
+
|
| 1993 |
+
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
|
| 1994 |
+
expect.any(String),
|
| 1995 |
+
false,
|
| 1996 |
+
expect.any(Object),
|
| 1997 |
+
);
|
| 1998 |
+
});
|
| 1999 |
+
});
|
| 2000 |
+
|
| 2001 |
+
describe("outbound message ids", () => {
|
| 2002 |
+
it("enqueues system event for outbound message id", async () => {
|
| 2003 |
+
mockEnqueueSystemEvent.mockClear();
|
| 2004 |
+
|
| 2005 |
+
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
|
| 2006 |
+
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
|
| 2007 |
+
});
|
| 2008 |
+
|
| 2009 |
+
const account = createMockAccount();
|
| 2010 |
+
const config: OpenClawConfig = {};
|
| 2011 |
+
const core = createMockRuntime();
|
| 2012 |
+
setBlueBubblesRuntime(core);
|
| 2013 |
+
|
| 2014 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 2015 |
+
account,
|
| 2016 |
+
config,
|
| 2017 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 2018 |
+
core,
|
| 2019 |
+
path: "/bluebubbles-webhook",
|
| 2020 |
+
});
|
| 2021 |
+
|
| 2022 |
+
const payload = {
|
| 2023 |
+
type: "new-message",
|
| 2024 |
+
data: {
|
| 2025 |
+
text: "hello",
|
| 2026 |
+
handle: { address: "+15551234567" },
|
| 2027 |
+
isGroup: false,
|
| 2028 |
+
isFromMe: false,
|
| 2029 |
+
guid: "msg-1",
|
| 2030 |
+
chatGuid: "iMessage;-;+15551234567",
|
| 2031 |
+
date: Date.now(),
|
| 2032 |
+
},
|
| 2033 |
+
};
|
| 2034 |
+
|
| 2035 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 2036 |
+
const res = createMockResponse();
|
| 2037 |
+
|
| 2038 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 2039 |
+
await flushAsync();
|
| 2040 |
+
|
| 2041 |
+
// Outbound message ID uses short ID "2" (inbound msg-1 is "1", outbound msg-123 is "2")
|
| 2042 |
+
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
| 2043 |
+
'Assistant sent "replying now" [message_id:2]',
|
| 2044 |
+
expect.objectContaining({
|
| 2045 |
+
sessionKey: "agent:main:bluebubbles:dm:+15551234567",
|
| 2046 |
+
}),
|
| 2047 |
+
);
|
| 2048 |
+
});
|
| 2049 |
+
});
|
| 2050 |
+
|
| 2051 |
+
describe("reaction events", () => {
|
| 2052 |
+
it("enqueues system event for reaction added", async () => {
|
| 2053 |
+
mockEnqueueSystemEvent.mockClear();
|
| 2054 |
+
|
| 2055 |
+
const account = createMockAccount();
|
| 2056 |
+
const config: OpenClawConfig = {};
|
| 2057 |
+
const core = createMockRuntime();
|
| 2058 |
+
setBlueBubblesRuntime(core);
|
| 2059 |
+
|
| 2060 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 2061 |
+
account,
|
| 2062 |
+
config,
|
| 2063 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 2064 |
+
core,
|
| 2065 |
+
path: "/bluebubbles-webhook",
|
| 2066 |
+
});
|
| 2067 |
+
|
| 2068 |
+
const payload = {
|
| 2069 |
+
type: "message-reaction",
|
| 2070 |
+
data: {
|
| 2071 |
+
handle: { address: "+15551234567" },
|
| 2072 |
+
isGroup: false,
|
| 2073 |
+
isFromMe: false,
|
| 2074 |
+
associatedMessageGuid: "msg-original-123",
|
| 2075 |
+
associatedMessageType: 2000, // Heart reaction added
|
| 2076 |
+
date: Date.now(),
|
| 2077 |
+
},
|
| 2078 |
+
};
|
| 2079 |
+
|
| 2080 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 2081 |
+
const res = createMockResponse();
|
| 2082 |
+
|
| 2083 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 2084 |
+
await flushAsync();
|
| 2085 |
+
|
| 2086 |
+
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
| 2087 |
+
expect.stringContaining("reacted with ❤️ [[reply_to:"),
|
| 2088 |
+
expect.any(Object),
|
| 2089 |
+
);
|
| 2090 |
+
});
|
| 2091 |
+
|
| 2092 |
+
it("enqueues system event for reaction removed", async () => {
|
| 2093 |
+
mockEnqueueSystemEvent.mockClear();
|
| 2094 |
+
|
| 2095 |
+
const account = createMockAccount();
|
| 2096 |
+
const config: OpenClawConfig = {};
|
| 2097 |
+
const core = createMockRuntime();
|
| 2098 |
+
setBlueBubblesRuntime(core);
|
| 2099 |
+
|
| 2100 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 2101 |
+
account,
|
| 2102 |
+
config,
|
| 2103 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 2104 |
+
core,
|
| 2105 |
+
path: "/bluebubbles-webhook",
|
| 2106 |
+
});
|
| 2107 |
+
|
| 2108 |
+
const payload = {
|
| 2109 |
+
type: "message-reaction",
|
| 2110 |
+
data: {
|
| 2111 |
+
handle: { address: "+15551234567" },
|
| 2112 |
+
isGroup: false,
|
| 2113 |
+
isFromMe: false,
|
| 2114 |
+
associatedMessageGuid: "msg-original-123",
|
| 2115 |
+
associatedMessageType: 3000, // Heart reaction removed
|
| 2116 |
+
date: Date.now(),
|
| 2117 |
+
},
|
| 2118 |
+
};
|
| 2119 |
+
|
| 2120 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 2121 |
+
const res = createMockResponse();
|
| 2122 |
+
|
| 2123 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 2124 |
+
await flushAsync();
|
| 2125 |
+
|
| 2126 |
+
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
| 2127 |
+
expect.stringContaining("removed ❤️ reaction [[reply_to:"),
|
| 2128 |
+
expect.any(Object),
|
| 2129 |
+
);
|
| 2130 |
+
});
|
| 2131 |
+
|
| 2132 |
+
it("ignores reaction from self (fromMe=true)", async () => {
|
| 2133 |
+
mockEnqueueSystemEvent.mockClear();
|
| 2134 |
+
|
| 2135 |
+
const account = createMockAccount();
|
| 2136 |
+
const config: OpenClawConfig = {};
|
| 2137 |
+
const core = createMockRuntime();
|
| 2138 |
+
setBlueBubblesRuntime(core);
|
| 2139 |
+
|
| 2140 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 2141 |
+
account,
|
| 2142 |
+
config,
|
| 2143 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 2144 |
+
core,
|
| 2145 |
+
path: "/bluebubbles-webhook",
|
| 2146 |
+
});
|
| 2147 |
+
|
| 2148 |
+
const payload = {
|
| 2149 |
+
type: "message-reaction",
|
| 2150 |
+
data: {
|
| 2151 |
+
handle: { address: "+15551234567" },
|
| 2152 |
+
isGroup: false,
|
| 2153 |
+
isFromMe: true, // From self
|
| 2154 |
+
associatedMessageGuid: "msg-original-123",
|
| 2155 |
+
associatedMessageType: 2000,
|
| 2156 |
+
date: Date.now(),
|
| 2157 |
+
},
|
| 2158 |
+
};
|
| 2159 |
+
|
| 2160 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 2161 |
+
const res = createMockResponse();
|
| 2162 |
+
|
| 2163 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 2164 |
+
await flushAsync();
|
| 2165 |
+
|
| 2166 |
+
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
|
| 2167 |
+
});
|
| 2168 |
+
|
| 2169 |
+
it("maps reaction types to correct emojis", async () => {
|
| 2170 |
+
mockEnqueueSystemEvent.mockClear();
|
| 2171 |
+
|
| 2172 |
+
const account = createMockAccount();
|
| 2173 |
+
const config: OpenClawConfig = {};
|
| 2174 |
+
const core = createMockRuntime();
|
| 2175 |
+
setBlueBubblesRuntime(core);
|
| 2176 |
+
|
| 2177 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 2178 |
+
account,
|
| 2179 |
+
config,
|
| 2180 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 2181 |
+
core,
|
| 2182 |
+
path: "/bluebubbles-webhook",
|
| 2183 |
+
});
|
| 2184 |
+
|
| 2185 |
+
// Test thumbs up reaction (2001)
|
| 2186 |
+
const payload = {
|
| 2187 |
+
type: "message-reaction",
|
| 2188 |
+
data: {
|
| 2189 |
+
handle: { address: "+15551234567" },
|
| 2190 |
+
isGroup: false,
|
| 2191 |
+
isFromMe: false,
|
| 2192 |
+
associatedMessageGuid: "msg-123",
|
| 2193 |
+
associatedMessageType: 2001, // Thumbs up
|
| 2194 |
+
date: Date.now(),
|
| 2195 |
+
},
|
| 2196 |
+
};
|
| 2197 |
+
|
| 2198 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 2199 |
+
const res = createMockResponse();
|
| 2200 |
+
|
| 2201 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 2202 |
+
await flushAsync();
|
| 2203 |
+
|
| 2204 |
+
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
| 2205 |
+
expect.stringContaining("👍"),
|
| 2206 |
+
expect.any(Object),
|
| 2207 |
+
);
|
| 2208 |
+
});
|
| 2209 |
+
});
|
| 2210 |
+
|
| 2211 |
+
describe("short message ID mapping", () => {
|
| 2212 |
+
it("assigns sequential short IDs to messages", async () => {
|
| 2213 |
+
const account = createMockAccount({ dmPolicy: "open" });
|
| 2214 |
+
const config: OpenClawConfig = {};
|
| 2215 |
+
const core = createMockRuntime();
|
| 2216 |
+
setBlueBubblesRuntime(core);
|
| 2217 |
+
|
| 2218 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 2219 |
+
account,
|
| 2220 |
+
config,
|
| 2221 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 2222 |
+
core,
|
| 2223 |
+
path: "/bluebubbles-webhook",
|
| 2224 |
+
});
|
| 2225 |
+
|
| 2226 |
+
const payload = {
|
| 2227 |
+
type: "new-message",
|
| 2228 |
+
data: {
|
| 2229 |
+
text: "hello",
|
| 2230 |
+
handle: { address: "+15551234567" },
|
| 2231 |
+
isGroup: false,
|
| 2232 |
+
isFromMe: false,
|
| 2233 |
+
guid: "p:1/msg-uuid-12345",
|
| 2234 |
+
chatGuid: "iMessage;-;+15551234567",
|
| 2235 |
+
date: Date.now(),
|
| 2236 |
+
},
|
| 2237 |
+
};
|
| 2238 |
+
|
| 2239 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 2240 |
+
const res = createMockResponse();
|
| 2241 |
+
|
| 2242 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 2243 |
+
await flushAsync();
|
| 2244 |
+
|
| 2245 |
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
| 2246 |
+
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
| 2247 |
+
// MessageSid should be short ID "1" instead of full UUID
|
| 2248 |
+
expect(callArgs.ctx.MessageSid).toBe("1");
|
| 2249 |
+
expect(callArgs.ctx.MessageSidFull).toBe("p:1/msg-uuid-12345");
|
| 2250 |
+
});
|
| 2251 |
+
|
| 2252 |
+
it("resolves short ID back to UUID", async () => {
|
| 2253 |
+
const account = createMockAccount({ dmPolicy: "open" });
|
| 2254 |
+
const config: OpenClawConfig = {};
|
| 2255 |
+
const core = createMockRuntime();
|
| 2256 |
+
setBlueBubblesRuntime(core);
|
| 2257 |
+
|
| 2258 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 2259 |
+
account,
|
| 2260 |
+
config,
|
| 2261 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 2262 |
+
core,
|
| 2263 |
+
path: "/bluebubbles-webhook",
|
| 2264 |
+
});
|
| 2265 |
+
|
| 2266 |
+
const payload = {
|
| 2267 |
+
type: "new-message",
|
| 2268 |
+
data: {
|
| 2269 |
+
text: "hello",
|
| 2270 |
+
handle: { address: "+15551234567" },
|
| 2271 |
+
isGroup: false,
|
| 2272 |
+
isFromMe: false,
|
| 2273 |
+
guid: "p:1/msg-uuid-12345",
|
| 2274 |
+
chatGuid: "iMessage;-;+15551234567",
|
| 2275 |
+
date: Date.now(),
|
| 2276 |
+
},
|
| 2277 |
+
};
|
| 2278 |
+
|
| 2279 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 2280 |
+
const res = createMockResponse();
|
| 2281 |
+
|
| 2282 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 2283 |
+
await flushAsync();
|
| 2284 |
+
|
| 2285 |
+
// The short ID "1" should resolve back to the full UUID
|
| 2286 |
+
expect(resolveBlueBubblesMessageId("1")).toBe("p:1/msg-uuid-12345");
|
| 2287 |
+
});
|
| 2288 |
+
|
| 2289 |
+
it("returns UUID unchanged when not in cache", () => {
|
| 2290 |
+
expect(resolveBlueBubblesMessageId("msg-not-cached")).toBe("msg-not-cached");
|
| 2291 |
+
});
|
| 2292 |
+
|
| 2293 |
+
it("returns short ID unchanged when numeric but not in cache", () => {
|
| 2294 |
+
expect(resolveBlueBubblesMessageId("999")).toBe("999");
|
| 2295 |
+
});
|
| 2296 |
+
|
| 2297 |
+
it("throws when numeric short ID is missing and requireKnownShortId is set", () => {
|
| 2298 |
+
expect(() => resolveBlueBubblesMessageId("999", { requireKnownShortId: true })).toThrow(
|
| 2299 |
+
/short message id/i,
|
| 2300 |
+
);
|
| 2301 |
+
});
|
| 2302 |
+
});
|
| 2303 |
+
|
| 2304 |
+
describe("fromMe messages", () => {
|
| 2305 |
+
it("ignores messages from self (fromMe=true)", async () => {
|
| 2306 |
+
const account = createMockAccount();
|
| 2307 |
+
const config: OpenClawConfig = {};
|
| 2308 |
+
const core = createMockRuntime();
|
| 2309 |
+
setBlueBubblesRuntime(core);
|
| 2310 |
+
|
| 2311 |
+
unregister = registerBlueBubblesWebhookTarget({
|
| 2312 |
+
account,
|
| 2313 |
+
config,
|
| 2314 |
+
runtime: { log: vi.fn(), error: vi.fn() },
|
| 2315 |
+
core,
|
| 2316 |
+
path: "/bluebubbles-webhook",
|
| 2317 |
+
});
|
| 2318 |
+
|
| 2319 |
+
const payload = {
|
| 2320 |
+
type: "new-message",
|
| 2321 |
+
data: {
|
| 2322 |
+
text: "my own message",
|
| 2323 |
+
handle: { address: "+15551234567" },
|
| 2324 |
+
isGroup: false,
|
| 2325 |
+
isFromMe: true,
|
| 2326 |
+
guid: "msg-1",
|
| 2327 |
+
date: Date.now(),
|
| 2328 |
+
},
|
| 2329 |
+
};
|
| 2330 |
+
|
| 2331 |
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
| 2332 |
+
const res = createMockResponse();
|
| 2333 |
+
|
| 2334 |
+
await handleBlueBubblesWebhookRequest(req, res);
|
| 2335 |
+
await flushAsync();
|
| 2336 |
+
|
| 2337 |
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
| 2338 |
+
});
|
| 2339 |
+
});
|
| 2340 |
+
});
|
extensions/bluebubbles/src/monitor.ts
ADDED
|
@@ -0,0 +1,2469 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
| 2 |
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
| 3 |
+
import {
|
| 4 |
+
logAckFailure,
|
| 5 |
+
logInboundDrop,
|
| 6 |
+
logTypingFailure,
|
| 7 |
+
resolveAckReaction,
|
| 8 |
+
resolveControlCommandGate,
|
| 9 |
+
} from "openclaw/plugin-sdk";
|
| 10 |
+
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
| 11 |
+
import type { BlueBubblesAccountConfig, BlueBubblesAttachment } from "./types.js";
|
| 12 |
+
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
| 13 |
+
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
|
| 14 |
+
import { sendBlueBubblesMedia } from "./media-send.js";
|
| 15 |
+
import { fetchBlueBubblesServerInfo } from "./probe.js";
|
| 16 |
+
import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
|
| 17 |
+
import { getBlueBubblesRuntime } from "./runtime.js";
|
| 18 |
+
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
| 19 |
+
import {
|
| 20 |
+
formatBlueBubblesChatTarget,
|
| 21 |
+
isAllowedBlueBubblesSender,
|
| 22 |
+
normalizeBlueBubblesHandle,
|
| 23 |
+
} from "./targets.js";
|
| 24 |
+
|
| 25 |
+
export type BlueBubblesRuntimeEnv = {
|
| 26 |
+
log?: (message: string) => void;
|
| 27 |
+
error?: (message: string) => void;
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
export type BlueBubblesMonitorOptions = {
|
| 31 |
+
account: ResolvedBlueBubblesAccount;
|
| 32 |
+
config: OpenClawConfig;
|
| 33 |
+
runtime: BlueBubblesRuntimeEnv;
|
| 34 |
+
abortSignal: AbortSignal;
|
| 35 |
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
| 36 |
+
webhookPath?: string;
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook";
|
| 40 |
+
const DEFAULT_TEXT_LIMIT = 4000;
|
| 41 |
+
const invalidAckReactions = new Set<string>();
|
| 42 |
+
|
| 43 |
+
const REPLY_CACHE_MAX = 2000;
|
| 44 |
+
const REPLY_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
| 45 |
+
|
| 46 |
+
type BlueBubblesReplyCacheEntry = {
|
| 47 |
+
accountId: string;
|
| 48 |
+
messageId: string;
|
| 49 |
+
shortId: string;
|
| 50 |
+
chatGuid?: string;
|
| 51 |
+
chatIdentifier?: string;
|
| 52 |
+
chatId?: number;
|
| 53 |
+
senderLabel?: string;
|
| 54 |
+
body?: string;
|
| 55 |
+
timestamp: number;
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
// Best-effort cache for resolving reply context when BlueBubbles webhooks omit sender/body.
|
| 59 |
+
const blueBubblesReplyCacheByMessageId = new Map<string, BlueBubblesReplyCacheEntry>();
|
| 60 |
+
|
| 61 |
+
// Bidirectional maps for short ID ↔ message GUID resolution (token savings optimization)
|
| 62 |
+
const blueBubblesShortIdToUuid = new Map<string, string>();
|
| 63 |
+
const blueBubblesUuidToShortId = new Map<string, string>();
|
| 64 |
+
let blueBubblesShortIdCounter = 0;
|
| 65 |
+
|
| 66 |
+
function trimOrUndefined(value?: string | null): string | undefined {
|
| 67 |
+
const trimmed = value?.trim();
|
| 68 |
+
return trimmed ? trimmed : undefined;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
function generateShortId(): string {
|
| 72 |
+
blueBubblesShortIdCounter += 1;
|
| 73 |
+
return String(blueBubblesShortIdCounter);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function rememberBlueBubblesReplyCache(
|
| 77 |
+
entry: Omit<BlueBubblesReplyCacheEntry, "shortId">,
|
| 78 |
+
): BlueBubblesReplyCacheEntry {
|
| 79 |
+
const messageId = entry.messageId.trim();
|
| 80 |
+
if (!messageId) {
|
| 81 |
+
return { ...entry, shortId: "" };
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
// Check if we already have a short ID for this GUID
|
| 85 |
+
let shortId = blueBubblesUuidToShortId.get(messageId);
|
| 86 |
+
if (!shortId) {
|
| 87 |
+
shortId = generateShortId();
|
| 88 |
+
blueBubblesShortIdToUuid.set(shortId, messageId);
|
| 89 |
+
blueBubblesUuidToShortId.set(messageId, shortId);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, messageId, shortId };
|
| 93 |
+
|
| 94 |
+
// Refresh insertion order.
|
| 95 |
+
blueBubblesReplyCacheByMessageId.delete(messageId);
|
| 96 |
+
blueBubblesReplyCacheByMessageId.set(messageId, fullEntry);
|
| 97 |
+
|
| 98 |
+
// Opportunistic prune.
|
| 99 |
+
const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
|
| 100 |
+
for (const [key, value] of blueBubblesReplyCacheByMessageId) {
|
| 101 |
+
if (value.timestamp < cutoff) {
|
| 102 |
+
blueBubblesReplyCacheByMessageId.delete(key);
|
| 103 |
+
// Clean up short ID mappings for expired entries
|
| 104 |
+
if (value.shortId) {
|
| 105 |
+
blueBubblesShortIdToUuid.delete(value.shortId);
|
| 106 |
+
blueBubblesUuidToShortId.delete(key);
|
| 107 |
+
}
|
| 108 |
+
continue;
|
| 109 |
+
}
|
| 110 |
+
break;
|
| 111 |
+
}
|
| 112 |
+
while (blueBubblesReplyCacheByMessageId.size > REPLY_CACHE_MAX) {
|
| 113 |
+
const oldest = blueBubblesReplyCacheByMessageId.keys().next().value as string | undefined;
|
| 114 |
+
if (!oldest) {
|
| 115 |
+
break;
|
| 116 |
+
}
|
| 117 |
+
const oldEntry = blueBubblesReplyCacheByMessageId.get(oldest);
|
| 118 |
+
blueBubblesReplyCacheByMessageId.delete(oldest);
|
| 119 |
+
// Clean up short ID mappings for evicted entries
|
| 120 |
+
if (oldEntry?.shortId) {
|
| 121 |
+
blueBubblesShortIdToUuid.delete(oldEntry.shortId);
|
| 122 |
+
blueBubblesUuidToShortId.delete(oldest);
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
return fullEntry;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
/**
|
| 130 |
+
* Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles GUID.
|
| 131 |
+
* Returns the input unchanged if it's already a GUID or not found in the mapping.
|
| 132 |
+
*/
|
| 133 |
+
export function resolveBlueBubblesMessageId(
|
| 134 |
+
shortOrUuid: string,
|
| 135 |
+
opts?: { requireKnownShortId?: boolean },
|
| 136 |
+
): string {
|
| 137 |
+
const trimmed = shortOrUuid.trim();
|
| 138 |
+
if (!trimmed) {
|
| 139 |
+
return trimmed;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
// If it looks like a short ID (numeric), try to resolve it
|
| 143 |
+
if (/^\d+$/.test(trimmed)) {
|
| 144 |
+
const uuid = blueBubblesShortIdToUuid.get(trimmed);
|
| 145 |
+
if (uuid) {
|
| 146 |
+
return uuid;
|
| 147 |
+
}
|
| 148 |
+
if (opts?.requireKnownShortId) {
|
| 149 |
+
throw new Error(
|
| 150 |
+
`BlueBubbles short message id "${trimmed}" is no longer available. Use MessageSidFull.`,
|
| 151 |
+
);
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
// Return as-is (either already a UUID or not found)
|
| 156 |
+
return trimmed;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
/**
|
| 160 |
+
* Resets the short ID state. Only use in tests.
|
| 161 |
+
* @internal
|
| 162 |
+
*/
|
| 163 |
+
export function _resetBlueBubblesShortIdState(): void {
|
| 164 |
+
blueBubblesShortIdToUuid.clear();
|
| 165 |
+
blueBubblesUuidToShortId.clear();
|
| 166 |
+
blueBubblesReplyCacheByMessageId.clear();
|
| 167 |
+
blueBubblesShortIdCounter = 0;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
/**
|
| 171 |
+
* Gets the short ID for a message GUID, if one exists.
|
| 172 |
+
*/
|
| 173 |
+
function getShortIdForUuid(uuid: string): string | undefined {
|
| 174 |
+
return blueBubblesUuidToShortId.get(uuid.trim());
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
function resolveReplyContextFromCache(params: {
|
| 178 |
+
accountId: string;
|
| 179 |
+
replyToId: string;
|
| 180 |
+
chatGuid?: string;
|
| 181 |
+
chatIdentifier?: string;
|
| 182 |
+
chatId?: number;
|
| 183 |
+
}): BlueBubblesReplyCacheEntry | null {
|
| 184 |
+
const replyToId = params.replyToId.trim();
|
| 185 |
+
if (!replyToId) {
|
| 186 |
+
return null;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
const cached = blueBubblesReplyCacheByMessageId.get(replyToId);
|
| 190 |
+
if (!cached) {
|
| 191 |
+
return null;
|
| 192 |
+
}
|
| 193 |
+
if (cached.accountId !== params.accountId) {
|
| 194 |
+
return null;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
|
| 198 |
+
if (cached.timestamp < cutoff) {
|
| 199 |
+
blueBubblesReplyCacheByMessageId.delete(replyToId);
|
| 200 |
+
return null;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
const chatGuid = trimOrUndefined(params.chatGuid);
|
| 204 |
+
const chatIdentifier = trimOrUndefined(params.chatIdentifier);
|
| 205 |
+
const cachedChatGuid = trimOrUndefined(cached.chatGuid);
|
| 206 |
+
const cachedChatIdentifier = trimOrUndefined(cached.chatIdentifier);
|
| 207 |
+
const chatId = typeof params.chatId === "number" ? params.chatId : undefined;
|
| 208 |
+
const cachedChatId = typeof cached.chatId === "number" ? cached.chatId : undefined;
|
| 209 |
+
|
| 210 |
+
// Avoid cross-chat collisions if we have identifiers.
|
| 211 |
+
if (chatGuid && cachedChatGuid && chatGuid !== cachedChatGuid) {
|
| 212 |
+
return null;
|
| 213 |
+
}
|
| 214 |
+
if (
|
| 215 |
+
!chatGuid &&
|
| 216 |
+
chatIdentifier &&
|
| 217 |
+
cachedChatIdentifier &&
|
| 218 |
+
chatIdentifier !== cachedChatIdentifier
|
| 219 |
+
) {
|
| 220 |
+
return null;
|
| 221 |
+
}
|
| 222 |
+
if (!chatGuid && !chatIdentifier && chatId && cachedChatId && chatId !== cachedChatId) {
|
| 223 |
+
return null;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
return cached;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
type BlueBubblesCoreRuntime = ReturnType<typeof getBlueBubblesRuntime>;
|
| 230 |
+
|
| 231 |
+
function logVerbose(
|
| 232 |
+
core: BlueBubblesCoreRuntime,
|
| 233 |
+
runtime: BlueBubblesRuntimeEnv,
|
| 234 |
+
message: string,
|
| 235 |
+
): void {
|
| 236 |
+
if (core.logging.shouldLogVerbose()) {
|
| 237 |
+
runtime.log?.(`[bluebubbles] ${message}`);
|
| 238 |
+
}
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
function logGroupAllowlistHint(params: {
|
| 242 |
+
runtime: BlueBubblesRuntimeEnv;
|
| 243 |
+
reason: string;
|
| 244 |
+
entry: string | null;
|
| 245 |
+
chatName?: string;
|
| 246 |
+
accountId?: string;
|
| 247 |
+
}): void {
|
| 248 |
+
const log = params.runtime.log ?? console.log;
|
| 249 |
+
const nameHint = params.chatName ? ` (group name: ${params.chatName})` : "";
|
| 250 |
+
const accountHint = params.accountId
|
| 251 |
+
? ` (or channels.bluebubbles.accounts.${params.accountId}.groupAllowFrom)`
|
| 252 |
+
: "";
|
| 253 |
+
if (params.entry) {
|
| 254 |
+
log(
|
| 255 |
+
`[bluebubbles] group message blocked (${params.reason}). Allow this group by adding ` +
|
| 256 |
+
`"${params.entry}" to channels.bluebubbles.groupAllowFrom${nameHint}.`,
|
| 257 |
+
);
|
| 258 |
+
log(
|
| 259 |
+
`[bluebubbles] add to config: channels.bluebubbles.groupAllowFrom=["${params.entry}"]${accountHint}.`,
|
| 260 |
+
);
|
| 261 |
+
return;
|
| 262 |
+
}
|
| 263 |
+
log(
|
| 264 |
+
`[bluebubbles] group message blocked (${params.reason}). Allow groups by setting ` +
|
| 265 |
+
`channels.bluebubbles.groupPolicy="open" or adding a group id to ` +
|
| 266 |
+
`channels.bluebubbles.groupAllowFrom${accountHint}${nameHint}.`,
|
| 267 |
+
);
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
type WebhookTarget = {
|
| 271 |
+
account: ResolvedBlueBubblesAccount;
|
| 272 |
+
config: OpenClawConfig;
|
| 273 |
+
runtime: BlueBubblesRuntimeEnv;
|
| 274 |
+
core: BlueBubblesCoreRuntime;
|
| 275 |
+
path: string;
|
| 276 |
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
| 277 |
+
};
|
| 278 |
+
|
| 279 |
+
/**
|
| 280 |
+
* Entry type for debouncing inbound messages.
|
| 281 |
+
* Captures the normalized message and its target for later combined processing.
|
| 282 |
+
*/
|
| 283 |
+
type BlueBubblesDebounceEntry = {
|
| 284 |
+
message: NormalizedWebhookMessage;
|
| 285 |
+
target: WebhookTarget;
|
| 286 |
+
};
|
| 287 |
+
|
| 288 |
+
/**
|
| 289 |
+
* Default debounce window for inbound message coalescing (ms).
|
| 290 |
+
* This helps combine URL text + link preview balloon messages that BlueBubbles
|
| 291 |
+
* sends as separate webhook events when no explicit inbound debounce config exists.
|
| 292 |
+
*/
|
| 293 |
+
const DEFAULT_INBOUND_DEBOUNCE_MS = 500;
|
| 294 |
+
|
| 295 |
+
/**
|
| 296 |
+
* Combines multiple debounced messages into a single message for processing.
|
| 297 |
+
* Used when multiple webhook events arrive within the debounce window.
|
| 298 |
+
*/
|
| 299 |
+
function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): NormalizedWebhookMessage {
|
| 300 |
+
if (entries.length === 0) {
|
| 301 |
+
throw new Error("Cannot combine empty entries");
|
| 302 |
+
}
|
| 303 |
+
if (entries.length === 1) {
|
| 304 |
+
return entries[0].message;
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
// Use the first message as the base (typically the text message)
|
| 308 |
+
const first = entries[0].message;
|
| 309 |
+
|
| 310 |
+
// Combine text from all entries, filtering out duplicates and empty strings
|
| 311 |
+
const seenTexts = new Set<string>();
|
| 312 |
+
const textParts: string[] = [];
|
| 313 |
+
|
| 314 |
+
for (const entry of entries) {
|
| 315 |
+
const text = entry.message.text.trim();
|
| 316 |
+
if (!text) {
|
| 317 |
+
continue;
|
| 318 |
+
}
|
| 319 |
+
// Skip duplicate text (URL might be in both text message and balloon)
|
| 320 |
+
const normalizedText = text.toLowerCase();
|
| 321 |
+
if (seenTexts.has(normalizedText)) {
|
| 322 |
+
continue;
|
| 323 |
+
}
|
| 324 |
+
seenTexts.add(normalizedText);
|
| 325 |
+
textParts.push(text);
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
// Merge attachments from all entries
|
| 329 |
+
const allAttachments = entries.flatMap((e) => e.message.attachments ?? []);
|
| 330 |
+
|
| 331 |
+
// Use the latest timestamp
|
| 332 |
+
const timestamps = entries
|
| 333 |
+
.map((e) => e.message.timestamp)
|
| 334 |
+
.filter((t): t is number => typeof t === "number");
|
| 335 |
+
const latestTimestamp = timestamps.length > 0 ? Math.max(...timestamps) : first.timestamp;
|
| 336 |
+
|
| 337 |
+
// Collect all message IDs for reference
|
| 338 |
+
const messageIds = entries
|
| 339 |
+
.map((e) => e.message.messageId)
|
| 340 |
+
.filter((id): id is string => Boolean(id));
|
| 341 |
+
|
| 342 |
+
// Prefer reply context from any entry that has it
|
| 343 |
+
const entryWithReply = entries.find((e) => e.message.replyToId);
|
| 344 |
+
|
| 345 |
+
return {
|
| 346 |
+
...first,
|
| 347 |
+
text: textParts.join(" "),
|
| 348 |
+
attachments: allAttachments.length > 0 ? allAttachments : first.attachments,
|
| 349 |
+
timestamp: latestTimestamp,
|
| 350 |
+
// Use first message's ID as primary (for reply reference), but we've coalesced others
|
| 351 |
+
messageId: messageIds[0] ?? first.messageId,
|
| 352 |
+
// Preserve reply context if present
|
| 353 |
+
replyToId: entryWithReply?.message.replyToId ?? first.replyToId,
|
| 354 |
+
replyToBody: entryWithReply?.message.replyToBody ?? first.replyToBody,
|
| 355 |
+
replyToSender: entryWithReply?.message.replyToSender ?? first.replyToSender,
|
| 356 |
+
// Clear balloonBundleId since we've combined (the combined message is no longer just a balloon)
|
| 357 |
+
balloonBundleId: undefined,
|
| 358 |
+
};
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
const webhookTargets = new Map<string, WebhookTarget[]>();
|
| 362 |
+
|
| 363 |
+
/**
|
| 364 |
+
* Maps webhook targets to their inbound debouncers.
|
| 365 |
+
* Each target gets its own debouncer keyed by a unique identifier.
|
| 366 |
+
*/
|
| 367 |
+
const targetDebouncers = new Map<
|
| 368 |
+
WebhookTarget,
|
| 369 |
+
ReturnType<BlueBubblesCoreRuntime["channel"]["debounce"]["createInboundDebouncer"]>
|
| 370 |
+
>();
|
| 371 |
+
|
| 372 |
+
function resolveBlueBubblesDebounceMs(
|
| 373 |
+
config: OpenClawConfig,
|
| 374 |
+
core: BlueBubblesCoreRuntime,
|
| 375 |
+
): number {
|
| 376 |
+
const inbound = config.messages?.inbound;
|
| 377 |
+
const hasExplicitDebounce =
|
| 378 |
+
typeof inbound?.debounceMs === "number" || typeof inbound?.byChannel?.bluebubbles === "number";
|
| 379 |
+
if (!hasExplicitDebounce) {
|
| 380 |
+
return DEFAULT_INBOUND_DEBOUNCE_MS;
|
| 381 |
+
}
|
| 382 |
+
return core.channel.debounce.resolveInboundDebounceMs({ cfg: config, channel: "bluebubbles" });
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
/**
|
| 386 |
+
* Creates or retrieves a debouncer for a webhook target.
|
| 387 |
+
*/
|
| 388 |
+
function getOrCreateDebouncer(target: WebhookTarget) {
|
| 389 |
+
const existing = targetDebouncers.get(target);
|
| 390 |
+
if (existing) {
|
| 391 |
+
return existing;
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
const { account, config, runtime, core } = target;
|
| 395 |
+
|
| 396 |
+
const debouncer = core.channel.debounce.createInboundDebouncer<BlueBubblesDebounceEntry>({
|
| 397 |
+
debounceMs: resolveBlueBubblesDebounceMs(config, core),
|
| 398 |
+
buildKey: (entry) => {
|
| 399 |
+
const msg = entry.message;
|
| 400 |
+
// Prefer stable, shared identifiers to coalesce rapid-fire webhook events for the
|
| 401 |
+
// same message (e.g., text-only then text+attachment).
|
| 402 |
+
//
|
| 403 |
+
// For balloons (URL previews, stickers, etc), BlueBubbles often uses a different
|
| 404 |
+
// messageId than the originating text. When present, key by associatedMessageGuid
|
| 405 |
+
// to keep text + balloon coalescing working.
|
| 406 |
+
const balloonBundleId = msg.balloonBundleId?.trim();
|
| 407 |
+
const associatedMessageGuid = msg.associatedMessageGuid?.trim();
|
| 408 |
+
if (balloonBundleId && associatedMessageGuid) {
|
| 409 |
+
return `bluebubbles:${account.accountId}:balloon:${associatedMessageGuid}`;
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
const messageId = msg.messageId?.trim();
|
| 413 |
+
if (messageId) {
|
| 414 |
+
return `bluebubbles:${account.accountId}:msg:${messageId}`;
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
const chatKey =
|
| 418 |
+
msg.chatGuid?.trim() ??
|
| 419 |
+
msg.chatIdentifier?.trim() ??
|
| 420 |
+
(msg.chatId ? String(msg.chatId) : "dm");
|
| 421 |
+
return `bluebubbles:${account.accountId}:${chatKey}:${msg.senderId}`;
|
| 422 |
+
},
|
| 423 |
+
shouldDebounce: (entry) => {
|
| 424 |
+
const msg = entry.message;
|
| 425 |
+
// Skip debouncing for from-me messages (they're just cached, not processed)
|
| 426 |
+
if (msg.fromMe) {
|
| 427 |
+
return false;
|
| 428 |
+
}
|
| 429 |
+
// Skip debouncing for control commands - process immediately
|
| 430 |
+
if (core.channel.text.hasControlCommand(msg.text, config)) {
|
| 431 |
+
return false;
|
| 432 |
+
}
|
| 433 |
+
// Debounce all other messages to coalesce rapid-fire webhook events
|
| 434 |
+
// (e.g., text+image arriving as separate webhooks for the same messageId)
|
| 435 |
+
return true;
|
| 436 |
+
},
|
| 437 |
+
onFlush: async (entries) => {
|
| 438 |
+
if (entries.length === 0) {
|
| 439 |
+
return;
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
// Use target from first entry (all entries have same target due to key structure)
|
| 443 |
+
const flushTarget = entries[0].target;
|
| 444 |
+
|
| 445 |
+
if (entries.length === 1) {
|
| 446 |
+
// Single message - process normally
|
| 447 |
+
await processMessage(entries[0].message, flushTarget);
|
| 448 |
+
return;
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
// Multiple messages - combine and process
|
| 452 |
+
const combined = combineDebounceEntries(entries);
|
| 453 |
+
|
| 454 |
+
if (core.logging.shouldLogVerbose()) {
|
| 455 |
+
const count = entries.length;
|
| 456 |
+
const preview = combined.text.slice(0, 50);
|
| 457 |
+
runtime.log?.(
|
| 458 |
+
`[bluebubbles] coalesced ${count} messages: "${preview}${combined.text.length > 50 ? "..." : ""}"`,
|
| 459 |
+
);
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
await processMessage(combined, flushTarget);
|
| 463 |
+
},
|
| 464 |
+
onError: (err) => {
|
| 465 |
+
runtime.error?.(`[${account.accountId}] [bluebubbles] debounce flush failed: ${String(err)}`);
|
| 466 |
+
},
|
| 467 |
+
});
|
| 468 |
+
|
| 469 |
+
targetDebouncers.set(target, debouncer);
|
| 470 |
+
return debouncer;
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
/**
|
| 474 |
+
* Removes a debouncer for a target (called during unregistration).
|
| 475 |
+
*/
|
| 476 |
+
function removeDebouncer(target: WebhookTarget): void {
|
| 477 |
+
targetDebouncers.delete(target);
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
function normalizeWebhookPath(raw: string): string {
|
| 481 |
+
const trimmed = raw.trim();
|
| 482 |
+
if (!trimmed) {
|
| 483 |
+
return "/";
|
| 484 |
+
}
|
| 485 |
+
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
| 486 |
+
if (withSlash.length > 1 && withSlash.endsWith("/")) {
|
| 487 |
+
return withSlash.slice(0, -1);
|
| 488 |
+
}
|
| 489 |
+
return withSlash;
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void {
|
| 493 |
+
const key = normalizeWebhookPath(target.path);
|
| 494 |
+
const normalizedTarget = { ...target, path: key };
|
| 495 |
+
const existing = webhookTargets.get(key) ?? [];
|
| 496 |
+
const next = [...existing, normalizedTarget];
|
| 497 |
+
webhookTargets.set(key, next);
|
| 498 |
+
return () => {
|
| 499 |
+
const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
|
| 500 |
+
if (updated.length > 0) {
|
| 501 |
+
webhookTargets.set(key, updated);
|
| 502 |
+
} else {
|
| 503 |
+
webhookTargets.delete(key);
|
| 504 |
+
}
|
| 505 |
+
// Clean up debouncer when target is unregistered
|
| 506 |
+
removeDebouncer(normalizedTarget);
|
| 507 |
+
};
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
async function readJsonBody(req: IncomingMessage, maxBytes: number) {
|
| 511 |
+
const chunks: Buffer[] = [];
|
| 512 |
+
let total = 0;
|
| 513 |
+
return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => {
|
| 514 |
+
req.on("data", (chunk: Buffer) => {
|
| 515 |
+
total += chunk.length;
|
| 516 |
+
if (total > maxBytes) {
|
| 517 |
+
resolve({ ok: false, error: "payload too large" });
|
| 518 |
+
req.destroy();
|
| 519 |
+
return;
|
| 520 |
+
}
|
| 521 |
+
chunks.push(chunk);
|
| 522 |
+
});
|
| 523 |
+
req.on("end", () => {
|
| 524 |
+
try {
|
| 525 |
+
const raw = Buffer.concat(chunks).toString("utf8");
|
| 526 |
+
if (!raw.trim()) {
|
| 527 |
+
resolve({ ok: false, error: "empty payload" });
|
| 528 |
+
return;
|
| 529 |
+
}
|
| 530 |
+
try {
|
| 531 |
+
resolve({ ok: true, value: JSON.parse(raw) as unknown });
|
| 532 |
+
return;
|
| 533 |
+
} catch {
|
| 534 |
+
const params = new URLSearchParams(raw);
|
| 535 |
+
const payload = params.get("payload") ?? params.get("data") ?? params.get("message");
|
| 536 |
+
if (payload) {
|
| 537 |
+
resolve({ ok: true, value: JSON.parse(payload) as unknown });
|
| 538 |
+
return;
|
| 539 |
+
}
|
| 540 |
+
throw new Error("invalid json");
|
| 541 |
+
}
|
| 542 |
+
} catch (err) {
|
| 543 |
+
resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
| 544 |
+
}
|
| 545 |
+
});
|
| 546 |
+
req.on("error", (err) => {
|
| 547 |
+
resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
| 548 |
+
});
|
| 549 |
+
});
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
| 553 |
+
return value && typeof value === "object" && !Array.isArray(value)
|
| 554 |
+
? (value as Record<string, unknown>)
|
| 555 |
+
: null;
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
function readString(record: Record<string, unknown> | null, key: string): string | undefined {
|
| 559 |
+
if (!record) {
|
| 560 |
+
return undefined;
|
| 561 |
+
}
|
| 562 |
+
const value = record[key];
|
| 563 |
+
return typeof value === "string" ? value : undefined;
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
function readNumber(record: Record<string, unknown> | null, key: string): number | undefined {
|
| 567 |
+
if (!record) {
|
| 568 |
+
return undefined;
|
| 569 |
+
}
|
| 570 |
+
const value = record[key];
|
| 571 |
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
function readBoolean(record: Record<string, unknown> | null, key: string): boolean | undefined {
|
| 575 |
+
if (!record) {
|
| 576 |
+
return undefined;
|
| 577 |
+
}
|
| 578 |
+
const value = record[key];
|
| 579 |
+
return typeof value === "boolean" ? value : undefined;
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
function extractAttachments(message: Record<string, unknown>): BlueBubblesAttachment[] {
|
| 583 |
+
const raw = message["attachments"];
|
| 584 |
+
if (!Array.isArray(raw)) {
|
| 585 |
+
return [];
|
| 586 |
+
}
|
| 587 |
+
const out: BlueBubblesAttachment[] = [];
|
| 588 |
+
for (const entry of raw) {
|
| 589 |
+
const record = asRecord(entry);
|
| 590 |
+
if (!record) {
|
| 591 |
+
continue;
|
| 592 |
+
}
|
| 593 |
+
out.push({
|
| 594 |
+
guid: readString(record, "guid"),
|
| 595 |
+
uti: readString(record, "uti"),
|
| 596 |
+
mimeType: readString(record, "mimeType") ?? readString(record, "mime_type"),
|
| 597 |
+
transferName: readString(record, "transferName") ?? readString(record, "transfer_name"),
|
| 598 |
+
totalBytes: readNumberLike(record, "totalBytes") ?? readNumberLike(record, "total_bytes"),
|
| 599 |
+
height: readNumberLike(record, "height"),
|
| 600 |
+
width: readNumberLike(record, "width"),
|
| 601 |
+
originalROWID: readNumberLike(record, "originalROWID") ?? readNumberLike(record, "rowid"),
|
| 602 |
+
});
|
| 603 |
+
}
|
| 604 |
+
return out;
|
| 605 |
+
}
|
| 606 |
+
|
| 607 |
+
function buildAttachmentPlaceholder(attachments: BlueBubblesAttachment[]): string {
|
| 608 |
+
if (attachments.length === 0) {
|
| 609 |
+
return "";
|
| 610 |
+
}
|
| 611 |
+
const mimeTypes = attachments.map((entry) => entry.mimeType ?? "");
|
| 612 |
+
const allImages = mimeTypes.every((entry) => entry.startsWith("image/"));
|
| 613 |
+
const allVideos = mimeTypes.every((entry) => entry.startsWith("video/"));
|
| 614 |
+
const allAudio = mimeTypes.every((entry) => entry.startsWith("audio/"));
|
| 615 |
+
const tag = allImages
|
| 616 |
+
? "<media:image>"
|
| 617 |
+
: allVideos
|
| 618 |
+
? "<media:video>"
|
| 619 |
+
: allAudio
|
| 620 |
+
? "<media:audio>"
|
| 621 |
+
: "<media:attachment>";
|
| 622 |
+
const label = allImages ? "image" : allVideos ? "video" : allAudio ? "audio" : "file";
|
| 623 |
+
const suffix = attachments.length === 1 ? label : `${label}s`;
|
| 624 |
+
return `${tag} (${attachments.length} ${suffix})`;
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
|
| 628 |
+
const attachmentPlaceholder = buildAttachmentPlaceholder(message.attachments ?? []);
|
| 629 |
+
if (attachmentPlaceholder) {
|
| 630 |
+
return attachmentPlaceholder;
|
| 631 |
+
}
|
| 632 |
+
if (message.balloonBundleId) {
|
| 633 |
+
return "<media:sticker>";
|
| 634 |
+
}
|
| 635 |
+
return "";
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
+
// Returns inline reply tag like "[[reply_to:4]]" for prepending to message body
|
| 639 |
+
function formatReplyTag(message: { replyToId?: string; replyToShortId?: string }): string | null {
|
| 640 |
+
// Prefer short ID
|
| 641 |
+
const rawId = message.replyToShortId || message.replyToId;
|
| 642 |
+
if (!rawId) {
|
| 643 |
+
return null;
|
| 644 |
+
}
|
| 645 |
+
return `[[reply_to:${rawId}]]`;
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
|
| 649 |
+
if (!record) {
|
| 650 |
+
return undefined;
|
| 651 |
+
}
|
| 652 |
+
const value = record[key];
|
| 653 |
+
if (typeof value === "number" && Number.isFinite(value)) {
|
| 654 |
+
return value;
|
| 655 |
+
}
|
| 656 |
+
if (typeof value === "string") {
|
| 657 |
+
const parsed = Number.parseFloat(value);
|
| 658 |
+
if (Number.isFinite(parsed)) {
|
| 659 |
+
return parsed;
|
| 660 |
+
}
|
| 661 |
+
}
|
| 662 |
+
return undefined;
|
| 663 |
+
}
|
| 664 |
+
|
| 665 |
+
function extractReplyMetadata(message: Record<string, unknown>): {
|
| 666 |
+
replyToId?: string;
|
| 667 |
+
replyToBody?: string;
|
| 668 |
+
replyToSender?: string;
|
| 669 |
+
} {
|
| 670 |
+
const replyRaw =
|
| 671 |
+
message["replyTo"] ??
|
| 672 |
+
message["reply_to"] ??
|
| 673 |
+
message["replyToMessage"] ??
|
| 674 |
+
message["reply_to_message"] ??
|
| 675 |
+
message["repliedMessage"] ??
|
| 676 |
+
message["quotedMessage"] ??
|
| 677 |
+
message["associatedMessage"] ??
|
| 678 |
+
message["reply"];
|
| 679 |
+
const replyRecord = asRecord(replyRaw);
|
| 680 |
+
const replyHandle =
|
| 681 |
+
asRecord(replyRecord?.["handle"]) ?? asRecord(replyRecord?.["sender"]) ?? null;
|
| 682 |
+
const replySenderRaw =
|
| 683 |
+
readString(replyHandle, "address") ??
|
| 684 |
+
readString(replyHandle, "handle") ??
|
| 685 |
+
readString(replyHandle, "id") ??
|
| 686 |
+
readString(replyRecord, "senderId") ??
|
| 687 |
+
readString(replyRecord, "sender") ??
|
| 688 |
+
readString(replyRecord, "from");
|
| 689 |
+
const normalizedSender = replySenderRaw
|
| 690 |
+
? normalizeBlueBubblesHandle(replySenderRaw) || replySenderRaw.trim()
|
| 691 |
+
: undefined;
|
| 692 |
+
|
| 693 |
+
const replyToBody =
|
| 694 |
+
readString(replyRecord, "text") ??
|
| 695 |
+
readString(replyRecord, "body") ??
|
| 696 |
+
readString(replyRecord, "message") ??
|
| 697 |
+
readString(replyRecord, "subject") ??
|
| 698 |
+
undefined;
|
| 699 |
+
|
| 700 |
+
const directReplyId =
|
| 701 |
+
readString(message, "replyToMessageGuid") ??
|
| 702 |
+
readString(message, "replyToGuid") ??
|
| 703 |
+
readString(message, "replyGuid") ??
|
| 704 |
+
readString(message, "selectedMessageGuid") ??
|
| 705 |
+
readString(message, "selectedMessageId") ??
|
| 706 |
+
readString(message, "replyToMessageId") ??
|
| 707 |
+
readString(message, "replyId") ??
|
| 708 |
+
readString(replyRecord, "guid") ??
|
| 709 |
+
readString(replyRecord, "id") ??
|
| 710 |
+
readString(replyRecord, "messageId");
|
| 711 |
+
|
| 712 |
+
const associatedType =
|
| 713 |
+
readNumberLike(message, "associatedMessageType") ??
|
| 714 |
+
readNumberLike(message, "associated_message_type");
|
| 715 |
+
const associatedGuid =
|
| 716 |
+
readString(message, "associatedMessageGuid") ??
|
| 717 |
+
readString(message, "associated_message_guid") ??
|
| 718 |
+
readString(message, "associatedMessageId");
|
| 719 |
+
const isReactionAssociation =
|
| 720 |
+
typeof associatedType === "number" && REACTION_TYPE_MAP.has(associatedType);
|
| 721 |
+
|
| 722 |
+
const replyToId = directReplyId ?? (!isReactionAssociation ? associatedGuid : undefined);
|
| 723 |
+
const threadOriginatorGuid = readString(message, "threadOriginatorGuid");
|
| 724 |
+
const messageGuid = readString(message, "guid");
|
| 725 |
+
const fallbackReplyId =
|
| 726 |
+
!replyToId && threadOriginatorGuid && threadOriginatorGuid !== messageGuid
|
| 727 |
+
? threadOriginatorGuid
|
| 728 |
+
: undefined;
|
| 729 |
+
|
| 730 |
+
return {
|
| 731 |
+
replyToId: (replyToId ?? fallbackReplyId)?.trim() || undefined,
|
| 732 |
+
replyToBody: replyToBody?.trim() || undefined,
|
| 733 |
+
replyToSender: normalizedSender || undefined,
|
| 734 |
+
};
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
function readFirstChatRecord(message: Record<string, unknown>): Record<string, unknown> | null {
|
| 738 |
+
const chats = message["chats"];
|
| 739 |
+
if (!Array.isArray(chats) || chats.length === 0) {
|
| 740 |
+
return null;
|
| 741 |
+
}
|
| 742 |
+
const first = chats[0];
|
| 743 |
+
return asRecord(first);
|
| 744 |
+
}
|
| 745 |
+
|
| 746 |
+
function normalizeParticipantEntry(entry: unknown): BlueBubblesParticipant | null {
|
| 747 |
+
if (typeof entry === "string" || typeof entry === "number") {
|
| 748 |
+
const raw = String(entry).trim();
|
| 749 |
+
if (!raw) {
|
| 750 |
+
return null;
|
| 751 |
+
}
|
| 752 |
+
const normalized = normalizeBlueBubblesHandle(raw) || raw;
|
| 753 |
+
return normalized ? { id: normalized } : null;
|
| 754 |
+
}
|
| 755 |
+
const record = asRecord(entry);
|
| 756 |
+
if (!record) {
|
| 757 |
+
return null;
|
| 758 |
+
}
|
| 759 |
+
const nestedHandle =
|
| 760 |
+
asRecord(record["handle"]) ?? asRecord(record["sender"]) ?? asRecord(record["contact"]) ?? null;
|
| 761 |
+
const idRaw =
|
| 762 |
+
readString(record, "address") ??
|
| 763 |
+
readString(record, "handle") ??
|
| 764 |
+
readString(record, "id") ??
|
| 765 |
+
readString(record, "phoneNumber") ??
|
| 766 |
+
readString(record, "phone_number") ??
|
| 767 |
+
readString(record, "email") ??
|
| 768 |
+
readString(nestedHandle, "address") ??
|
| 769 |
+
readString(nestedHandle, "handle") ??
|
| 770 |
+
readString(nestedHandle, "id");
|
| 771 |
+
const nameRaw =
|
| 772 |
+
readString(record, "displayName") ??
|
| 773 |
+
readString(record, "name") ??
|
| 774 |
+
readString(record, "title") ??
|
| 775 |
+
readString(nestedHandle, "displayName") ??
|
| 776 |
+
readString(nestedHandle, "name");
|
| 777 |
+
const normalizedId = idRaw ? normalizeBlueBubblesHandle(idRaw) || idRaw.trim() : "";
|
| 778 |
+
if (!normalizedId) {
|
| 779 |
+
return null;
|
| 780 |
+
}
|
| 781 |
+
const name = nameRaw?.trim() || undefined;
|
| 782 |
+
return { id: normalizedId, name };
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
function normalizeParticipantList(raw: unknown): BlueBubblesParticipant[] {
|
| 786 |
+
if (!Array.isArray(raw) || raw.length === 0) {
|
| 787 |
+
return [];
|
| 788 |
+
}
|
| 789 |
+
const seen = new Set<string>();
|
| 790 |
+
const output: BlueBubblesParticipant[] = [];
|
| 791 |
+
for (const entry of raw) {
|
| 792 |
+
const normalized = normalizeParticipantEntry(entry);
|
| 793 |
+
if (!normalized?.id) {
|
| 794 |
+
continue;
|
| 795 |
+
}
|
| 796 |
+
const key = normalized.id.toLowerCase();
|
| 797 |
+
if (seen.has(key)) {
|
| 798 |
+
continue;
|
| 799 |
+
}
|
| 800 |
+
seen.add(key);
|
| 801 |
+
output.push(normalized);
|
| 802 |
+
}
|
| 803 |
+
return output;
|
| 804 |
+
}
|
| 805 |
+
|
| 806 |
+
function formatGroupMembers(params: {
|
| 807 |
+
participants?: BlueBubblesParticipant[];
|
| 808 |
+
fallback?: BlueBubblesParticipant;
|
| 809 |
+
}): string | undefined {
|
| 810 |
+
const seen = new Set<string>();
|
| 811 |
+
const ordered: BlueBubblesParticipant[] = [];
|
| 812 |
+
for (const entry of params.participants ?? []) {
|
| 813 |
+
if (!entry?.id) {
|
| 814 |
+
continue;
|
| 815 |
+
}
|
| 816 |
+
const key = entry.id.toLowerCase();
|
| 817 |
+
if (seen.has(key)) {
|
| 818 |
+
continue;
|
| 819 |
+
}
|
| 820 |
+
seen.add(key);
|
| 821 |
+
ordered.push(entry);
|
| 822 |
+
}
|
| 823 |
+
if (ordered.length === 0 && params.fallback?.id) {
|
| 824 |
+
ordered.push(params.fallback);
|
| 825 |
+
}
|
| 826 |
+
if (ordered.length === 0) {
|
| 827 |
+
return undefined;
|
| 828 |
+
}
|
| 829 |
+
return ordered.map((entry) => (entry.name ? `${entry.name} (${entry.id})` : entry.id)).join(", ");
|
| 830 |
+
}
|
| 831 |
+
|
| 832 |
+
function resolveGroupFlagFromChatGuid(chatGuid?: string | null): boolean | undefined {
|
| 833 |
+
const guid = chatGuid?.trim();
|
| 834 |
+
if (!guid) {
|
| 835 |
+
return undefined;
|
| 836 |
+
}
|
| 837 |
+
const parts = guid.split(";");
|
| 838 |
+
if (parts.length >= 3) {
|
| 839 |
+
if (parts[1] === "+") {
|
| 840 |
+
return true;
|
| 841 |
+
}
|
| 842 |
+
if (parts[1] === "-") {
|
| 843 |
+
return false;
|
| 844 |
+
}
|
| 845 |
+
}
|
| 846 |
+
if (guid.includes(";+;")) {
|
| 847 |
+
return true;
|
| 848 |
+
}
|
| 849 |
+
if (guid.includes(";-;")) {
|
| 850 |
+
return false;
|
| 851 |
+
}
|
| 852 |
+
return undefined;
|
| 853 |
+
}
|
| 854 |
+
|
| 855 |
+
function extractChatIdentifierFromChatGuid(chatGuid?: string | null): string | undefined {
|
| 856 |
+
const guid = chatGuid?.trim();
|
| 857 |
+
if (!guid) {
|
| 858 |
+
return undefined;
|
| 859 |
+
}
|
| 860 |
+
const parts = guid.split(";");
|
| 861 |
+
if (parts.length < 3) {
|
| 862 |
+
return undefined;
|
| 863 |
+
}
|
| 864 |
+
const identifier = parts[2]?.trim();
|
| 865 |
+
return identifier || undefined;
|
| 866 |
+
}
|
| 867 |
+
|
| 868 |
+
function formatGroupAllowlistEntry(params: {
|
| 869 |
+
chatGuid?: string;
|
| 870 |
+
chatId?: number;
|
| 871 |
+
chatIdentifier?: string;
|
| 872 |
+
}): string | null {
|
| 873 |
+
const guid = params.chatGuid?.trim();
|
| 874 |
+
if (guid) {
|
| 875 |
+
return `chat_guid:${guid}`;
|
| 876 |
+
}
|
| 877 |
+
const chatId = params.chatId;
|
| 878 |
+
if (typeof chatId === "number" && Number.isFinite(chatId)) {
|
| 879 |
+
return `chat_id:${chatId}`;
|
| 880 |
+
}
|
| 881 |
+
const identifier = params.chatIdentifier?.trim();
|
| 882 |
+
if (identifier) {
|
| 883 |
+
return `chat_identifier:${identifier}`;
|
| 884 |
+
}
|
| 885 |
+
return null;
|
| 886 |
+
}
|
| 887 |
+
|
| 888 |
+
type BlueBubblesParticipant = {
|
| 889 |
+
id: string;
|
| 890 |
+
name?: string;
|
| 891 |
+
};
|
| 892 |
+
|
| 893 |
+
type NormalizedWebhookMessage = {
|
| 894 |
+
text: string;
|
| 895 |
+
senderId: string;
|
| 896 |
+
senderName?: string;
|
| 897 |
+
messageId?: string;
|
| 898 |
+
timestamp?: number;
|
| 899 |
+
isGroup: boolean;
|
| 900 |
+
chatId?: number;
|
| 901 |
+
chatGuid?: string;
|
| 902 |
+
chatIdentifier?: string;
|
| 903 |
+
chatName?: string;
|
| 904 |
+
fromMe?: boolean;
|
| 905 |
+
attachments?: BlueBubblesAttachment[];
|
| 906 |
+
balloonBundleId?: string;
|
| 907 |
+
associatedMessageGuid?: string;
|
| 908 |
+
associatedMessageType?: number;
|
| 909 |
+
associatedMessageEmoji?: string;
|
| 910 |
+
isTapback?: boolean;
|
| 911 |
+
participants?: BlueBubblesParticipant[];
|
| 912 |
+
replyToId?: string;
|
| 913 |
+
replyToBody?: string;
|
| 914 |
+
replyToSender?: string;
|
| 915 |
+
};
|
| 916 |
+
|
| 917 |
+
type NormalizedWebhookReaction = {
|
| 918 |
+
action: "added" | "removed";
|
| 919 |
+
emoji: string;
|
| 920 |
+
senderId: string;
|
| 921 |
+
senderName?: string;
|
| 922 |
+
messageId: string;
|
| 923 |
+
timestamp?: number;
|
| 924 |
+
isGroup: boolean;
|
| 925 |
+
chatId?: number;
|
| 926 |
+
chatGuid?: string;
|
| 927 |
+
chatIdentifier?: string;
|
| 928 |
+
chatName?: string;
|
| 929 |
+
fromMe?: boolean;
|
| 930 |
+
};
|
| 931 |
+
|
| 932 |
+
const REACTION_TYPE_MAP = new Map<number, { emoji: string; action: "added" | "removed" }>([
|
| 933 |
+
[2000, { emoji: "❤️", action: "added" }],
|
| 934 |
+
[2001, { emoji: "👍", action: "added" }],
|
| 935 |
+
[2002, { emoji: "👎", action: "added" }],
|
| 936 |
+
[2003, { emoji: "😂", action: "added" }],
|
| 937 |
+
[2004, { emoji: "‼️", action: "added" }],
|
| 938 |
+
[2005, { emoji: "❓", action: "added" }],
|
| 939 |
+
[3000, { emoji: "❤️", action: "removed" }],
|
| 940 |
+
[3001, { emoji: "👍", action: "removed" }],
|
| 941 |
+
[3002, { emoji: "��", action: "removed" }],
|
| 942 |
+
[3003, { emoji: "😂", action: "removed" }],
|
| 943 |
+
[3004, { emoji: "‼️", action: "removed" }],
|
| 944 |
+
[3005, { emoji: "❓", action: "removed" }],
|
| 945 |
+
]);
|
| 946 |
+
|
| 947 |
+
// Maps tapback text patterns (e.g., "Loved", "Liked") to emoji + action
|
| 948 |
+
const TAPBACK_TEXT_MAP = new Map<string, { emoji: string; action: "added" | "removed" }>([
|
| 949 |
+
["loved", { emoji: "❤️", action: "added" }],
|
| 950 |
+
["liked", { emoji: "👍", action: "added" }],
|
| 951 |
+
["disliked", { emoji: "👎", action: "added" }],
|
| 952 |
+
["laughed at", { emoji: "😂", action: "added" }],
|
| 953 |
+
["emphasized", { emoji: "‼️", action: "added" }],
|
| 954 |
+
["questioned", { emoji: "❓", action: "added" }],
|
| 955 |
+
// Removal patterns (e.g., "Removed a heart from")
|
| 956 |
+
["removed a heart from", { emoji: "❤️", action: "removed" }],
|
| 957 |
+
["removed a like from", { emoji: "👍", action: "removed" }],
|
| 958 |
+
["removed a dislike from", { emoji: "👎", action: "removed" }],
|
| 959 |
+
["removed a laugh from", { emoji: "😂", action: "removed" }],
|
| 960 |
+
["removed an emphasis from", { emoji: "‼️", action: "removed" }],
|
| 961 |
+
["removed a question from", { emoji: "❓", action: "removed" }],
|
| 962 |
+
]);
|
| 963 |
+
|
| 964 |
+
const TAPBACK_EMOJI_REGEX =
|
| 965 |
+
/(?:\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;
|
| 966 |
+
|
| 967 |
+
function extractFirstEmoji(text: string): string | null {
|
| 968 |
+
const match = text.match(TAPBACK_EMOJI_REGEX);
|
| 969 |
+
return match ? match[0] : null;
|
| 970 |
+
}
|
| 971 |
+
|
| 972 |
+
function extractQuotedTapbackText(text: string): string | null {
|
| 973 |
+
const match = text.match(/[“"]([^”"]+)[”"]/s);
|
| 974 |
+
return match ? match[1] : null;
|
| 975 |
+
}
|
| 976 |
+
|
| 977 |
+
function isTapbackAssociatedType(type: number | undefined): boolean {
|
| 978 |
+
return typeof type === "number" && Number.isFinite(type) && type >= 2000 && type < 4000;
|
| 979 |
+
}
|
| 980 |
+
|
| 981 |
+
function resolveTapbackActionHint(type: number | undefined): "added" | "removed" | undefined {
|
| 982 |
+
if (typeof type !== "number" || !Number.isFinite(type)) {
|
| 983 |
+
return undefined;
|
| 984 |
+
}
|
| 985 |
+
if (type >= 3000 && type < 4000) {
|
| 986 |
+
return "removed";
|
| 987 |
+
}
|
| 988 |
+
if (type >= 2000 && type < 3000) {
|
| 989 |
+
return "added";
|
| 990 |
+
}
|
| 991 |
+
return undefined;
|
| 992 |
+
}
|
| 993 |
+
|
| 994 |
+
function resolveTapbackContext(message: NormalizedWebhookMessage): {
|
| 995 |
+
emojiHint?: string;
|
| 996 |
+
actionHint?: "added" | "removed";
|
| 997 |
+
replyToId?: string;
|
| 998 |
+
} | null {
|
| 999 |
+
const associatedType = message.associatedMessageType;
|
| 1000 |
+
const hasTapbackType = isTapbackAssociatedType(associatedType);
|
| 1001 |
+
const hasTapbackMarker = Boolean(message.associatedMessageEmoji) || Boolean(message.isTapback);
|
| 1002 |
+
if (!hasTapbackType && !hasTapbackMarker) {
|
| 1003 |
+
return null;
|
| 1004 |
+
}
|
| 1005 |
+
const replyToId = message.associatedMessageGuid?.trim() || message.replyToId?.trim() || undefined;
|
| 1006 |
+
const actionHint = resolveTapbackActionHint(associatedType);
|
| 1007 |
+
const emojiHint =
|
| 1008 |
+
message.associatedMessageEmoji?.trim() || REACTION_TYPE_MAP.get(associatedType ?? -1)?.emoji;
|
| 1009 |
+
return { emojiHint, actionHint, replyToId };
|
| 1010 |
+
}
|
| 1011 |
+
|
| 1012 |
+
// Detects tapback text patterns like 'Loved "message"' and converts to structured format
|
| 1013 |
+
function parseTapbackText(params: {
|
| 1014 |
+
text: string;
|
| 1015 |
+
emojiHint?: string;
|
| 1016 |
+
actionHint?: "added" | "removed";
|
| 1017 |
+
requireQuoted?: boolean;
|
| 1018 |
+
}): {
|
| 1019 |
+
emoji: string;
|
| 1020 |
+
action: "added" | "removed";
|
| 1021 |
+
quotedText: string;
|
| 1022 |
+
} | null {
|
| 1023 |
+
const trimmed = params.text.trim();
|
| 1024 |
+
const lower = trimmed.toLowerCase();
|
| 1025 |
+
if (!trimmed) {
|
| 1026 |
+
return null;
|
| 1027 |
+
}
|
| 1028 |
+
|
| 1029 |
+
for (const [pattern, { emoji, action }] of TAPBACK_TEXT_MAP) {
|
| 1030 |
+
if (lower.startsWith(pattern)) {
|
| 1031 |
+
// Extract quoted text if present (e.g., 'Loved "hello"' -> "hello")
|
| 1032 |
+
const afterPattern = trimmed.slice(pattern.length).trim();
|
| 1033 |
+
if (params.requireQuoted) {
|
| 1034 |
+
const strictMatch = afterPattern.match(/^[“"](.+)[”"]$/s);
|
| 1035 |
+
if (!strictMatch) {
|
| 1036 |
+
return null;
|
| 1037 |
+
}
|
| 1038 |
+
return { emoji, action, quotedText: strictMatch[1] };
|
| 1039 |
+
}
|
| 1040 |
+
const quotedText =
|
| 1041 |
+
extractQuotedTapbackText(afterPattern) ?? extractQuotedTapbackText(trimmed) ?? afterPattern;
|
| 1042 |
+
return { emoji, action, quotedText };
|
| 1043 |
+
}
|
| 1044 |
+
}
|
| 1045 |
+
|
| 1046 |
+
if (lower.startsWith("reacted")) {
|
| 1047 |
+
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
|
| 1048 |
+
if (!emoji) {
|
| 1049 |
+
return null;
|
| 1050 |
+
}
|
| 1051 |
+
const quotedText = extractQuotedTapbackText(trimmed);
|
| 1052 |
+
if (params.requireQuoted && !quotedText) {
|
| 1053 |
+
return null;
|
| 1054 |
+
}
|
| 1055 |
+
const fallback = trimmed.slice("reacted".length).trim();
|
| 1056 |
+
return { emoji, action: params.actionHint ?? "added", quotedText: quotedText ?? fallback };
|
| 1057 |
+
}
|
| 1058 |
+
|
| 1059 |
+
if (lower.startsWith("removed")) {
|
| 1060 |
+
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
|
| 1061 |
+
if (!emoji) {
|
| 1062 |
+
return null;
|
| 1063 |
+
}
|
| 1064 |
+
const quotedText = extractQuotedTapbackText(trimmed);
|
| 1065 |
+
if (params.requireQuoted && !quotedText) {
|
| 1066 |
+
return null;
|
| 1067 |
+
}
|
| 1068 |
+
const fallback = trimmed.slice("removed".length).trim();
|
| 1069 |
+
return { emoji, action: params.actionHint ?? "removed", quotedText: quotedText ?? fallback };
|
| 1070 |
+
}
|
| 1071 |
+
return null;
|
| 1072 |
+
}
|
| 1073 |
+
|
| 1074 |
+
function maskSecret(value: string): string {
|
| 1075 |
+
if (value.length <= 6) {
|
| 1076 |
+
return "***";
|
| 1077 |
+
}
|
| 1078 |
+
return `${value.slice(0, 2)}***${value.slice(-2)}`;
|
| 1079 |
+
}
|
| 1080 |
+
|
| 1081 |
+
function resolveBlueBubblesAckReaction(params: {
|
| 1082 |
+
cfg: OpenClawConfig;
|
| 1083 |
+
agentId: string;
|
| 1084 |
+
core: BlueBubblesCoreRuntime;
|
| 1085 |
+
runtime: BlueBubblesRuntimeEnv;
|
| 1086 |
+
}): string | null {
|
| 1087 |
+
const raw = resolveAckReaction(params.cfg, params.agentId).trim();
|
| 1088 |
+
if (!raw) {
|
| 1089 |
+
return null;
|
| 1090 |
+
}
|
| 1091 |
+
try {
|
| 1092 |
+
normalizeBlueBubblesReactionInput(raw);
|
| 1093 |
+
return raw;
|
| 1094 |
+
} catch {
|
| 1095 |
+
const key = raw.toLowerCase();
|
| 1096 |
+
if (!invalidAckReactions.has(key)) {
|
| 1097 |
+
invalidAckReactions.add(key);
|
| 1098 |
+
logVerbose(
|
| 1099 |
+
params.core,
|
| 1100 |
+
params.runtime,
|
| 1101 |
+
`ack reaction skipped (unsupported for BlueBubbles): ${raw}`,
|
| 1102 |
+
);
|
| 1103 |
+
}
|
| 1104 |
+
return null;
|
| 1105 |
+
}
|
| 1106 |
+
}
|
| 1107 |
+
|
| 1108 |
+
function extractMessagePayload(payload: Record<string, unknown>): Record<string, unknown> | null {
|
| 1109 |
+
const dataRaw = payload.data ?? payload.payload ?? payload.event;
|
| 1110 |
+
const data =
|
| 1111 |
+
asRecord(dataRaw) ??
|
| 1112 |
+
(typeof dataRaw === "string" ? (asRecord(JSON.parse(dataRaw)) ?? null) : null);
|
| 1113 |
+
const messageRaw = payload.message ?? data?.message ?? data;
|
| 1114 |
+
const message =
|
| 1115 |
+
asRecord(messageRaw) ??
|
| 1116 |
+
(typeof messageRaw === "string" ? (asRecord(JSON.parse(messageRaw)) ?? null) : null);
|
| 1117 |
+
if (!message) {
|
| 1118 |
+
return null;
|
| 1119 |
+
}
|
| 1120 |
+
return message;
|
| 1121 |
+
}
|
| 1122 |
+
|
| 1123 |
+
function normalizeWebhookMessage(
|
| 1124 |
+
payload: Record<string, unknown>,
|
| 1125 |
+
): NormalizedWebhookMessage | null {
|
| 1126 |
+
const message = extractMessagePayload(payload);
|
| 1127 |
+
if (!message) {
|
| 1128 |
+
return null;
|
| 1129 |
+
}
|
| 1130 |
+
|
| 1131 |
+
const text =
|
| 1132 |
+
readString(message, "text") ??
|
| 1133 |
+
readString(message, "body") ??
|
| 1134 |
+
readString(message, "subject") ??
|
| 1135 |
+
"";
|
| 1136 |
+
|
| 1137 |
+
const handleValue = message.handle ?? message.sender;
|
| 1138 |
+
const handle =
|
| 1139 |
+
asRecord(handleValue) ?? (typeof handleValue === "string" ? { address: handleValue } : null);
|
| 1140 |
+
const senderId =
|
| 1141 |
+
readString(handle, "address") ??
|
| 1142 |
+
readString(handle, "handle") ??
|
| 1143 |
+
readString(handle, "id") ??
|
| 1144 |
+
readString(message, "senderId") ??
|
| 1145 |
+
readString(message, "sender") ??
|
| 1146 |
+
readString(message, "from") ??
|
| 1147 |
+
"";
|
| 1148 |
+
|
| 1149 |
+
const senderName =
|
| 1150 |
+
readString(handle, "displayName") ??
|
| 1151 |
+
readString(handle, "name") ??
|
| 1152 |
+
readString(message, "senderName") ??
|
| 1153 |
+
undefined;
|
| 1154 |
+
|
| 1155 |
+
const chat = asRecord(message.chat) ?? asRecord(message.conversation) ?? null;
|
| 1156 |
+
const chatFromList = readFirstChatRecord(message);
|
| 1157 |
+
const chatGuid =
|
| 1158 |
+
readString(message, "chatGuid") ??
|
| 1159 |
+
readString(message, "chat_guid") ??
|
| 1160 |
+
readString(chat, "chatGuid") ??
|
| 1161 |
+
readString(chat, "chat_guid") ??
|
| 1162 |
+
readString(chat, "guid") ??
|
| 1163 |
+
readString(chatFromList, "chatGuid") ??
|
| 1164 |
+
readString(chatFromList, "chat_guid") ??
|
| 1165 |
+
readString(chatFromList, "guid");
|
| 1166 |
+
const chatIdentifier =
|
| 1167 |
+
readString(message, "chatIdentifier") ??
|
| 1168 |
+
readString(message, "chat_identifier") ??
|
| 1169 |
+
readString(chat, "chatIdentifier") ??
|
| 1170 |
+
readString(chat, "chat_identifier") ??
|
| 1171 |
+
readString(chat, "identifier") ??
|
| 1172 |
+
readString(chatFromList, "chatIdentifier") ??
|
| 1173 |
+
readString(chatFromList, "chat_identifier") ??
|
| 1174 |
+
readString(chatFromList, "identifier") ??
|
| 1175 |
+
extractChatIdentifierFromChatGuid(chatGuid);
|
| 1176 |
+
const chatId =
|
| 1177 |
+
readNumberLike(message, "chatId") ??
|
| 1178 |
+
readNumberLike(message, "chat_id") ??
|
| 1179 |
+
readNumberLike(chat, "chatId") ??
|
| 1180 |
+
readNumberLike(chat, "chat_id") ??
|
| 1181 |
+
readNumberLike(chat, "id") ??
|
| 1182 |
+
readNumberLike(chatFromList, "chatId") ??
|
| 1183 |
+
readNumberLike(chatFromList, "chat_id") ??
|
| 1184 |
+
readNumberLike(chatFromList, "id");
|
| 1185 |
+
const chatName =
|
| 1186 |
+
readString(message, "chatName") ??
|
| 1187 |
+
readString(chat, "displayName") ??
|
| 1188 |
+
readString(chat, "name") ??
|
| 1189 |
+
readString(chatFromList, "displayName") ??
|
| 1190 |
+
readString(chatFromList, "name") ??
|
| 1191 |
+
undefined;
|
| 1192 |
+
|
| 1193 |
+
const chatParticipants = chat ? chat["participants"] : undefined;
|
| 1194 |
+
const messageParticipants = message["participants"];
|
| 1195 |
+
const chatsParticipants = chatFromList ? chatFromList["participants"] : undefined;
|
| 1196 |
+
const participants = Array.isArray(chatParticipants)
|
| 1197 |
+
? chatParticipants
|
| 1198 |
+
: Array.isArray(messageParticipants)
|
| 1199 |
+
? messageParticipants
|
| 1200 |
+
: Array.isArray(chatsParticipants)
|
| 1201 |
+
? chatsParticipants
|
| 1202 |
+
: [];
|
| 1203 |
+
const normalizedParticipants = normalizeParticipantList(participants);
|
| 1204 |
+
const participantsCount = participants.length;
|
| 1205 |
+
const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid);
|
| 1206 |
+
const explicitIsGroup =
|
| 1207 |
+
readBoolean(message, "isGroup") ??
|
| 1208 |
+
readBoolean(message, "is_group") ??
|
| 1209 |
+
readBoolean(chat, "isGroup") ??
|
| 1210 |
+
readBoolean(message, "group");
|
| 1211 |
+
const isGroup =
|
| 1212 |
+
typeof groupFromChatGuid === "boolean"
|
| 1213 |
+
? groupFromChatGuid
|
| 1214 |
+
: (explicitIsGroup ?? participantsCount > 2);
|
| 1215 |
+
|
| 1216 |
+
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
|
| 1217 |
+
const messageId =
|
| 1218 |
+
readString(message, "guid") ??
|
| 1219 |
+
readString(message, "id") ??
|
| 1220 |
+
readString(message, "messageId") ??
|
| 1221 |
+
undefined;
|
| 1222 |
+
const balloonBundleId = readString(message, "balloonBundleId");
|
| 1223 |
+
const associatedMessageGuid =
|
| 1224 |
+
readString(message, "associatedMessageGuid") ??
|
| 1225 |
+
readString(message, "associated_message_guid") ??
|
| 1226 |
+
readString(message, "associatedMessageId") ??
|
| 1227 |
+
undefined;
|
| 1228 |
+
const associatedMessageType =
|
| 1229 |
+
readNumberLike(message, "associatedMessageType") ??
|
| 1230 |
+
readNumberLike(message, "associated_message_type");
|
| 1231 |
+
const associatedMessageEmoji =
|
| 1232 |
+
readString(message, "associatedMessageEmoji") ??
|
| 1233 |
+
readString(message, "associated_message_emoji") ??
|
| 1234 |
+
readString(message, "reactionEmoji") ??
|
| 1235 |
+
readString(message, "reaction_emoji") ??
|
| 1236 |
+
undefined;
|
| 1237 |
+
const isTapback =
|
| 1238 |
+
readBoolean(message, "isTapback") ??
|
| 1239 |
+
readBoolean(message, "is_tapback") ??
|
| 1240 |
+
readBoolean(message, "tapback") ??
|
| 1241 |
+
undefined;
|
| 1242 |
+
|
| 1243 |
+
const timestampRaw =
|
| 1244 |
+
readNumber(message, "date") ??
|
| 1245 |
+
readNumber(message, "dateCreated") ??
|
| 1246 |
+
readNumber(message, "timestamp");
|
| 1247 |
+
const timestamp =
|
| 1248 |
+
typeof timestampRaw === "number"
|
| 1249 |
+
? timestampRaw > 1_000_000_000_000
|
| 1250 |
+
? timestampRaw
|
| 1251 |
+
: timestampRaw * 1000
|
| 1252 |
+
: undefined;
|
| 1253 |
+
|
| 1254 |
+
const normalizedSender = normalizeBlueBubblesHandle(senderId);
|
| 1255 |
+
if (!normalizedSender) {
|
| 1256 |
+
return null;
|
| 1257 |
+
}
|
| 1258 |
+
const replyMetadata = extractReplyMetadata(message);
|
| 1259 |
+
|
| 1260 |
+
return {
|
| 1261 |
+
text,
|
| 1262 |
+
senderId: normalizedSender,
|
| 1263 |
+
senderName,
|
| 1264 |
+
messageId,
|
| 1265 |
+
timestamp,
|
| 1266 |
+
isGroup,
|
| 1267 |
+
chatId,
|
| 1268 |
+
chatGuid,
|
| 1269 |
+
chatIdentifier,
|
| 1270 |
+
chatName,
|
| 1271 |
+
fromMe,
|
| 1272 |
+
attachments: extractAttachments(message),
|
| 1273 |
+
balloonBundleId,
|
| 1274 |
+
associatedMessageGuid,
|
| 1275 |
+
associatedMessageType,
|
| 1276 |
+
associatedMessageEmoji,
|
| 1277 |
+
isTapback,
|
| 1278 |
+
participants: normalizedParticipants,
|
| 1279 |
+
replyToId: replyMetadata.replyToId,
|
| 1280 |
+
replyToBody: replyMetadata.replyToBody,
|
| 1281 |
+
replyToSender: replyMetadata.replyToSender,
|
| 1282 |
+
};
|
| 1283 |
+
}
|
| 1284 |
+
|
| 1285 |
+
function normalizeWebhookReaction(
|
| 1286 |
+
payload: Record<string, unknown>,
|
| 1287 |
+
): NormalizedWebhookReaction | null {
|
| 1288 |
+
const message = extractMessagePayload(payload);
|
| 1289 |
+
if (!message) {
|
| 1290 |
+
return null;
|
| 1291 |
+
}
|
| 1292 |
+
|
| 1293 |
+
const associatedGuid =
|
| 1294 |
+
readString(message, "associatedMessageGuid") ??
|
| 1295 |
+
readString(message, "associated_message_guid") ??
|
| 1296 |
+
readString(message, "associatedMessageId");
|
| 1297 |
+
const associatedType =
|
| 1298 |
+
readNumberLike(message, "associatedMessageType") ??
|
| 1299 |
+
readNumberLike(message, "associated_message_type");
|
| 1300 |
+
if (!associatedGuid || associatedType === undefined) {
|
| 1301 |
+
return null;
|
| 1302 |
+
}
|
| 1303 |
+
|
| 1304 |
+
const mapping = REACTION_TYPE_MAP.get(associatedType);
|
| 1305 |
+
const associatedEmoji =
|
| 1306 |
+
readString(message, "associatedMessageEmoji") ??
|
| 1307 |
+
readString(message, "associated_message_emoji") ??
|
| 1308 |
+
readString(message, "reactionEmoji") ??
|
| 1309 |
+
readString(message, "reaction_emoji");
|
| 1310 |
+
const emoji = (associatedEmoji?.trim() || mapping?.emoji) ?? `reaction:${associatedType}`;
|
| 1311 |
+
const action = mapping?.action ?? resolveTapbackActionHint(associatedType) ?? "added";
|
| 1312 |
+
|
| 1313 |
+
const handleValue = message.handle ?? message.sender;
|
| 1314 |
+
const handle =
|
| 1315 |
+
asRecord(handleValue) ?? (typeof handleValue === "string" ? { address: handleValue } : null);
|
| 1316 |
+
const senderId =
|
| 1317 |
+
readString(handle, "address") ??
|
| 1318 |
+
readString(handle, "handle") ??
|
| 1319 |
+
readString(handle, "id") ??
|
| 1320 |
+
readString(message, "senderId") ??
|
| 1321 |
+
readString(message, "sender") ??
|
| 1322 |
+
readString(message, "from") ??
|
| 1323 |
+
"";
|
| 1324 |
+
const senderName =
|
| 1325 |
+
readString(handle, "displayName") ??
|
| 1326 |
+
readString(handle, "name") ??
|
| 1327 |
+
readString(message, "senderName") ??
|
| 1328 |
+
undefined;
|
| 1329 |
+
|
| 1330 |
+
const chat = asRecord(message.chat) ?? asRecord(message.conversation) ?? null;
|
| 1331 |
+
const chatFromList = readFirstChatRecord(message);
|
| 1332 |
+
const chatGuid =
|
| 1333 |
+
readString(message, "chatGuid") ??
|
| 1334 |
+
readString(message, "chat_guid") ??
|
| 1335 |
+
readString(chat, "chatGuid") ??
|
| 1336 |
+
readString(chat, "chat_guid") ??
|
| 1337 |
+
readString(chat, "guid") ??
|
| 1338 |
+
readString(chatFromList, "chatGuid") ??
|
| 1339 |
+
readString(chatFromList, "chat_guid") ??
|
| 1340 |
+
readString(chatFromList, "guid");
|
| 1341 |
+
const chatIdentifier =
|
| 1342 |
+
readString(message, "chatIdentifier") ??
|
| 1343 |
+
readString(message, "chat_identifier") ??
|
| 1344 |
+
readString(chat, "chatIdentifier") ??
|
| 1345 |
+
readString(chat, "chat_identifier") ??
|
| 1346 |
+
readString(chat, "identifier") ??
|
| 1347 |
+
readString(chatFromList, "chatIdentifier") ??
|
| 1348 |
+
readString(chatFromList, "chat_identifier") ??
|
| 1349 |
+
readString(chatFromList, "identifier") ??
|
| 1350 |
+
extractChatIdentifierFromChatGuid(chatGuid);
|
| 1351 |
+
const chatId =
|
| 1352 |
+
readNumberLike(message, "chatId") ??
|
| 1353 |
+
readNumberLike(message, "chat_id") ??
|
| 1354 |
+
readNumberLike(chat, "chatId") ??
|
| 1355 |
+
readNumberLike(chat, "chat_id") ??
|
| 1356 |
+
readNumberLike(chat, "id") ??
|
| 1357 |
+
readNumberLike(chatFromList, "chatId") ??
|
| 1358 |
+
readNumberLike(chatFromList, "chat_id") ??
|
| 1359 |
+
readNumberLike(chatFromList, "id");
|
| 1360 |
+
const chatName =
|
| 1361 |
+
readString(message, "chatName") ??
|
| 1362 |
+
readString(chat, "displayName") ??
|
| 1363 |
+
readString(chat, "name") ??
|
| 1364 |
+
readString(chatFromList, "displayName") ??
|
| 1365 |
+
readString(chatFromList, "name") ??
|
| 1366 |
+
undefined;
|
| 1367 |
+
|
| 1368 |
+
const chatParticipants = chat ? chat["participants"] : undefined;
|
| 1369 |
+
const messageParticipants = message["participants"];
|
| 1370 |
+
const chatsParticipants = chatFromList ? chatFromList["participants"] : undefined;
|
| 1371 |
+
const participants = Array.isArray(chatParticipants)
|
| 1372 |
+
? chatParticipants
|
| 1373 |
+
: Array.isArray(messageParticipants)
|
| 1374 |
+
? messageParticipants
|
| 1375 |
+
: Array.isArray(chatsParticipants)
|
| 1376 |
+
? chatsParticipants
|
| 1377 |
+
: [];
|
| 1378 |
+
const participantsCount = participants.length;
|
| 1379 |
+
const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid);
|
| 1380 |
+
const explicitIsGroup =
|
| 1381 |
+
readBoolean(message, "isGroup") ??
|
| 1382 |
+
readBoolean(message, "is_group") ??
|
| 1383 |
+
readBoolean(chat, "isGroup") ??
|
| 1384 |
+
readBoolean(message, "group");
|
| 1385 |
+
const isGroup =
|
| 1386 |
+
typeof groupFromChatGuid === "boolean"
|
| 1387 |
+
? groupFromChatGuid
|
| 1388 |
+
: (explicitIsGroup ?? participantsCount > 2);
|
| 1389 |
+
|
| 1390 |
+
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
|
| 1391 |
+
const timestampRaw =
|
| 1392 |
+
readNumberLike(message, "date") ??
|
| 1393 |
+
readNumberLike(message, "dateCreated") ??
|
| 1394 |
+
readNumberLike(message, "timestamp");
|
| 1395 |
+
const timestamp =
|
| 1396 |
+
typeof timestampRaw === "number"
|
| 1397 |
+
? timestampRaw > 1_000_000_000_000
|
| 1398 |
+
? timestampRaw
|
| 1399 |
+
: timestampRaw * 1000
|
| 1400 |
+
: undefined;
|
| 1401 |
+
|
| 1402 |
+
const normalizedSender = normalizeBlueBubblesHandle(senderId);
|
| 1403 |
+
if (!normalizedSender) {
|
| 1404 |
+
return null;
|
| 1405 |
+
}
|
| 1406 |
+
|
| 1407 |
+
return {
|
| 1408 |
+
action,
|
| 1409 |
+
emoji,
|
| 1410 |
+
senderId: normalizedSender,
|
| 1411 |
+
senderName,
|
| 1412 |
+
messageId: associatedGuid,
|
| 1413 |
+
timestamp,
|
| 1414 |
+
isGroup,
|
| 1415 |
+
chatId,
|
| 1416 |
+
chatGuid,
|
| 1417 |
+
chatIdentifier,
|
| 1418 |
+
chatName,
|
| 1419 |
+
fromMe,
|
| 1420 |
+
};
|
| 1421 |
+
}
|
| 1422 |
+
|
| 1423 |
+
export async function handleBlueBubblesWebhookRequest(
|
| 1424 |
+
req: IncomingMessage,
|
| 1425 |
+
res: ServerResponse,
|
| 1426 |
+
): Promise<boolean> {
|
| 1427 |
+
const url = new URL(req.url ?? "/", "http://localhost");
|
| 1428 |
+
const path = normalizeWebhookPath(url.pathname);
|
| 1429 |
+
const targets = webhookTargets.get(path);
|
| 1430 |
+
if (!targets || targets.length === 0) {
|
| 1431 |
+
return false;
|
| 1432 |
+
}
|
| 1433 |
+
|
| 1434 |
+
if (req.method !== "POST") {
|
| 1435 |
+
res.statusCode = 405;
|
| 1436 |
+
res.setHeader("Allow", "POST");
|
| 1437 |
+
res.end("Method Not Allowed");
|
| 1438 |
+
return true;
|
| 1439 |
+
}
|
| 1440 |
+
|
| 1441 |
+
const body = await readJsonBody(req, 1024 * 1024);
|
| 1442 |
+
if (!body.ok) {
|
| 1443 |
+
res.statusCode = body.error === "payload too large" ? 413 : 400;
|
| 1444 |
+
res.end(body.error ?? "invalid payload");
|
| 1445 |
+
console.warn(`[bluebubbles] webhook rejected: ${body.error ?? "invalid payload"}`);
|
| 1446 |
+
return true;
|
| 1447 |
+
}
|
| 1448 |
+
|
| 1449 |
+
const payload = asRecord(body.value) ?? {};
|
| 1450 |
+
const firstTarget = targets[0];
|
| 1451 |
+
if (firstTarget) {
|
| 1452 |
+
logVerbose(
|
| 1453 |
+
firstTarget.core,
|
| 1454 |
+
firstTarget.runtime,
|
| 1455 |
+
`webhook received path=${path} keys=${Object.keys(payload).join(",") || "none"}`,
|
| 1456 |
+
);
|
| 1457 |
+
}
|
| 1458 |
+
const eventTypeRaw = payload.type;
|
| 1459 |
+
const eventType = typeof eventTypeRaw === "string" ? eventTypeRaw.trim() : "";
|
| 1460 |
+
const allowedEventTypes = new Set([
|
| 1461 |
+
"new-message",
|
| 1462 |
+
"updated-message",
|
| 1463 |
+
"message-reaction",
|
| 1464 |
+
"reaction",
|
| 1465 |
+
]);
|
| 1466 |
+
if (eventType && !allowedEventTypes.has(eventType)) {
|
| 1467 |
+
res.statusCode = 200;
|
| 1468 |
+
res.end("ok");
|
| 1469 |
+
if (firstTarget) {
|
| 1470 |
+
logVerbose(firstTarget.core, firstTarget.runtime, `webhook ignored type=${eventType}`);
|
| 1471 |
+
}
|
| 1472 |
+
return true;
|
| 1473 |
+
}
|
| 1474 |
+
const reaction = normalizeWebhookReaction(payload);
|
| 1475 |
+
if (
|
| 1476 |
+
(eventType === "updated-message" ||
|
| 1477 |
+
eventType === "message-reaction" ||
|
| 1478 |
+
eventType === "reaction") &&
|
| 1479 |
+
!reaction
|
| 1480 |
+
) {
|
| 1481 |
+
res.statusCode = 200;
|
| 1482 |
+
res.end("ok");
|
| 1483 |
+
if (firstTarget) {
|
| 1484 |
+
logVerbose(
|
| 1485 |
+
firstTarget.core,
|
| 1486 |
+
firstTarget.runtime,
|
| 1487 |
+
`webhook ignored ${eventType || "event"} without reaction`,
|
| 1488 |
+
);
|
| 1489 |
+
}
|
| 1490 |
+
return true;
|
| 1491 |
+
}
|
| 1492 |
+
const message = reaction ? null : normalizeWebhookMessage(payload);
|
| 1493 |
+
if (!message && !reaction) {
|
| 1494 |
+
res.statusCode = 400;
|
| 1495 |
+
res.end("invalid payload");
|
| 1496 |
+
console.warn("[bluebubbles] webhook rejected: unable to parse message payload");
|
| 1497 |
+
return true;
|
| 1498 |
+
}
|
| 1499 |
+
|
| 1500 |
+
const matching = targets.filter((target) => {
|
| 1501 |
+
const token = target.account.config.password?.trim();
|
| 1502 |
+
if (!token) {
|
| 1503 |
+
return true;
|
| 1504 |
+
}
|
| 1505 |
+
const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password");
|
| 1506 |
+
const headerToken =
|
| 1507 |
+
req.headers["x-guid"] ??
|
| 1508 |
+
req.headers["x-password"] ??
|
| 1509 |
+
req.headers["x-bluebubbles-guid"] ??
|
| 1510 |
+
req.headers["authorization"];
|
| 1511 |
+
const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
|
| 1512 |
+
if (guid && guid.trim() === token) {
|
| 1513 |
+
return true;
|
| 1514 |
+
}
|
| 1515 |
+
const remote = req.socket?.remoteAddress ?? "";
|
| 1516 |
+
if (remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1") {
|
| 1517 |
+
return true;
|
| 1518 |
+
}
|
| 1519 |
+
return false;
|
| 1520 |
+
});
|
| 1521 |
+
|
| 1522 |
+
if (matching.length === 0) {
|
| 1523 |
+
res.statusCode = 401;
|
| 1524 |
+
res.end("unauthorized");
|
| 1525 |
+
console.warn(
|
| 1526 |
+
`[bluebubbles] webhook rejected: unauthorized guid=${maskSecret(url.searchParams.get("guid") ?? url.searchParams.get("password") ?? "")}`,
|
| 1527 |
+
);
|
| 1528 |
+
return true;
|
| 1529 |
+
}
|
| 1530 |
+
|
| 1531 |
+
for (const target of matching) {
|
| 1532 |
+
target.statusSink?.({ lastInboundAt: Date.now() });
|
| 1533 |
+
if (reaction) {
|
| 1534 |
+
processReaction(reaction, target).catch((err) => {
|
| 1535 |
+
target.runtime.error?.(
|
| 1536 |
+
`[${target.account.accountId}] BlueBubbles reaction failed: ${String(err)}`,
|
| 1537 |
+
);
|
| 1538 |
+
});
|
| 1539 |
+
} else if (message) {
|
| 1540 |
+
// Route messages through debouncer to coalesce rapid-fire events
|
| 1541 |
+
// (e.g., text message + URL balloon arriving as separate webhooks)
|
| 1542 |
+
const debouncer = getOrCreateDebouncer(target);
|
| 1543 |
+
debouncer.enqueue({ message, target }).catch((err) => {
|
| 1544 |
+
target.runtime.error?.(
|
| 1545 |
+
`[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`,
|
| 1546 |
+
);
|
| 1547 |
+
});
|
| 1548 |
+
}
|
| 1549 |
+
}
|
| 1550 |
+
|
| 1551 |
+
res.statusCode = 200;
|
| 1552 |
+
res.end("ok");
|
| 1553 |
+
if (reaction) {
|
| 1554 |
+
if (firstTarget) {
|
| 1555 |
+
logVerbose(
|
| 1556 |
+
firstTarget.core,
|
| 1557 |
+
firstTarget.runtime,
|
| 1558 |
+
`webhook accepted reaction sender=${reaction.senderId} msg=${reaction.messageId} action=${reaction.action}`,
|
| 1559 |
+
);
|
| 1560 |
+
}
|
| 1561 |
+
} else if (message) {
|
| 1562 |
+
if (firstTarget) {
|
| 1563 |
+
logVerbose(
|
| 1564 |
+
firstTarget.core,
|
| 1565 |
+
firstTarget.runtime,
|
| 1566 |
+
`webhook accepted sender=${message.senderId} group=${message.isGroup} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
|
| 1567 |
+
);
|
| 1568 |
+
}
|
| 1569 |
+
}
|
| 1570 |
+
return true;
|
| 1571 |
+
}
|
| 1572 |
+
|
| 1573 |
+
async function processMessage(
|
| 1574 |
+
message: NormalizedWebhookMessage,
|
| 1575 |
+
target: WebhookTarget,
|
| 1576 |
+
): Promise<void> {
|
| 1577 |
+
const { account, config, runtime, core, statusSink } = target;
|
| 1578 |
+
|
| 1579 |
+
const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid);
|
| 1580 |
+
const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup;
|
| 1581 |
+
|
| 1582 |
+
const text = message.text.trim();
|
| 1583 |
+
const attachments = message.attachments ?? [];
|
| 1584 |
+
const placeholder = buildMessagePlaceholder(message);
|
| 1585 |
+
// Check if text is a tapback pattern (e.g., 'Loved "hello"') and transform to emoji format
|
| 1586 |
+
// For tapbacks, we'll append [[reply_to:N]] at the end; for regular messages, prepend it
|
| 1587 |
+
const tapbackContext = resolveTapbackContext(message);
|
| 1588 |
+
const tapbackParsed = parseTapbackText({
|
| 1589 |
+
text,
|
| 1590 |
+
emojiHint: tapbackContext?.emojiHint,
|
| 1591 |
+
actionHint: tapbackContext?.actionHint,
|
| 1592 |
+
requireQuoted: !tapbackContext,
|
| 1593 |
+
});
|
| 1594 |
+
const isTapbackMessage = Boolean(tapbackParsed);
|
| 1595 |
+
const rawBody = tapbackParsed
|
| 1596 |
+
? tapbackParsed.action === "removed"
|
| 1597 |
+
? `removed ${tapbackParsed.emoji} reaction`
|
| 1598 |
+
: `reacted with ${tapbackParsed.emoji}`
|
| 1599 |
+
: text || placeholder;
|
| 1600 |
+
|
| 1601 |
+
const cacheMessageId = message.messageId?.trim();
|
| 1602 |
+
let messageShortId: string | undefined;
|
| 1603 |
+
const cacheInboundMessage = () => {
|
| 1604 |
+
if (!cacheMessageId) {
|
| 1605 |
+
return;
|
| 1606 |
+
}
|
| 1607 |
+
const cacheEntry = rememberBlueBubblesReplyCache({
|
| 1608 |
+
accountId: account.accountId,
|
| 1609 |
+
messageId: cacheMessageId,
|
| 1610 |
+
chatGuid: message.chatGuid,
|
| 1611 |
+
chatIdentifier: message.chatIdentifier,
|
| 1612 |
+
chatId: message.chatId,
|
| 1613 |
+
senderLabel: message.fromMe ? "me" : message.senderId,
|
| 1614 |
+
body: rawBody,
|
| 1615 |
+
timestamp: message.timestamp ?? Date.now(),
|
| 1616 |
+
});
|
| 1617 |
+
messageShortId = cacheEntry.shortId;
|
| 1618 |
+
};
|
| 1619 |
+
|
| 1620 |
+
if (message.fromMe) {
|
| 1621 |
+
// Cache from-me messages so reply context can resolve sender/body.
|
| 1622 |
+
cacheInboundMessage();
|
| 1623 |
+
return;
|
| 1624 |
+
}
|
| 1625 |
+
|
| 1626 |
+
if (!rawBody) {
|
| 1627 |
+
logVerbose(core, runtime, `drop: empty text sender=${message.senderId}`);
|
| 1628 |
+
return;
|
| 1629 |
+
}
|
| 1630 |
+
logVerbose(
|
| 1631 |
+
core,
|
| 1632 |
+
runtime,
|
| 1633 |
+
`msg sender=${message.senderId} group=${isGroup} textLen=${text.length} attachments=${attachments.length} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
|
| 1634 |
+
);
|
| 1635 |
+
|
| 1636 |
+
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
| 1637 |
+
const groupPolicy = account.config.groupPolicy ?? "allowlist";
|
| 1638 |
+
const configAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
|
| 1639 |
+
const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => String(entry));
|
| 1640 |
+
const storeAllowFrom = await core.channel.pairing
|
| 1641 |
+
.readAllowFromStore("bluebubbles")
|
| 1642 |
+
.catch(() => []);
|
| 1643 |
+
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]
|
| 1644 |
+
.map((entry) => String(entry).trim())
|
| 1645 |
+
.filter(Boolean);
|
| 1646 |
+
const effectiveGroupAllowFrom = [
|
| 1647 |
+
...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
|
| 1648 |
+
...storeAllowFrom,
|
| 1649 |
+
]
|
| 1650 |
+
.map((entry) => String(entry).trim())
|
| 1651 |
+
.filter(Boolean);
|
| 1652 |
+
const groupAllowEntry = formatGroupAllowlistEntry({
|
| 1653 |
+
chatGuid: message.chatGuid,
|
| 1654 |
+
chatId: message.chatId ?? undefined,
|
| 1655 |
+
chatIdentifier: message.chatIdentifier ?? undefined,
|
| 1656 |
+
});
|
| 1657 |
+
const groupName = message.chatName?.trim() || undefined;
|
| 1658 |
+
|
| 1659 |
+
if (isGroup) {
|
| 1660 |
+
if (groupPolicy === "disabled") {
|
| 1661 |
+
logVerbose(core, runtime, "Blocked BlueBubbles group message (groupPolicy=disabled)");
|
| 1662 |
+
logGroupAllowlistHint({
|
| 1663 |
+
runtime,
|
| 1664 |
+
reason: "groupPolicy=disabled",
|
| 1665 |
+
entry: groupAllowEntry,
|
| 1666 |
+
chatName: groupName,
|
| 1667 |
+
accountId: account.accountId,
|
| 1668 |
+
});
|
| 1669 |
+
return;
|
| 1670 |
+
}
|
| 1671 |
+
if (groupPolicy === "allowlist") {
|
| 1672 |
+
if (effectiveGroupAllowFrom.length === 0) {
|
| 1673 |
+
logVerbose(core, runtime, "Blocked BlueBubbles group message (no allowlist)");
|
| 1674 |
+
logGroupAllowlistHint({
|
| 1675 |
+
runtime,
|
| 1676 |
+
reason: "groupPolicy=allowlist (empty allowlist)",
|
| 1677 |
+
entry: groupAllowEntry,
|
| 1678 |
+
chatName: groupName,
|
| 1679 |
+
accountId: account.accountId,
|
| 1680 |
+
});
|
| 1681 |
+
return;
|
| 1682 |
+
}
|
| 1683 |
+
const allowed = isAllowedBlueBubblesSender({
|
| 1684 |
+
allowFrom: effectiveGroupAllowFrom,
|
| 1685 |
+
sender: message.senderId,
|
| 1686 |
+
chatId: message.chatId ?? undefined,
|
| 1687 |
+
chatGuid: message.chatGuid ?? undefined,
|
| 1688 |
+
chatIdentifier: message.chatIdentifier ?? undefined,
|
| 1689 |
+
});
|
| 1690 |
+
if (!allowed) {
|
| 1691 |
+
logVerbose(
|
| 1692 |
+
core,
|
| 1693 |
+
runtime,
|
| 1694 |
+
`Blocked BlueBubbles sender ${message.senderId} (not in groupAllowFrom)`,
|
| 1695 |
+
);
|
| 1696 |
+
logVerbose(
|
| 1697 |
+
core,
|
| 1698 |
+
runtime,
|
| 1699 |
+
`drop: group sender not allowed sender=${message.senderId} allowFrom=${effectiveGroupAllowFrom.join(",")}`,
|
| 1700 |
+
);
|
| 1701 |
+
logGroupAllowlistHint({
|
| 1702 |
+
runtime,
|
| 1703 |
+
reason: "groupPolicy=allowlist (not allowlisted)",
|
| 1704 |
+
entry: groupAllowEntry,
|
| 1705 |
+
chatName: groupName,
|
| 1706 |
+
accountId: account.accountId,
|
| 1707 |
+
});
|
| 1708 |
+
return;
|
| 1709 |
+
}
|
| 1710 |
+
}
|
| 1711 |
+
} else {
|
| 1712 |
+
if (dmPolicy === "disabled") {
|
| 1713 |
+
logVerbose(core, runtime, `Blocked BlueBubbles DM from ${message.senderId}`);
|
| 1714 |
+
logVerbose(core, runtime, `drop: dmPolicy disabled sender=${message.senderId}`);
|
| 1715 |
+
return;
|
| 1716 |
+
}
|
| 1717 |
+
if (dmPolicy !== "open") {
|
| 1718 |
+
const allowed = isAllowedBlueBubblesSender({
|
| 1719 |
+
allowFrom: effectiveAllowFrom,
|
| 1720 |
+
sender: message.senderId,
|
| 1721 |
+
chatId: message.chatId ?? undefined,
|
| 1722 |
+
chatGuid: message.chatGuid ?? undefined,
|
| 1723 |
+
chatIdentifier: message.chatIdentifier ?? undefined,
|
| 1724 |
+
});
|
| 1725 |
+
if (!allowed) {
|
| 1726 |
+
if (dmPolicy === "pairing") {
|
| 1727 |
+
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
| 1728 |
+
channel: "bluebubbles",
|
| 1729 |
+
id: message.senderId,
|
| 1730 |
+
meta: { name: message.senderName },
|
| 1731 |
+
});
|
| 1732 |
+
runtime.log?.(
|
| 1733 |
+
`[bluebubbles] pairing request sender=${message.senderId} created=${created}`,
|
| 1734 |
+
);
|
| 1735 |
+
if (created) {
|
| 1736 |
+
logVerbose(core, runtime, `bluebubbles pairing request sender=${message.senderId}`);
|
| 1737 |
+
try {
|
| 1738 |
+
await sendMessageBlueBubbles(
|
| 1739 |
+
message.senderId,
|
| 1740 |
+
core.channel.pairing.buildPairingReply({
|
| 1741 |
+
channel: "bluebubbles",
|
| 1742 |
+
idLine: `Your BlueBubbles sender id: ${message.senderId}`,
|
| 1743 |
+
code,
|
| 1744 |
+
}),
|
| 1745 |
+
{ cfg: config, accountId: account.accountId },
|
| 1746 |
+
);
|
| 1747 |
+
statusSink?.({ lastOutboundAt: Date.now() });
|
| 1748 |
+
} catch (err) {
|
| 1749 |
+
logVerbose(
|
| 1750 |
+
core,
|
| 1751 |
+
runtime,
|
| 1752 |
+
`bluebubbles pairing reply failed for ${message.senderId}: ${String(err)}`,
|
| 1753 |
+
);
|
| 1754 |
+
runtime.error?.(
|
| 1755 |
+
`[bluebubbles] pairing reply failed sender=${message.senderId}: ${String(err)}`,
|
| 1756 |
+
);
|
| 1757 |
+
}
|
| 1758 |
+
}
|
| 1759 |
+
} else {
|
| 1760 |
+
logVerbose(
|
| 1761 |
+
core,
|
| 1762 |
+
runtime,
|
| 1763 |
+
`Blocked unauthorized BlueBubbles sender ${message.senderId} (dmPolicy=${dmPolicy})`,
|
| 1764 |
+
);
|
| 1765 |
+
logVerbose(
|
| 1766 |
+
core,
|
| 1767 |
+
runtime,
|
| 1768 |
+
`drop: dm sender not allowed sender=${message.senderId} allowFrom=${effectiveAllowFrom.join(",")}`,
|
| 1769 |
+
);
|
| 1770 |
+
}
|
| 1771 |
+
return;
|
| 1772 |
+
}
|
| 1773 |
+
}
|
| 1774 |
+
}
|
| 1775 |
+
|
| 1776 |
+
const chatId = message.chatId ?? undefined;
|
| 1777 |
+
const chatGuid = message.chatGuid ?? undefined;
|
| 1778 |
+
const chatIdentifier = message.chatIdentifier ?? undefined;
|
| 1779 |
+
const peerId = isGroup
|
| 1780 |
+
? (chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group"))
|
| 1781 |
+
: message.senderId;
|
| 1782 |
+
|
| 1783 |
+
const route = core.channel.routing.resolveAgentRoute({
|
| 1784 |
+
cfg: config,
|
| 1785 |
+
channel: "bluebubbles",
|
| 1786 |
+
accountId: account.accountId,
|
| 1787 |
+
peer: {
|
| 1788 |
+
kind: isGroup ? "group" : "dm",
|
| 1789 |
+
id: peerId,
|
| 1790 |
+
},
|
| 1791 |
+
});
|
| 1792 |
+
|
| 1793 |
+
// Mention gating for group chats (parity with iMessage/WhatsApp)
|
| 1794 |
+
const messageText = text;
|
| 1795 |
+
const mentionRegexes = core.channel.mentions.buildMentionRegexes(config, route.agentId);
|
| 1796 |
+
const wasMentioned = isGroup
|
| 1797 |
+
? core.channel.mentions.matchesMentionPatterns(messageText, mentionRegexes)
|
| 1798 |
+
: true;
|
| 1799 |
+
const canDetectMention = mentionRegexes.length > 0;
|
| 1800 |
+
const requireMention = core.channel.groups.resolveRequireMention({
|
| 1801 |
+
cfg: config,
|
| 1802 |
+
channel: "bluebubbles",
|
| 1803 |
+
groupId: peerId,
|
| 1804 |
+
accountId: account.accountId,
|
| 1805 |
+
});
|
| 1806 |
+
|
| 1807 |
+
// Command gating (parity with iMessage/WhatsApp)
|
| 1808 |
+
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
| 1809 |
+
const hasControlCmd = core.channel.text.hasControlCommand(messageText, config);
|
| 1810 |
+
const ownerAllowedForCommands =
|
| 1811 |
+
effectiveAllowFrom.length > 0
|
| 1812 |
+
? isAllowedBlueBubblesSender({
|
| 1813 |
+
allowFrom: effectiveAllowFrom,
|
| 1814 |
+
sender: message.senderId,
|
| 1815 |
+
chatId: message.chatId ?? undefined,
|
| 1816 |
+
chatGuid: message.chatGuid ?? undefined,
|
| 1817 |
+
chatIdentifier: message.chatIdentifier ?? undefined,
|
| 1818 |
+
})
|
| 1819 |
+
: false;
|
| 1820 |
+
const groupAllowedForCommands =
|
| 1821 |
+
effectiveGroupAllowFrom.length > 0
|
| 1822 |
+
? isAllowedBlueBubblesSender({
|
| 1823 |
+
allowFrom: effectiveGroupAllowFrom,
|
| 1824 |
+
sender: message.senderId,
|
| 1825 |
+
chatId: message.chatId ?? undefined,
|
| 1826 |
+
chatGuid: message.chatGuid ?? undefined,
|
| 1827 |
+
chatIdentifier: message.chatIdentifier ?? undefined,
|
| 1828 |
+
})
|
| 1829 |
+
: false;
|
| 1830 |
+
const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands;
|
| 1831 |
+
const commandGate = resolveControlCommandGate({
|
| 1832 |
+
useAccessGroups,
|
| 1833 |
+
authorizers: [
|
| 1834 |
+
{ configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands },
|
| 1835 |
+
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
|
| 1836 |
+
],
|
| 1837 |
+
allowTextCommands: true,
|
| 1838 |
+
hasControlCommand: hasControlCmd,
|
| 1839 |
+
});
|
| 1840 |
+
const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized;
|
| 1841 |
+
|
| 1842 |
+
// Block control commands from unauthorized senders in groups
|
| 1843 |
+
if (isGroup && commandGate.shouldBlock) {
|
| 1844 |
+
logInboundDrop({
|
| 1845 |
+
log: (msg) => logVerbose(core, runtime, msg),
|
| 1846 |
+
channel: "bluebubbles",
|
| 1847 |
+
reason: "control command (unauthorized)",
|
| 1848 |
+
target: message.senderId,
|
| 1849 |
+
});
|
| 1850 |
+
return;
|
| 1851 |
+
}
|
| 1852 |
+
|
| 1853 |
+
// Allow control commands to bypass mention gating when authorized (parity with iMessage)
|
| 1854 |
+
const shouldBypassMention =
|
| 1855 |
+
isGroup && requireMention && !wasMentioned && commandAuthorized && hasControlCmd;
|
| 1856 |
+
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
|
| 1857 |
+
|
| 1858 |
+
// Skip group messages that require mention but weren't mentioned
|
| 1859 |
+
if (isGroup && requireMention && canDetectMention && !wasMentioned && !shouldBypassMention) {
|
| 1860 |
+
logVerbose(core, runtime, `bluebubbles: skipping group message (no mention)`);
|
| 1861 |
+
return;
|
| 1862 |
+
}
|
| 1863 |
+
|
| 1864 |
+
// Cache allowed inbound messages so later replies can resolve sender/body without
|
| 1865 |
+
// surfacing dropped content (allowlist/mention/command gating).
|
| 1866 |
+
cacheInboundMessage();
|
| 1867 |
+
|
| 1868 |
+
const baseUrl = account.config.serverUrl?.trim();
|
| 1869 |
+
const password = account.config.password?.trim();
|
| 1870 |
+
const maxBytes =
|
| 1871 |
+
account.config.mediaMaxMb && account.config.mediaMaxMb > 0
|
| 1872 |
+
? account.config.mediaMaxMb * 1024 * 1024
|
| 1873 |
+
: 8 * 1024 * 1024;
|
| 1874 |
+
|
| 1875 |
+
let mediaUrls: string[] = [];
|
| 1876 |
+
let mediaPaths: string[] = [];
|
| 1877 |
+
let mediaTypes: string[] = [];
|
| 1878 |
+
if (attachments.length > 0) {
|
| 1879 |
+
if (!baseUrl || !password) {
|
| 1880 |
+
logVerbose(core, runtime, "attachment download skipped (missing serverUrl/password)");
|
| 1881 |
+
} else {
|
| 1882 |
+
for (const attachment of attachments) {
|
| 1883 |
+
if (!attachment.guid) {
|
| 1884 |
+
continue;
|
| 1885 |
+
}
|
| 1886 |
+
if (attachment.totalBytes && attachment.totalBytes > maxBytes) {
|
| 1887 |
+
logVerbose(
|
| 1888 |
+
core,
|
| 1889 |
+
runtime,
|
| 1890 |
+
`attachment too large guid=${attachment.guid} bytes=${attachment.totalBytes}`,
|
| 1891 |
+
);
|
| 1892 |
+
continue;
|
| 1893 |
+
}
|
| 1894 |
+
try {
|
| 1895 |
+
const downloaded = await downloadBlueBubblesAttachment(attachment, {
|
| 1896 |
+
cfg: config,
|
| 1897 |
+
accountId: account.accountId,
|
| 1898 |
+
maxBytes,
|
| 1899 |
+
});
|
| 1900 |
+
const saved = await core.channel.media.saveMediaBuffer(
|
| 1901 |
+
downloaded.buffer,
|
| 1902 |
+
downloaded.contentType,
|
| 1903 |
+
"inbound",
|
| 1904 |
+
maxBytes,
|
| 1905 |
+
);
|
| 1906 |
+
mediaPaths.push(saved.path);
|
| 1907 |
+
mediaUrls.push(saved.path);
|
| 1908 |
+
if (saved.contentType) {
|
| 1909 |
+
mediaTypes.push(saved.contentType);
|
| 1910 |
+
}
|
| 1911 |
+
} catch (err) {
|
| 1912 |
+
logVerbose(
|
| 1913 |
+
core,
|
| 1914 |
+
runtime,
|
| 1915 |
+
`attachment download failed guid=${attachment.guid} err=${String(err)}`,
|
| 1916 |
+
);
|
| 1917 |
+
}
|
| 1918 |
+
}
|
| 1919 |
+
}
|
| 1920 |
+
}
|
| 1921 |
+
let replyToId = message.replyToId;
|
| 1922 |
+
let replyToBody = message.replyToBody;
|
| 1923 |
+
let replyToSender = message.replyToSender;
|
| 1924 |
+
let replyToShortId: string | undefined;
|
| 1925 |
+
|
| 1926 |
+
if (isTapbackMessage && tapbackContext?.replyToId) {
|
| 1927 |
+
replyToId = tapbackContext.replyToId;
|
| 1928 |
+
}
|
| 1929 |
+
|
| 1930 |
+
if (replyToId) {
|
| 1931 |
+
const cached = resolveReplyContextFromCache({
|
| 1932 |
+
accountId: account.accountId,
|
| 1933 |
+
replyToId,
|
| 1934 |
+
chatGuid: message.chatGuid,
|
| 1935 |
+
chatIdentifier: message.chatIdentifier,
|
| 1936 |
+
chatId: message.chatId,
|
| 1937 |
+
});
|
| 1938 |
+
if (cached) {
|
| 1939 |
+
if (!replyToBody && cached.body) {
|
| 1940 |
+
replyToBody = cached.body;
|
| 1941 |
+
}
|
| 1942 |
+
if (!replyToSender && cached.senderLabel) {
|
| 1943 |
+
replyToSender = cached.senderLabel;
|
| 1944 |
+
}
|
| 1945 |
+
replyToShortId = cached.shortId;
|
| 1946 |
+
if (core.logging.shouldLogVerbose()) {
|
| 1947 |
+
const preview = (cached.body ?? "").replace(/\s+/g, " ").slice(0, 120);
|
| 1948 |
+
logVerbose(
|
| 1949 |
+
core,
|
| 1950 |
+
runtime,
|
| 1951 |
+
`reply-context cache hit replyToId=${replyToId} sender=${replyToSender ?? ""} body="${preview}"`,
|
| 1952 |
+
);
|
| 1953 |
+
}
|
| 1954 |
+
}
|
| 1955 |
+
}
|
| 1956 |
+
|
| 1957 |
+
// If no cached short ID, try to get one from the UUID directly
|
| 1958 |
+
if (replyToId && !replyToShortId) {
|
| 1959 |
+
replyToShortId = getShortIdForUuid(replyToId);
|
| 1960 |
+
}
|
| 1961 |
+
|
| 1962 |
+
// Use inline [[reply_to:N]] tag format
|
| 1963 |
+
// For tapbacks/reactions: append at end (e.g., "reacted with ❤️ [[reply_to:4]]")
|
| 1964 |
+
// For regular replies: prepend at start (e.g., "[[reply_to:4]] Awesome")
|
| 1965 |
+
const replyTag = formatReplyTag({ replyToId, replyToShortId });
|
| 1966 |
+
const baseBody = replyTag
|
| 1967 |
+
? isTapbackMessage
|
| 1968 |
+
? `${rawBody} ${replyTag}`
|
| 1969 |
+
: `${replyTag} ${rawBody}`
|
| 1970 |
+
: rawBody;
|
| 1971 |
+
const fromLabel = isGroup ? undefined : message.senderName || `user:${message.senderId}`;
|
| 1972 |
+
const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
|
| 1973 |
+
const groupMembers = isGroup
|
| 1974 |
+
? formatGroupMembers({
|
| 1975 |
+
participants: message.participants,
|
| 1976 |
+
fallback: message.senderId ? { id: message.senderId, name: message.senderName } : undefined,
|
| 1977 |
+
})
|
| 1978 |
+
: undefined;
|
| 1979 |
+
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
| 1980 |
+
agentId: route.agentId,
|
| 1981 |
+
});
|
| 1982 |
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
| 1983 |
+
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
| 1984 |
+
storePath,
|
| 1985 |
+
sessionKey: route.sessionKey,
|
| 1986 |
+
});
|
| 1987 |
+
const body = core.channel.reply.formatAgentEnvelope({
|
| 1988 |
+
channel: "BlueBubbles",
|
| 1989 |
+
from: fromLabel,
|
| 1990 |
+
timestamp: message.timestamp,
|
| 1991 |
+
previousTimestamp,
|
| 1992 |
+
envelope: envelopeOptions,
|
| 1993 |
+
body: baseBody,
|
| 1994 |
+
});
|
| 1995 |
+
let chatGuidForActions = chatGuid;
|
| 1996 |
+
if (!chatGuidForActions && baseUrl && password) {
|
| 1997 |
+
const target =
|
| 1998 |
+
isGroup && (chatId || chatIdentifier)
|
| 1999 |
+
? chatId
|
| 2000 |
+
? ({ kind: "chat_id", chatId } as const)
|
| 2001 |
+
: ({ kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" } as const)
|
| 2002 |
+
: ({ kind: "handle", address: message.senderId } as const);
|
| 2003 |
+
if (target.kind !== "chat_identifier" || target.chatIdentifier) {
|
| 2004 |
+
chatGuidForActions =
|
| 2005 |
+
(await resolveChatGuidForTarget({
|
| 2006 |
+
baseUrl,
|
| 2007 |
+
password,
|
| 2008 |
+
target,
|
| 2009 |
+
})) ?? undefined;
|
| 2010 |
+
}
|
| 2011 |
+
}
|
| 2012 |
+
|
| 2013 |
+
const ackReactionScope = config.messages?.ackReactionScope ?? "group-mentions";
|
| 2014 |
+
const removeAckAfterReply = config.messages?.removeAckAfterReply ?? false;
|
| 2015 |
+
const ackReactionValue = resolveBlueBubblesAckReaction({
|
| 2016 |
+
cfg: config,
|
| 2017 |
+
agentId: route.agentId,
|
| 2018 |
+
core,
|
| 2019 |
+
runtime,
|
| 2020 |
+
});
|
| 2021 |
+
const shouldAckReaction = () =>
|
| 2022 |
+
Boolean(
|
| 2023 |
+
ackReactionValue &&
|
| 2024 |
+
core.channel.reactions.shouldAckReaction({
|
| 2025 |
+
scope: ackReactionScope,
|
| 2026 |
+
isDirect: !isGroup,
|
| 2027 |
+
isGroup,
|
| 2028 |
+
isMentionableGroup: isGroup,
|
| 2029 |
+
requireMention: Boolean(requireMention),
|
| 2030 |
+
canDetectMention,
|
| 2031 |
+
effectiveWasMentioned,
|
| 2032 |
+
shouldBypassMention,
|
| 2033 |
+
}),
|
| 2034 |
+
);
|
| 2035 |
+
const ackMessageId = message.messageId?.trim() || "";
|
| 2036 |
+
const ackReactionPromise =
|
| 2037 |
+
shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue
|
| 2038 |
+
? sendBlueBubblesReaction({
|
| 2039 |
+
chatGuid: chatGuidForActions,
|
| 2040 |
+
messageGuid: ackMessageId,
|
| 2041 |
+
emoji: ackReactionValue,
|
| 2042 |
+
opts: { cfg: config, accountId: account.accountId },
|
| 2043 |
+
}).then(
|
| 2044 |
+
() => true,
|
| 2045 |
+
(err) => {
|
| 2046 |
+
logVerbose(
|
| 2047 |
+
core,
|
| 2048 |
+
runtime,
|
| 2049 |
+
`ack reaction failed chatGuid=${chatGuidForActions} msg=${ackMessageId}: ${String(err)}`,
|
| 2050 |
+
);
|
| 2051 |
+
return false;
|
| 2052 |
+
},
|
| 2053 |
+
)
|
| 2054 |
+
: null;
|
| 2055 |
+
|
| 2056 |
+
// Respect sendReadReceipts config (parity with WhatsApp)
|
| 2057 |
+
const sendReadReceipts = account.config.sendReadReceipts !== false;
|
| 2058 |
+
if (chatGuidForActions && baseUrl && password && sendReadReceipts) {
|
| 2059 |
+
try {
|
| 2060 |
+
await markBlueBubblesChatRead(chatGuidForActions, {
|
| 2061 |
+
cfg: config,
|
| 2062 |
+
accountId: account.accountId,
|
| 2063 |
+
});
|
| 2064 |
+
logVerbose(core, runtime, `marked read chatGuid=${chatGuidForActions}`);
|
| 2065 |
+
} catch (err) {
|
| 2066 |
+
runtime.error?.(`[bluebubbles] mark read failed: ${String(err)}`);
|
| 2067 |
+
}
|
| 2068 |
+
} else if (!sendReadReceipts) {
|
| 2069 |
+
logVerbose(core, runtime, "mark read skipped (sendReadReceipts=false)");
|
| 2070 |
+
} else {
|
| 2071 |
+
logVerbose(core, runtime, "mark read skipped (missing chatGuid or credentials)");
|
| 2072 |
+
}
|
| 2073 |
+
|
| 2074 |
+
const outboundTarget = isGroup
|
| 2075 |
+
? formatBlueBubblesChatTarget({
|
| 2076 |
+
chatId,
|
| 2077 |
+
chatGuid: chatGuidForActions ?? chatGuid,
|
| 2078 |
+
chatIdentifier,
|
| 2079 |
+
}) || peerId
|
| 2080 |
+
: chatGuidForActions
|
| 2081 |
+
? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions })
|
| 2082 |
+
: message.senderId;
|
| 2083 |
+
|
| 2084 |
+
const maybeEnqueueOutboundMessageId = (messageId?: string, snippet?: string) => {
|
| 2085 |
+
const trimmed = messageId?.trim();
|
| 2086 |
+
if (!trimmed || trimmed === "ok" || trimmed === "unknown") {
|
| 2087 |
+
return;
|
| 2088 |
+
}
|
| 2089 |
+
// Cache outbound message to get short ID
|
| 2090 |
+
const cacheEntry = rememberBlueBubblesReplyCache({
|
| 2091 |
+
accountId: account.accountId,
|
| 2092 |
+
messageId: trimmed,
|
| 2093 |
+
chatGuid: chatGuidForActions ?? chatGuid,
|
| 2094 |
+
chatIdentifier,
|
| 2095 |
+
chatId,
|
| 2096 |
+
senderLabel: "me",
|
| 2097 |
+
body: snippet ?? "",
|
| 2098 |
+
timestamp: Date.now(),
|
| 2099 |
+
});
|
| 2100 |
+
const displayId = cacheEntry.shortId || trimmed;
|
| 2101 |
+
const preview = snippet ? ` "${snippet.slice(0, 12)}${snippet.length > 12 ? "…" : ""}"` : "";
|
| 2102 |
+
core.system.enqueueSystemEvent(`Assistant sent${preview} [message_id:${displayId}]`, {
|
| 2103 |
+
sessionKey: route.sessionKey,
|
| 2104 |
+
contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`,
|
| 2105 |
+
});
|
| 2106 |
+
};
|
| 2107 |
+
|
| 2108 |
+
const ctxPayload = {
|
| 2109 |
+
Body: body,
|
| 2110 |
+
BodyForAgent: body,
|
| 2111 |
+
RawBody: rawBody,
|
| 2112 |
+
CommandBody: rawBody,
|
| 2113 |
+
BodyForCommands: rawBody,
|
| 2114 |
+
MediaUrl: mediaUrls[0],
|
| 2115 |
+
MediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined,
|
| 2116 |
+
MediaPath: mediaPaths[0],
|
| 2117 |
+
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
| 2118 |
+
MediaType: mediaTypes[0],
|
| 2119 |
+
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
| 2120 |
+
From: isGroup ? `group:${peerId}` : `bluebubbles:${message.senderId}`,
|
| 2121 |
+
To: `bluebubbles:${outboundTarget}`,
|
| 2122 |
+
SessionKey: route.sessionKey,
|
| 2123 |
+
AccountId: route.accountId,
|
| 2124 |
+
ChatType: isGroup ? "group" : "direct",
|
| 2125 |
+
ConversationLabel: fromLabel,
|
| 2126 |
+
// Use short ID for token savings (agent can use this to reference the message)
|
| 2127 |
+
ReplyToId: replyToShortId || replyToId,
|
| 2128 |
+
ReplyToIdFull: replyToId,
|
| 2129 |
+
ReplyToBody: replyToBody,
|
| 2130 |
+
ReplyToSender: replyToSender,
|
| 2131 |
+
GroupSubject: groupSubject,
|
| 2132 |
+
GroupMembers: groupMembers,
|
| 2133 |
+
SenderName: message.senderName || undefined,
|
| 2134 |
+
SenderId: message.senderId,
|
| 2135 |
+
Provider: "bluebubbles",
|
| 2136 |
+
Surface: "bluebubbles",
|
| 2137 |
+
// Use short ID for token savings (agent can use this to reference the message)
|
| 2138 |
+
MessageSid: messageShortId || message.messageId,
|
| 2139 |
+
MessageSidFull: message.messageId,
|
| 2140 |
+
Timestamp: message.timestamp,
|
| 2141 |
+
OriginatingChannel: "bluebubbles",
|
| 2142 |
+
OriginatingTo: `bluebubbles:${outboundTarget}`,
|
| 2143 |
+
WasMentioned: effectiveWasMentioned,
|
| 2144 |
+
CommandAuthorized: commandAuthorized,
|
| 2145 |
+
};
|
| 2146 |
+
|
| 2147 |
+
let sentMessage = false;
|
| 2148 |
+
try {
|
| 2149 |
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
| 2150 |
+
ctx: ctxPayload,
|
| 2151 |
+
cfg: config,
|
| 2152 |
+
dispatcherOptions: {
|
| 2153 |
+
deliver: async (payload) => {
|
| 2154 |
+
const rawReplyToId =
|
| 2155 |
+
typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
|
| 2156 |
+
// Resolve short ID (e.g., "5") to full UUID
|
| 2157 |
+
const replyToMessageGuid = rawReplyToId
|
| 2158 |
+
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
|
| 2159 |
+
: "";
|
| 2160 |
+
const mediaList = payload.mediaUrls?.length
|
| 2161 |
+
? payload.mediaUrls
|
| 2162 |
+
: payload.mediaUrl
|
| 2163 |
+
? [payload.mediaUrl]
|
| 2164 |
+
: [];
|
| 2165 |
+
if (mediaList.length > 0) {
|
| 2166 |
+
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
| 2167 |
+
cfg: config,
|
| 2168 |
+
channel: "bluebubbles",
|
| 2169 |
+
accountId: account.accountId,
|
| 2170 |
+
});
|
| 2171 |
+
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
| 2172 |
+
let first = true;
|
| 2173 |
+
for (const mediaUrl of mediaList) {
|
| 2174 |
+
const caption = first ? text : undefined;
|
| 2175 |
+
first = false;
|
| 2176 |
+
const result = await sendBlueBubblesMedia({
|
| 2177 |
+
cfg: config,
|
| 2178 |
+
to: outboundTarget,
|
| 2179 |
+
mediaUrl,
|
| 2180 |
+
caption: caption ?? undefined,
|
| 2181 |
+
replyToId: replyToMessageGuid || null,
|
| 2182 |
+
accountId: account.accountId,
|
| 2183 |
+
});
|
| 2184 |
+
const cachedBody = (caption ?? "").trim() || "<media:attachment>";
|
| 2185 |
+
maybeEnqueueOutboundMessageId(result.messageId, cachedBody);
|
| 2186 |
+
sentMessage = true;
|
| 2187 |
+
statusSink?.({ lastOutboundAt: Date.now() });
|
| 2188 |
+
}
|
| 2189 |
+
return;
|
| 2190 |
+
}
|
| 2191 |
+
|
| 2192 |
+
const textLimit =
|
| 2193 |
+
account.config.textChunkLimit && account.config.textChunkLimit > 0
|
| 2194 |
+
? account.config.textChunkLimit
|
| 2195 |
+
: DEFAULT_TEXT_LIMIT;
|
| 2196 |
+
const chunkMode = account.config.chunkMode ?? "length";
|
| 2197 |
+
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
| 2198 |
+
cfg: config,
|
| 2199 |
+
channel: "bluebubbles",
|
| 2200 |
+
accountId: account.accountId,
|
| 2201 |
+
});
|
| 2202 |
+
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
| 2203 |
+
const chunks =
|
| 2204 |
+
chunkMode === "newline"
|
| 2205 |
+
? core.channel.text.chunkTextWithMode(text, textLimit, chunkMode)
|
| 2206 |
+
: core.channel.text.chunkMarkdownText(text, textLimit);
|
| 2207 |
+
if (!chunks.length && text) {
|
| 2208 |
+
chunks.push(text);
|
| 2209 |
+
}
|
| 2210 |
+
if (!chunks.length) {
|
| 2211 |
+
return;
|
| 2212 |
+
}
|
| 2213 |
+
for (let i = 0; i < chunks.length; i++) {
|
| 2214 |
+
const chunk = chunks[i];
|
| 2215 |
+
const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
|
| 2216 |
+
cfg: config,
|
| 2217 |
+
accountId: account.accountId,
|
| 2218 |
+
replyToMessageGuid: replyToMessageGuid || undefined,
|
| 2219 |
+
});
|
| 2220 |
+
maybeEnqueueOutboundMessageId(result.messageId, chunk);
|
| 2221 |
+
sentMessage = true;
|
| 2222 |
+
statusSink?.({ lastOutboundAt: Date.now() });
|
| 2223 |
+
// In newline mode, restart typing after each chunk if more chunks remain
|
| 2224 |
+
// Small delay allows the Apple API to finish clearing the typing state from message send
|
| 2225 |
+
if (chunkMode === "newline" && i < chunks.length - 1 && chatGuidForActions) {
|
| 2226 |
+
await new Promise((r) => setTimeout(r, 150));
|
| 2227 |
+
sendBlueBubblesTyping(chatGuidForActions, true, {
|
| 2228 |
+
cfg: config,
|
| 2229 |
+
accountId: account.accountId,
|
| 2230 |
+
}).catch(() => {
|
| 2231 |
+
// Ignore typing errors
|
| 2232 |
+
});
|
| 2233 |
+
}
|
| 2234 |
+
}
|
| 2235 |
+
},
|
| 2236 |
+
onReplyStart: async () => {
|
| 2237 |
+
if (!chatGuidForActions) {
|
| 2238 |
+
return;
|
| 2239 |
+
}
|
| 2240 |
+
if (!baseUrl || !password) {
|
| 2241 |
+
return;
|
| 2242 |
+
}
|
| 2243 |
+
logVerbose(core, runtime, `typing start chatGuid=${chatGuidForActions}`);
|
| 2244 |
+
try {
|
| 2245 |
+
await sendBlueBubblesTyping(chatGuidForActions, true, {
|
| 2246 |
+
cfg: config,
|
| 2247 |
+
accountId: account.accountId,
|
| 2248 |
+
});
|
| 2249 |
+
} catch (err) {
|
| 2250 |
+
runtime.error?.(`[bluebubbles] typing start failed: ${String(err)}`);
|
| 2251 |
+
}
|
| 2252 |
+
},
|
| 2253 |
+
onIdle: async () => {
|
| 2254 |
+
if (!chatGuidForActions) {
|
| 2255 |
+
return;
|
| 2256 |
+
}
|
| 2257 |
+
if (!baseUrl || !password) {
|
| 2258 |
+
return;
|
| 2259 |
+
}
|
| 2260 |
+
try {
|
| 2261 |
+
await sendBlueBubblesTyping(chatGuidForActions, false, {
|
| 2262 |
+
cfg: config,
|
| 2263 |
+
accountId: account.accountId,
|
| 2264 |
+
});
|
| 2265 |
+
} catch (err) {
|
| 2266 |
+
logVerbose(core, runtime, `typing stop failed: ${String(err)}`);
|
| 2267 |
+
}
|
| 2268 |
+
},
|
| 2269 |
+
onError: (err, info) => {
|
| 2270 |
+
runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`);
|
| 2271 |
+
},
|
| 2272 |
+
},
|
| 2273 |
+
replyOptions: {
|
| 2274 |
+
disableBlockStreaming:
|
| 2275 |
+
typeof account.config.blockStreaming === "boolean"
|
| 2276 |
+
? !account.config.blockStreaming
|
| 2277 |
+
: undefined,
|
| 2278 |
+
},
|
| 2279 |
+
});
|
| 2280 |
+
} finally {
|
| 2281 |
+
if (sentMessage && chatGuidForActions && ackMessageId) {
|
| 2282 |
+
core.channel.reactions.removeAckReactionAfterReply({
|
| 2283 |
+
removeAfterReply: removeAckAfterReply,
|
| 2284 |
+
ackReactionPromise,
|
| 2285 |
+
ackReactionValue: ackReactionValue ?? null,
|
| 2286 |
+
remove: () =>
|
| 2287 |
+
sendBlueBubblesReaction({
|
| 2288 |
+
chatGuid: chatGuidForActions,
|
| 2289 |
+
messageGuid: ackMessageId,
|
| 2290 |
+
emoji: ackReactionValue ?? "",
|
| 2291 |
+
remove: true,
|
| 2292 |
+
opts: { cfg: config, accountId: account.accountId },
|
| 2293 |
+
}),
|
| 2294 |
+
onError: (err) => {
|
| 2295 |
+
logAckFailure({
|
| 2296 |
+
log: (msg) => logVerbose(core, runtime, msg),
|
| 2297 |
+
channel: "bluebubbles",
|
| 2298 |
+
target: `${chatGuidForActions}/${ackMessageId}`,
|
| 2299 |
+
error: err,
|
| 2300 |
+
});
|
| 2301 |
+
},
|
| 2302 |
+
});
|
| 2303 |
+
}
|
| 2304 |
+
if (chatGuidForActions && baseUrl && password && !sentMessage) {
|
| 2305 |
+
// Stop typing indicator when no message was sent (e.g., NO_REPLY)
|
| 2306 |
+
sendBlueBubblesTyping(chatGuidForActions, false, {
|
| 2307 |
+
cfg: config,
|
| 2308 |
+
accountId: account.accountId,
|
| 2309 |
+
}).catch((err) => {
|
| 2310 |
+
logTypingFailure({
|
| 2311 |
+
log: (msg) => logVerbose(core, runtime, msg),
|
| 2312 |
+
channel: "bluebubbles",
|
| 2313 |
+
action: "stop",
|
| 2314 |
+
target: chatGuidForActions,
|
| 2315 |
+
error: err,
|
| 2316 |
+
});
|
| 2317 |
+
});
|
| 2318 |
+
}
|
| 2319 |
+
}
|
| 2320 |
+
}
|
| 2321 |
+
|
| 2322 |
+
async function processReaction(
|
| 2323 |
+
reaction: NormalizedWebhookReaction,
|
| 2324 |
+
target: WebhookTarget,
|
| 2325 |
+
): Promise<void> {
|
| 2326 |
+
const { account, config, runtime, core } = target;
|
| 2327 |
+
if (reaction.fromMe) {
|
| 2328 |
+
return;
|
| 2329 |
+
}
|
| 2330 |
+
|
| 2331 |
+
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
| 2332 |
+
const groupPolicy = account.config.groupPolicy ?? "allowlist";
|
| 2333 |
+
const configAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
|
| 2334 |
+
const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => String(entry));
|
| 2335 |
+
const storeAllowFrom = await core.channel.pairing
|
| 2336 |
+
.readAllowFromStore("bluebubbles")
|
| 2337 |
+
.catch(() => []);
|
| 2338 |
+
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]
|
| 2339 |
+
.map((entry) => String(entry).trim())
|
| 2340 |
+
.filter(Boolean);
|
| 2341 |
+
const effectiveGroupAllowFrom = [
|
| 2342 |
+
...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
|
| 2343 |
+
...storeAllowFrom,
|
| 2344 |
+
]
|
| 2345 |
+
.map((entry) => String(entry).trim())
|
| 2346 |
+
.filter(Boolean);
|
| 2347 |
+
|
| 2348 |
+
if (reaction.isGroup) {
|
| 2349 |
+
if (groupPolicy === "disabled") {
|
| 2350 |
+
return;
|
| 2351 |
+
}
|
| 2352 |
+
if (groupPolicy === "allowlist") {
|
| 2353 |
+
if (effectiveGroupAllowFrom.length === 0) {
|
| 2354 |
+
return;
|
| 2355 |
+
}
|
| 2356 |
+
const allowed = isAllowedBlueBubblesSender({
|
| 2357 |
+
allowFrom: effectiveGroupAllowFrom,
|
| 2358 |
+
sender: reaction.senderId,
|
| 2359 |
+
chatId: reaction.chatId ?? undefined,
|
| 2360 |
+
chatGuid: reaction.chatGuid ?? undefined,
|
| 2361 |
+
chatIdentifier: reaction.chatIdentifier ?? undefined,
|
| 2362 |
+
});
|
| 2363 |
+
if (!allowed) {
|
| 2364 |
+
return;
|
| 2365 |
+
}
|
| 2366 |
+
}
|
| 2367 |
+
} else {
|
| 2368 |
+
if (dmPolicy === "disabled") {
|
| 2369 |
+
return;
|
| 2370 |
+
}
|
| 2371 |
+
if (dmPolicy !== "open") {
|
| 2372 |
+
const allowed = isAllowedBlueBubblesSender({
|
| 2373 |
+
allowFrom: effectiveAllowFrom,
|
| 2374 |
+
sender: reaction.senderId,
|
| 2375 |
+
chatId: reaction.chatId ?? undefined,
|
| 2376 |
+
chatGuid: reaction.chatGuid ?? undefined,
|
| 2377 |
+
chatIdentifier: reaction.chatIdentifier ?? undefined,
|
| 2378 |
+
});
|
| 2379 |
+
if (!allowed) {
|
| 2380 |
+
return;
|
| 2381 |
+
}
|
| 2382 |
+
}
|
| 2383 |
+
}
|
| 2384 |
+
|
| 2385 |
+
const chatId = reaction.chatId ?? undefined;
|
| 2386 |
+
const chatGuid = reaction.chatGuid ?? undefined;
|
| 2387 |
+
const chatIdentifier = reaction.chatIdentifier ?? undefined;
|
| 2388 |
+
const peerId = reaction.isGroup
|
| 2389 |
+
? (chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group"))
|
| 2390 |
+
: reaction.senderId;
|
| 2391 |
+
|
| 2392 |
+
const route = core.channel.routing.resolveAgentRoute({
|
| 2393 |
+
cfg: config,
|
| 2394 |
+
channel: "bluebubbles",
|
| 2395 |
+
accountId: account.accountId,
|
| 2396 |
+
peer: {
|
| 2397 |
+
kind: reaction.isGroup ? "group" : "dm",
|
| 2398 |
+
id: peerId,
|
| 2399 |
+
},
|
| 2400 |
+
});
|
| 2401 |
+
|
| 2402 |
+
const senderLabel = reaction.senderName || reaction.senderId;
|
| 2403 |
+
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
|
| 2404 |
+
// Use short ID for token savings
|
| 2405 |
+
const messageDisplayId = getShortIdForUuid(reaction.messageId) || reaction.messageId;
|
| 2406 |
+
// Format: "Tyler reacted with ❤️ [[reply_to:5]]" or "Tyler removed ❤️ reaction [[reply_to:5]]"
|
| 2407 |
+
const text =
|
| 2408 |
+
reaction.action === "removed"
|
| 2409 |
+
? `${senderLabel} removed ${reaction.emoji} reaction [[reply_to:${messageDisplayId}]]${chatLabel}`
|
| 2410 |
+
: `${senderLabel} reacted with ${reaction.emoji} [[reply_to:${messageDisplayId}]]${chatLabel}`;
|
| 2411 |
+
core.system.enqueueSystemEvent(text, {
|
| 2412 |
+
sessionKey: route.sessionKey,
|
| 2413 |
+
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
|
| 2414 |
+
});
|
| 2415 |
+
logVerbose(core, runtime, `reaction event enqueued: ${text}`);
|
| 2416 |
+
}
|
| 2417 |
+
|
| 2418 |
+
export async function monitorBlueBubblesProvider(
|
| 2419 |
+
options: BlueBubblesMonitorOptions,
|
| 2420 |
+
): Promise<void> {
|
| 2421 |
+
const { account, config, runtime, abortSignal, statusSink } = options;
|
| 2422 |
+
const core = getBlueBubblesRuntime();
|
| 2423 |
+
const path = options.webhookPath?.trim() || DEFAULT_WEBHOOK_PATH;
|
| 2424 |
+
|
| 2425 |
+
// Fetch and cache server info (for macOS version detection in action gating)
|
| 2426 |
+
const serverInfo = await fetchBlueBubblesServerInfo({
|
| 2427 |
+
baseUrl: account.baseUrl,
|
| 2428 |
+
password: account.config.password,
|
| 2429 |
+
accountId: account.accountId,
|
| 2430 |
+
timeoutMs: 5000,
|
| 2431 |
+
}).catch(() => null);
|
| 2432 |
+
if (serverInfo?.os_version) {
|
| 2433 |
+
runtime.log?.(`[${account.accountId}] BlueBubbles server macOS ${serverInfo.os_version}`);
|
| 2434 |
+
}
|
| 2435 |
+
|
| 2436 |
+
const unregister = registerBlueBubblesWebhookTarget({
|
| 2437 |
+
account,
|
| 2438 |
+
config,
|
| 2439 |
+
runtime,
|
| 2440 |
+
core,
|
| 2441 |
+
path,
|
| 2442 |
+
statusSink,
|
| 2443 |
+
});
|
| 2444 |
+
|
| 2445 |
+
return await new Promise((resolve) => {
|
| 2446 |
+
const stop = () => {
|
| 2447 |
+
unregister();
|
| 2448 |
+
resolve();
|
| 2449 |
+
};
|
| 2450 |
+
|
| 2451 |
+
if (abortSignal?.aborted) {
|
| 2452 |
+
stop();
|
| 2453 |
+
return;
|
| 2454 |
+
}
|
| 2455 |
+
|
| 2456 |
+
abortSignal?.addEventListener("abort", stop, { once: true });
|
| 2457 |
+
runtime.log?.(
|
| 2458 |
+
`[${account.accountId}] BlueBubbles webhook listening on ${normalizeWebhookPath(path)}`,
|
| 2459 |
+
);
|
| 2460 |
+
});
|
| 2461 |
+
}
|
| 2462 |
+
|
| 2463 |
+
export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string {
|
| 2464 |
+
const raw = config?.webhookPath?.trim();
|
| 2465 |
+
if (raw) {
|
| 2466 |
+
return normalizeWebhookPath(raw);
|
| 2467 |
+
}
|
| 2468 |
+
return DEFAULT_WEBHOOK_PATH;
|
| 2469 |
+
}
|
extensions/bluebubbles/src/onboarding.ts
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type {
|
| 2 |
+
ChannelOnboardingAdapter,
|
| 3 |
+
ChannelOnboardingDmPolicy,
|
| 4 |
+
OpenClawConfig,
|
| 5 |
+
DmPolicy,
|
| 6 |
+
WizardPrompter,
|
| 7 |
+
} from "openclaw/plugin-sdk";
|
| 8 |
+
import {
|
| 9 |
+
DEFAULT_ACCOUNT_ID,
|
| 10 |
+
addWildcardAllowFrom,
|
| 11 |
+
formatDocsLink,
|
| 12 |
+
normalizeAccountId,
|
| 13 |
+
promptAccountId,
|
| 14 |
+
} from "openclaw/plugin-sdk";
|
| 15 |
+
import {
|
| 16 |
+
listBlueBubblesAccountIds,
|
| 17 |
+
resolveBlueBubblesAccount,
|
| 18 |
+
resolveDefaultBlueBubblesAccountId,
|
| 19 |
+
} from "./accounts.js";
|
| 20 |
+
import { parseBlueBubblesAllowTarget } from "./targets.js";
|
| 21 |
+
import { normalizeBlueBubblesServerUrl } from "./types.js";
|
| 22 |
+
|
| 23 |
+
const channel = "bluebubbles" as const;
|
| 24 |
+
|
| 25 |
+
function setBlueBubblesDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig {
|
| 26 |
+
const allowFrom =
|
| 27 |
+
dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.bluebubbles?.allowFrom) : undefined;
|
| 28 |
+
return {
|
| 29 |
+
...cfg,
|
| 30 |
+
channels: {
|
| 31 |
+
...cfg.channels,
|
| 32 |
+
bluebubbles: {
|
| 33 |
+
...cfg.channels?.bluebubbles,
|
| 34 |
+
dmPolicy,
|
| 35 |
+
...(allowFrom ? { allowFrom } : {}),
|
| 36 |
+
},
|
| 37 |
+
},
|
| 38 |
+
};
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function setBlueBubblesAllowFrom(
|
| 42 |
+
cfg: OpenClawConfig,
|
| 43 |
+
accountId: string,
|
| 44 |
+
allowFrom: string[],
|
| 45 |
+
): OpenClawConfig {
|
| 46 |
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
| 47 |
+
return {
|
| 48 |
+
...cfg,
|
| 49 |
+
channels: {
|
| 50 |
+
...cfg.channels,
|
| 51 |
+
bluebubbles: {
|
| 52 |
+
...cfg.channels?.bluebubbles,
|
| 53 |
+
allowFrom,
|
| 54 |
+
},
|
| 55 |
+
},
|
| 56 |
+
};
|
| 57 |
+
}
|
| 58 |
+
return {
|
| 59 |
+
...cfg,
|
| 60 |
+
channels: {
|
| 61 |
+
...cfg.channels,
|
| 62 |
+
bluebubbles: {
|
| 63 |
+
...cfg.channels?.bluebubbles,
|
| 64 |
+
accounts: {
|
| 65 |
+
...cfg.channels?.bluebubbles?.accounts,
|
| 66 |
+
[accountId]: {
|
| 67 |
+
...cfg.channels?.bluebubbles?.accounts?.[accountId],
|
| 68 |
+
allowFrom,
|
| 69 |
+
},
|
| 70 |
+
},
|
| 71 |
+
},
|
| 72 |
+
},
|
| 73 |
+
};
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function parseBlueBubblesAllowFromInput(raw: string): string[] {
|
| 77 |
+
return raw
|
| 78 |
+
.split(/[\n,]+/g)
|
| 79 |
+
.map((entry) => entry.trim())
|
| 80 |
+
.filter(Boolean);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
async function promptBlueBubblesAllowFrom(params: {
|
| 84 |
+
cfg: OpenClawConfig;
|
| 85 |
+
prompter: WizardPrompter;
|
| 86 |
+
accountId?: string;
|
| 87 |
+
}): Promise<OpenClawConfig> {
|
| 88 |
+
const accountId =
|
| 89 |
+
params.accountId && normalizeAccountId(params.accountId)
|
| 90 |
+
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
|
| 91 |
+
: resolveDefaultBlueBubblesAccountId(params.cfg);
|
| 92 |
+
const resolved = resolveBlueBubblesAccount({ cfg: params.cfg, accountId });
|
| 93 |
+
const existing = resolved.config.allowFrom ?? [];
|
| 94 |
+
await params.prompter.note(
|
| 95 |
+
[
|
| 96 |
+
"Allowlist BlueBubbles DMs by handle or chat target.",
|
| 97 |
+
"Examples:",
|
| 98 |
+
"- +15555550123",
|
| 99 |
+
"- user@example.com",
|
| 100 |
+
"- chat_id:123",
|
| 101 |
+
"- chat_guid:iMessage;-;+15555550123",
|
| 102 |
+
"Multiple entries: comma- or newline-separated.",
|
| 103 |
+
`Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
|
| 104 |
+
].join("\n"),
|
| 105 |
+
"BlueBubbles allowlist",
|
| 106 |
+
);
|
| 107 |
+
const entry = await params.prompter.text({
|
| 108 |
+
message: "BlueBubbles allowFrom (handle or chat_id)",
|
| 109 |
+
placeholder: "+15555550123, user@example.com, chat_id:123",
|
| 110 |
+
initialValue: existing[0] ? String(existing[0]) : undefined,
|
| 111 |
+
validate: (value) => {
|
| 112 |
+
const raw = String(value ?? "").trim();
|
| 113 |
+
if (!raw) {
|
| 114 |
+
return "Required";
|
| 115 |
+
}
|
| 116 |
+
const parts = parseBlueBubblesAllowFromInput(raw);
|
| 117 |
+
for (const part of parts) {
|
| 118 |
+
if (part === "*") {
|
| 119 |
+
continue;
|
| 120 |
+
}
|
| 121 |
+
const parsed = parseBlueBubblesAllowTarget(part);
|
| 122 |
+
if (parsed.kind === "handle" && !parsed.handle) {
|
| 123 |
+
return `Invalid entry: ${part}`;
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
return undefined;
|
| 127 |
+
},
|
| 128 |
+
});
|
| 129 |
+
const parts = parseBlueBubblesAllowFromInput(String(entry));
|
| 130 |
+
const unique = [...new Set(parts)];
|
| 131 |
+
return setBlueBubblesAllowFrom(params.cfg, accountId, unique);
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
const dmPolicy: ChannelOnboardingDmPolicy = {
|
| 135 |
+
label: "BlueBubbles",
|
| 136 |
+
channel,
|
| 137 |
+
policyKey: "channels.bluebubbles.dmPolicy",
|
| 138 |
+
allowFromKey: "channels.bluebubbles.allowFrom",
|
| 139 |
+
getCurrent: (cfg) => cfg.channels?.bluebubbles?.dmPolicy ?? "pairing",
|
| 140 |
+
setPolicy: (cfg, policy) => setBlueBubblesDmPolicy(cfg, policy),
|
| 141 |
+
promptAllowFrom: promptBlueBubblesAllowFrom,
|
| 142 |
+
};
|
| 143 |
+
|
| 144 |
+
export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
|
| 145 |
+
channel,
|
| 146 |
+
getStatus: async ({ cfg }) => {
|
| 147 |
+
const configured = listBlueBubblesAccountIds(cfg).some((accountId) => {
|
| 148 |
+
const account = resolveBlueBubblesAccount({ cfg, accountId });
|
| 149 |
+
return account.configured;
|
| 150 |
+
});
|
| 151 |
+
return {
|
| 152 |
+
channel,
|
| 153 |
+
configured,
|
| 154 |
+
statusLines: [`BlueBubbles: ${configured ? "configured" : "needs setup"}`],
|
| 155 |
+
selectionHint: configured ? "configured" : "iMessage via BlueBubbles app",
|
| 156 |
+
quickstartScore: configured ? 1 : 0,
|
| 157 |
+
};
|
| 158 |
+
},
|
| 159 |
+
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
|
| 160 |
+
const blueBubblesOverride = accountOverrides.bluebubbles?.trim();
|
| 161 |
+
const defaultAccountId = resolveDefaultBlueBubblesAccountId(cfg);
|
| 162 |
+
let accountId = blueBubblesOverride
|
| 163 |
+
? normalizeAccountId(blueBubblesOverride)
|
| 164 |
+
: defaultAccountId;
|
| 165 |
+
if (shouldPromptAccountIds && !blueBubblesOverride) {
|
| 166 |
+
accountId = await promptAccountId({
|
| 167 |
+
cfg,
|
| 168 |
+
prompter,
|
| 169 |
+
label: "BlueBubbles",
|
| 170 |
+
currentId: accountId,
|
| 171 |
+
listAccountIds: listBlueBubblesAccountIds,
|
| 172 |
+
defaultAccountId,
|
| 173 |
+
});
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
let next = cfg;
|
| 177 |
+
const resolvedAccount = resolveBlueBubblesAccount({ cfg: next, accountId });
|
| 178 |
+
|
| 179 |
+
// Prompt for server URL
|
| 180 |
+
let serverUrl = resolvedAccount.config.serverUrl?.trim();
|
| 181 |
+
if (!serverUrl) {
|
| 182 |
+
await prompter.note(
|
| 183 |
+
[
|
| 184 |
+
"Enter the BlueBubbles server URL (e.g., http://192.168.1.100:1234).",
|
| 185 |
+
"Find this in the BlueBubbles Server app under Connection.",
|
| 186 |
+
`Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
|
| 187 |
+
].join("\n"),
|
| 188 |
+
"BlueBubbles server URL",
|
| 189 |
+
);
|
| 190 |
+
const entered = await prompter.text({
|
| 191 |
+
message: "BlueBubbles server URL",
|
| 192 |
+
placeholder: "http://192.168.1.100:1234",
|
| 193 |
+
validate: (value) => {
|
| 194 |
+
const trimmed = String(value ?? "").trim();
|
| 195 |
+
if (!trimmed) {
|
| 196 |
+
return "Required";
|
| 197 |
+
}
|
| 198 |
+
try {
|
| 199 |
+
const normalized = normalizeBlueBubblesServerUrl(trimmed);
|
| 200 |
+
new URL(normalized);
|
| 201 |
+
return undefined;
|
| 202 |
+
} catch {
|
| 203 |
+
return "Invalid URL format";
|
| 204 |
+
}
|
| 205 |
+
},
|
| 206 |
+
});
|
| 207 |
+
serverUrl = String(entered).trim();
|
| 208 |
+
} else {
|
| 209 |
+
const keepUrl = await prompter.confirm({
|
| 210 |
+
message: `BlueBubbles server URL already set (${serverUrl}). Keep it?`,
|
| 211 |
+
initialValue: true,
|
| 212 |
+
});
|
| 213 |
+
if (!keepUrl) {
|
| 214 |
+
const entered = await prompter.text({
|
| 215 |
+
message: "BlueBubbles server URL",
|
| 216 |
+
placeholder: "http://192.168.1.100:1234",
|
| 217 |
+
initialValue: serverUrl,
|
| 218 |
+
validate: (value) => {
|
| 219 |
+
const trimmed = String(value ?? "").trim();
|
| 220 |
+
if (!trimmed) {
|
| 221 |
+
return "Required";
|
| 222 |
+
}
|
| 223 |
+
try {
|
| 224 |
+
const normalized = normalizeBlueBubblesServerUrl(trimmed);
|
| 225 |
+
new URL(normalized);
|
| 226 |
+
return undefined;
|
| 227 |
+
} catch {
|
| 228 |
+
return "Invalid URL format";
|
| 229 |
+
}
|
| 230 |
+
},
|
| 231 |
+
});
|
| 232 |
+
serverUrl = String(entered).trim();
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
// Prompt for password
|
| 237 |
+
let password = resolvedAccount.config.password?.trim();
|
| 238 |
+
if (!password) {
|
| 239 |
+
await prompter.note(
|
| 240 |
+
[
|
| 241 |
+
"Enter the BlueBubbles server password.",
|
| 242 |
+
"Find this in the BlueBubbles Server app under Settings.",
|
| 243 |
+
].join("\n"),
|
| 244 |
+
"BlueBubbles password",
|
| 245 |
+
);
|
| 246 |
+
const entered = await prompter.text({
|
| 247 |
+
message: "BlueBubbles password",
|
| 248 |
+
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
| 249 |
+
});
|
| 250 |
+
password = String(entered).trim();
|
| 251 |
+
} else {
|
| 252 |
+
const keepPassword = await prompter.confirm({
|
| 253 |
+
message: "BlueBubbles password already set. Keep it?",
|
| 254 |
+
initialValue: true,
|
| 255 |
+
});
|
| 256 |
+
if (!keepPassword) {
|
| 257 |
+
const entered = await prompter.text({
|
| 258 |
+
message: "BlueBubbles password",
|
| 259 |
+
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
| 260 |
+
});
|
| 261 |
+
password = String(entered).trim();
|
| 262 |
+
}
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
// Prompt for webhook path (optional)
|
| 266 |
+
const existingWebhookPath = resolvedAccount.config.webhookPath?.trim();
|
| 267 |
+
const wantsWebhook = await prompter.confirm({
|
| 268 |
+
message: "Configure a custom webhook path? (default: /bluebubbles-webhook)",
|
| 269 |
+
initialValue: Boolean(existingWebhookPath && existingWebhookPath !== "/bluebubbles-webhook"),
|
| 270 |
+
});
|
| 271 |
+
let webhookPath = "/bluebubbles-webhook";
|
| 272 |
+
if (wantsWebhook) {
|
| 273 |
+
const entered = await prompter.text({
|
| 274 |
+
message: "Webhook path",
|
| 275 |
+
placeholder: "/bluebubbles-webhook",
|
| 276 |
+
initialValue: existingWebhookPath || "/bluebubbles-webhook",
|
| 277 |
+
validate: (value) => {
|
| 278 |
+
const trimmed = String(value ?? "").trim();
|
| 279 |
+
if (!trimmed) {
|
| 280 |
+
return "Required";
|
| 281 |
+
}
|
| 282 |
+
if (!trimmed.startsWith("/")) {
|
| 283 |
+
return "Path must start with /";
|
| 284 |
+
}
|
| 285 |
+
return undefined;
|
| 286 |
+
},
|
| 287 |
+
});
|
| 288 |
+
webhookPath = String(entered).trim();
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
// Apply config
|
| 292 |
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
| 293 |
+
next = {
|
| 294 |
+
...next,
|
| 295 |
+
channels: {
|
| 296 |
+
...next.channels,
|
| 297 |
+
bluebubbles: {
|
| 298 |
+
...next.channels?.bluebubbles,
|
| 299 |
+
enabled: true,
|
| 300 |
+
serverUrl,
|
| 301 |
+
password,
|
| 302 |
+
webhookPath,
|
| 303 |
+
},
|
| 304 |
+
},
|
| 305 |
+
};
|
| 306 |
+
} else {
|
| 307 |
+
next = {
|
| 308 |
+
...next,
|
| 309 |
+
channels: {
|
| 310 |
+
...next.channels,
|
| 311 |
+
bluebubbles: {
|
| 312 |
+
...next.channels?.bluebubbles,
|
| 313 |
+
enabled: true,
|
| 314 |
+
accounts: {
|
| 315 |
+
...next.channels?.bluebubbles?.accounts,
|
| 316 |
+
[accountId]: {
|
| 317 |
+
...next.channels?.bluebubbles?.accounts?.[accountId],
|
| 318 |
+
enabled: next.channels?.bluebubbles?.accounts?.[accountId]?.enabled ?? true,
|
| 319 |
+
serverUrl,
|
| 320 |
+
password,
|
| 321 |
+
webhookPath,
|
| 322 |
+
},
|
| 323 |
+
},
|
| 324 |
+
},
|
| 325 |
+
},
|
| 326 |
+
};
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
await prompter.note(
|
| 330 |
+
[
|
| 331 |
+
"Configure the webhook URL in BlueBubbles Server:",
|
| 332 |
+
"1. Open BlueBubbles Server → Settings → Webhooks",
|
| 333 |
+
"2. Add your OpenClaw gateway URL + webhook path",
|
| 334 |
+
" Example: https://your-gateway-host:3000/bluebubbles-webhook",
|
| 335 |
+
"3. Enable the webhook and save",
|
| 336 |
+
"",
|
| 337 |
+
`Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
|
| 338 |
+
].join("\n"),
|
| 339 |
+
"BlueBubbles next steps",
|
| 340 |
+
);
|
| 341 |
+
|
| 342 |
+
return { cfg: next, accountId };
|
| 343 |
+
},
|
| 344 |
+
dmPolicy,
|
| 345 |
+
disable: (cfg) => ({
|
| 346 |
+
...cfg,
|
| 347 |
+
channels: {
|
| 348 |
+
...cfg.channels,
|
| 349 |
+
bluebubbles: { ...cfg.channels?.bluebubbles, enabled: false },
|
| 350 |
+
},
|
| 351 |
+
}),
|
| 352 |
+
};
|
extensions/bluebubbles/src/probe.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js";
|
| 2 |
+
|
| 3 |
+
export type BlueBubblesProbe = {
|
| 4 |
+
ok: boolean;
|
| 5 |
+
status?: number | null;
|
| 6 |
+
error?: string | null;
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
export type BlueBubblesServerInfo = {
|
| 10 |
+
os_version?: string;
|
| 11 |
+
server_version?: string;
|
| 12 |
+
private_api?: boolean;
|
| 13 |
+
helper_connected?: boolean;
|
| 14 |
+
proxy_service?: string;
|
| 15 |
+
detected_icloud?: string;
|
| 16 |
+
computer_id?: string;
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
/** Cache server info by account ID to avoid repeated API calls */
|
| 20 |
+
const serverInfoCache = new Map<string, { info: BlueBubblesServerInfo; expires: number }>();
|
| 21 |
+
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
| 22 |
+
|
| 23 |
+
function buildCacheKey(accountId?: string): string {
|
| 24 |
+
return accountId?.trim() || "default";
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
/**
|
| 28 |
+
* Fetch server info from BlueBubbles API and cache it.
|
| 29 |
+
* Returns cached result if available and not expired.
|
| 30 |
+
*/
|
| 31 |
+
export async function fetchBlueBubblesServerInfo(params: {
|
| 32 |
+
baseUrl?: string | null;
|
| 33 |
+
password?: string | null;
|
| 34 |
+
accountId?: string;
|
| 35 |
+
timeoutMs?: number;
|
| 36 |
+
}): Promise<BlueBubblesServerInfo | null> {
|
| 37 |
+
const baseUrl = params.baseUrl?.trim();
|
| 38 |
+
const password = params.password?.trim();
|
| 39 |
+
if (!baseUrl || !password) {
|
| 40 |
+
return null;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const cacheKey = buildCacheKey(params.accountId);
|
| 44 |
+
const cached = serverInfoCache.get(cacheKey);
|
| 45 |
+
if (cached && cached.expires > Date.now()) {
|
| 46 |
+
return cached.info;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/server/info", password });
|
| 50 |
+
try {
|
| 51 |
+
const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, params.timeoutMs ?? 5000);
|
| 52 |
+
if (!res.ok) {
|
| 53 |
+
return null;
|
| 54 |
+
}
|
| 55 |
+
const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
| 56 |
+
const data = payload?.data as BlueBubblesServerInfo | undefined;
|
| 57 |
+
if (data) {
|
| 58 |
+
serverInfoCache.set(cacheKey, { info: data, expires: Date.now() + CACHE_TTL_MS });
|
| 59 |
+
}
|
| 60 |
+
return data ?? null;
|
| 61 |
+
} catch {
|
| 62 |
+
return null;
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
/**
|
| 67 |
+
* Get cached server info synchronously (for use in listActions).
|
| 68 |
+
* Returns null if not cached or expired.
|
| 69 |
+
*/
|
| 70 |
+
export function getCachedBlueBubblesServerInfo(accountId?: string): BlueBubblesServerInfo | null {
|
| 71 |
+
const cacheKey = buildCacheKey(accountId);
|
| 72 |
+
const cached = serverInfoCache.get(cacheKey);
|
| 73 |
+
if (cached && cached.expires > Date.now()) {
|
| 74 |
+
return cached.info;
|
| 75 |
+
}
|
| 76 |
+
return null;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
/**
|
| 80 |
+
* Parse macOS version string (e.g., "15.0.1" or "26.0") into major version number.
|
| 81 |
+
*/
|
| 82 |
+
export function parseMacOSMajorVersion(version?: string | null): number | null {
|
| 83 |
+
if (!version) {
|
| 84 |
+
return null;
|
| 85 |
+
}
|
| 86 |
+
const match = /^(\d+)/.exec(version.trim());
|
| 87 |
+
return match ? Number.parseInt(match[1], 10) : null;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
/**
|
| 91 |
+
* Check if the cached server info indicates macOS 26 or higher.
|
| 92 |
+
* Returns false if no cached info is available (fail open for action listing).
|
| 93 |
+
*/
|
| 94 |
+
export function isMacOS26OrHigher(accountId?: string): boolean {
|
| 95 |
+
const info = getCachedBlueBubblesServerInfo(accountId);
|
| 96 |
+
if (!info?.os_version) {
|
| 97 |
+
return false;
|
| 98 |
+
}
|
| 99 |
+
const major = parseMacOSMajorVersion(info.os_version);
|
| 100 |
+
return major !== null && major >= 26;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
/** Clear the server info cache (for testing) */
|
| 104 |
+
export function clearServerInfoCache(): void {
|
| 105 |
+
serverInfoCache.clear();
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
export async function probeBlueBubbles(params: {
|
| 109 |
+
baseUrl?: string | null;
|
| 110 |
+
password?: string | null;
|
| 111 |
+
timeoutMs?: number;
|
| 112 |
+
}): Promise<BlueBubblesProbe> {
|
| 113 |
+
const baseUrl = params.baseUrl?.trim();
|
| 114 |
+
const password = params.password?.trim();
|
| 115 |
+
if (!baseUrl) {
|
| 116 |
+
return { ok: false, error: "serverUrl not configured" };
|
| 117 |
+
}
|
| 118 |
+
if (!password) {
|
| 119 |
+
return { ok: false, error: "password not configured" };
|
| 120 |
+
}
|
| 121 |
+
const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/ping", password });
|
| 122 |
+
try {
|
| 123 |
+
const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, params.timeoutMs);
|
| 124 |
+
if (!res.ok) {
|
| 125 |
+
return { ok: false, status: res.status, error: `HTTP ${res.status}` };
|
| 126 |
+
}
|
| 127 |
+
return { ok: true, status: res.status };
|
| 128 |
+
} catch (err) {
|
| 129 |
+
return {
|
| 130 |
+
ok: false,
|
| 131 |
+
status: null,
|
| 132 |
+
error: err instanceof Error ? err.message : String(err),
|
| 133 |
+
};
|
| 134 |
+
}
|
| 135 |
+
}
|
extensions/bluebubbles/src/reactions.test.ts
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
| 2 |
+
import { sendBlueBubblesReaction } from "./reactions.js";
|
| 3 |
+
|
| 4 |
+
vi.mock("./accounts.js", () => ({
|
| 5 |
+
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
|
| 6 |
+
const config = cfg?.channels?.bluebubbles ?? {};
|
| 7 |
+
return {
|
| 8 |
+
accountId: accountId ?? "default",
|
| 9 |
+
enabled: config.enabled !== false,
|
| 10 |
+
configured: Boolean(config.serverUrl && config.password),
|
| 11 |
+
config,
|
| 12 |
+
};
|
| 13 |
+
}),
|
| 14 |
+
}));
|
| 15 |
+
|
| 16 |
+
const mockFetch = vi.fn();
|
| 17 |
+
|
| 18 |
+
describe("reactions", () => {
|
| 19 |
+
beforeEach(() => {
|
| 20 |
+
vi.stubGlobal("fetch", mockFetch);
|
| 21 |
+
mockFetch.mockReset();
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
afterEach(() => {
|
| 25 |
+
vi.unstubAllGlobals();
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
describe("sendBlueBubblesReaction", () => {
|
| 29 |
+
it("throws when chatGuid is empty", async () => {
|
| 30 |
+
await expect(
|
| 31 |
+
sendBlueBubblesReaction({
|
| 32 |
+
chatGuid: "",
|
| 33 |
+
messageGuid: "msg-123",
|
| 34 |
+
emoji: "love",
|
| 35 |
+
opts: {
|
| 36 |
+
serverUrl: "http://localhost:1234",
|
| 37 |
+
password: "test",
|
| 38 |
+
},
|
| 39 |
+
}),
|
| 40 |
+
).rejects.toThrow("chatGuid");
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
it("throws when messageGuid is empty", async () => {
|
| 44 |
+
await expect(
|
| 45 |
+
sendBlueBubblesReaction({
|
| 46 |
+
chatGuid: "chat-123",
|
| 47 |
+
messageGuid: "",
|
| 48 |
+
emoji: "love",
|
| 49 |
+
opts: {
|
| 50 |
+
serverUrl: "http://localhost:1234",
|
| 51 |
+
password: "test",
|
| 52 |
+
},
|
| 53 |
+
}),
|
| 54 |
+
).rejects.toThrow("messageGuid");
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
it("throws when emoji is empty", async () => {
|
| 58 |
+
await expect(
|
| 59 |
+
sendBlueBubblesReaction({
|
| 60 |
+
chatGuid: "chat-123",
|
| 61 |
+
messageGuid: "msg-123",
|
| 62 |
+
emoji: "",
|
| 63 |
+
opts: {
|
| 64 |
+
serverUrl: "http://localhost:1234",
|
| 65 |
+
password: "test",
|
| 66 |
+
},
|
| 67 |
+
}),
|
| 68 |
+
).rejects.toThrow("emoji or name");
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
it("throws when serverUrl is missing", async () => {
|
| 72 |
+
await expect(
|
| 73 |
+
sendBlueBubblesReaction({
|
| 74 |
+
chatGuid: "chat-123",
|
| 75 |
+
messageGuid: "msg-123",
|
| 76 |
+
emoji: "love",
|
| 77 |
+
opts: {},
|
| 78 |
+
}),
|
| 79 |
+
).rejects.toThrow("serverUrl is required");
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
it("throws when password is missing", async () => {
|
| 83 |
+
await expect(
|
| 84 |
+
sendBlueBubblesReaction({
|
| 85 |
+
chatGuid: "chat-123",
|
| 86 |
+
messageGuid: "msg-123",
|
| 87 |
+
emoji: "love",
|
| 88 |
+
opts: {
|
| 89 |
+
serverUrl: "http://localhost:1234",
|
| 90 |
+
},
|
| 91 |
+
}),
|
| 92 |
+
).rejects.toThrow("password is required");
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
it("throws for unsupported reaction type", async () => {
|
| 96 |
+
await expect(
|
| 97 |
+
sendBlueBubblesReaction({
|
| 98 |
+
chatGuid: "chat-123",
|
| 99 |
+
messageGuid: "msg-123",
|
| 100 |
+
emoji: "unsupported",
|
| 101 |
+
opts: {
|
| 102 |
+
serverUrl: "http://localhost:1234",
|
| 103 |
+
password: "test",
|
| 104 |
+
},
|
| 105 |
+
}),
|
| 106 |
+
).rejects.toThrow("Unsupported BlueBubbles reaction");
|
| 107 |
+
});
|
| 108 |
+
|
| 109 |
+
describe("reaction type normalization", () => {
|
| 110 |
+
const testCases = [
|
| 111 |
+
{ input: "love", expected: "love" },
|
| 112 |
+
{ input: "like", expected: "like" },
|
| 113 |
+
{ input: "dislike", expected: "dislike" },
|
| 114 |
+
{ input: "laugh", expected: "laugh" },
|
| 115 |
+
{ input: "emphasize", expected: "emphasize" },
|
| 116 |
+
{ input: "question", expected: "question" },
|
| 117 |
+
{ input: "heart", expected: "love" },
|
| 118 |
+
{ input: "thumbs_up", expected: "like" },
|
| 119 |
+
{ input: "thumbs-down", expected: "dislike" },
|
| 120 |
+
{ input: "thumbs_down", expected: "dislike" },
|
| 121 |
+
{ input: "haha", expected: "laugh" },
|
| 122 |
+
{ input: "lol", expected: "laugh" },
|
| 123 |
+
{ input: "emphasis", expected: "emphasize" },
|
| 124 |
+
{ input: "exclaim", expected: "emphasize" },
|
| 125 |
+
{ input: "❤️", expected: "love" },
|
| 126 |
+
{ input: "❤", expected: "love" },
|
| 127 |
+
{ input: "♥️", expected: "love" },
|
| 128 |
+
{ input: "😍", expected: "love" },
|
| 129 |
+
{ input: "👍", expected: "like" },
|
| 130 |
+
{ input: "👎", expected: "dislike" },
|
| 131 |
+
{ input: "😂", expected: "laugh" },
|
| 132 |
+
{ input: "🤣", expected: "laugh" },
|
| 133 |
+
{ input: "😆", expected: "laugh" },
|
| 134 |
+
{ input: "‼️", expected: "emphasize" },
|
| 135 |
+
{ input: "‼", expected: "emphasize" },
|
| 136 |
+
{ input: "❗", expected: "emphasize" },
|
| 137 |
+
{ input: "❓", expected: "question" },
|
| 138 |
+
{ input: "❔", expected: "question" },
|
| 139 |
+
{ input: "LOVE", expected: "love" },
|
| 140 |
+
{ input: "Like", expected: "like" },
|
| 141 |
+
];
|
| 142 |
+
|
| 143 |
+
for (const { input, expected } of testCases) {
|
| 144 |
+
it(`normalizes "${input}" to "${expected}"`, async () => {
|
| 145 |
+
mockFetch.mockResolvedValueOnce({
|
| 146 |
+
ok: true,
|
| 147 |
+
text: () => Promise.resolve(""),
|
| 148 |
+
});
|
| 149 |
+
|
| 150 |
+
await sendBlueBubblesReaction({
|
| 151 |
+
chatGuid: "chat-123",
|
| 152 |
+
messageGuid: "msg-123",
|
| 153 |
+
emoji: input,
|
| 154 |
+
opts: {
|
| 155 |
+
serverUrl: "http://localhost:1234",
|
| 156 |
+
password: "test",
|
| 157 |
+
},
|
| 158 |
+
});
|
| 159 |
+
|
| 160 |
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
| 161 |
+
expect(body.reaction).toBe(expected);
|
| 162 |
+
});
|
| 163 |
+
}
|
| 164 |
+
});
|
| 165 |
+
|
| 166 |
+
it("sends reaction successfully", async () => {
|
| 167 |
+
mockFetch.mockResolvedValueOnce({
|
| 168 |
+
ok: true,
|
| 169 |
+
text: () => Promise.resolve(""),
|
| 170 |
+
});
|
| 171 |
+
|
| 172 |
+
await sendBlueBubblesReaction({
|
| 173 |
+
chatGuid: "iMessage;-;+15551234567",
|
| 174 |
+
messageGuid: "msg-uuid-123",
|
| 175 |
+
emoji: "love",
|
| 176 |
+
opts: {
|
| 177 |
+
serverUrl: "http://localhost:1234",
|
| 178 |
+
password: "test-password",
|
| 179 |
+
},
|
| 180 |
+
});
|
| 181 |
+
|
| 182 |
+
expect(mockFetch).toHaveBeenCalledWith(
|
| 183 |
+
expect.stringContaining("/api/v1/message/react"),
|
| 184 |
+
expect.objectContaining({
|
| 185 |
+
method: "POST",
|
| 186 |
+
headers: { "Content-Type": "application/json" },
|
| 187 |
+
}),
|
| 188 |
+
);
|
| 189 |
+
|
| 190 |
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
| 191 |
+
expect(body.chatGuid).toBe("iMessage;-;+15551234567");
|
| 192 |
+
expect(body.selectedMessageGuid).toBe("msg-uuid-123");
|
| 193 |
+
expect(body.reaction).toBe("love");
|
| 194 |
+
expect(body.partIndex).toBe(0);
|
| 195 |
+
});
|
| 196 |
+
|
| 197 |
+
it("includes password in URL query", async () => {
|
| 198 |
+
mockFetch.mockResolvedValueOnce({
|
| 199 |
+
ok: true,
|
| 200 |
+
text: () => Promise.resolve(""),
|
| 201 |
+
});
|
| 202 |
+
|
| 203 |
+
await sendBlueBubblesReaction({
|
| 204 |
+
chatGuid: "chat-123",
|
| 205 |
+
messageGuid: "msg-123",
|
| 206 |
+
emoji: "like",
|
| 207 |
+
opts: {
|
| 208 |
+
serverUrl: "http://localhost:1234",
|
| 209 |
+
password: "my-react-password",
|
| 210 |
+
},
|
| 211 |
+
});
|
| 212 |
+
|
| 213 |
+
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
| 214 |
+
expect(calledUrl).toContain("password=my-react-password");
|
| 215 |
+
});
|
| 216 |
+
|
| 217 |
+
it("sends reaction removal with dash prefix", async () => {
|
| 218 |
+
mockFetch.mockResolvedValueOnce({
|
| 219 |
+
ok: true,
|
| 220 |
+
text: () => Promise.resolve(""),
|
| 221 |
+
});
|
| 222 |
+
|
| 223 |
+
await sendBlueBubblesReaction({
|
| 224 |
+
chatGuid: "chat-123",
|
| 225 |
+
messageGuid: "msg-123",
|
| 226 |
+
emoji: "love",
|
| 227 |
+
remove: true,
|
| 228 |
+
opts: {
|
| 229 |
+
serverUrl: "http://localhost:1234",
|
| 230 |
+
password: "test",
|
| 231 |
+
},
|
| 232 |
+
});
|
| 233 |
+
|
| 234 |
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
| 235 |
+
expect(body.reaction).toBe("-love");
|
| 236 |
+
});
|
| 237 |
+
|
| 238 |
+
it("strips leading dash from emoji when remove flag is set", async () => {
|
| 239 |
+
mockFetch.mockResolvedValueOnce({
|
| 240 |
+
ok: true,
|
| 241 |
+
text: () => Promise.resolve(""),
|
| 242 |
+
});
|
| 243 |
+
|
| 244 |
+
await sendBlueBubblesReaction({
|
| 245 |
+
chatGuid: "chat-123",
|
| 246 |
+
messageGuid: "msg-123",
|
| 247 |
+
emoji: "-love",
|
| 248 |
+
remove: true,
|
| 249 |
+
opts: {
|
| 250 |
+
serverUrl: "http://localhost:1234",
|
| 251 |
+
password: "test",
|
| 252 |
+
},
|
| 253 |
+
});
|
| 254 |
+
|
| 255 |
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
| 256 |
+
expect(body.reaction).toBe("-love");
|
| 257 |
+
});
|
| 258 |
+
|
| 259 |
+
it("uses custom partIndex when provided", async () => {
|
| 260 |
+
mockFetch.mockResolvedValueOnce({
|
| 261 |
+
ok: true,
|
| 262 |
+
text: () => Promise.resolve(""),
|
| 263 |
+
});
|
| 264 |
+
|
| 265 |
+
await sendBlueBubblesReaction({
|
| 266 |
+
chatGuid: "chat-123",
|
| 267 |
+
messageGuid: "msg-123",
|
| 268 |
+
emoji: "laugh",
|
| 269 |
+
partIndex: 3,
|
| 270 |
+
opts: {
|
| 271 |
+
serverUrl: "http://localhost:1234",
|
| 272 |
+
password: "test",
|
| 273 |
+
},
|
| 274 |
+
});
|
| 275 |
+
|
| 276 |
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
| 277 |
+
expect(body.partIndex).toBe(3);
|
| 278 |
+
});
|
| 279 |
+
|
| 280 |
+
it("throws on non-ok response", async () => {
|
| 281 |
+
mockFetch.mockResolvedValueOnce({
|
| 282 |
+
ok: false,
|
| 283 |
+
status: 400,
|
| 284 |
+
text: () => Promise.resolve("Invalid reaction type"),
|
| 285 |
+
});
|
| 286 |
+
|
| 287 |
+
await expect(
|
| 288 |
+
sendBlueBubblesReaction({
|
| 289 |
+
chatGuid: "chat-123",
|
| 290 |
+
messageGuid: "msg-123",
|
| 291 |
+
emoji: "like",
|
| 292 |
+
opts: {
|
| 293 |
+
serverUrl: "http://localhost:1234",
|
| 294 |
+
password: "test",
|
| 295 |
+
},
|
| 296 |
+
}),
|
| 297 |
+
).rejects.toThrow("reaction failed (400): Invalid reaction type");
|
| 298 |
+
});
|
| 299 |
+
|
| 300 |
+
it("resolves credentials from config", async () => {
|
| 301 |
+
mockFetch.mockResolvedValueOnce({
|
| 302 |
+
ok: true,
|
| 303 |
+
text: () => Promise.resolve(""),
|
| 304 |
+
});
|
| 305 |
+
|
| 306 |
+
await sendBlueBubblesReaction({
|
| 307 |
+
chatGuid: "chat-123",
|
| 308 |
+
messageGuid: "msg-123",
|
| 309 |
+
emoji: "emphasize",
|
| 310 |
+
opts: {
|
| 311 |
+
cfg: {
|
| 312 |
+
channels: {
|
| 313 |
+
bluebubbles: {
|
| 314 |
+
serverUrl: "http://react-server:7777",
|
| 315 |
+
password: "react-pass",
|
| 316 |
+
},
|
| 317 |
+
},
|
| 318 |
+
},
|
| 319 |
+
},
|
| 320 |
+
});
|
| 321 |
+
|
| 322 |
+
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
| 323 |
+
expect(calledUrl).toContain("react-server:7777");
|
| 324 |
+
expect(calledUrl).toContain("password=react-pass");
|
| 325 |
+
});
|
| 326 |
+
|
| 327 |
+
it("trims chatGuid and messageGuid", async () => {
|
| 328 |
+
mockFetch.mockResolvedValueOnce({
|
| 329 |
+
ok: true,
|
| 330 |
+
text: () => Promise.resolve(""),
|
| 331 |
+
});
|
| 332 |
+
|
| 333 |
+
await sendBlueBubblesReaction({
|
| 334 |
+
chatGuid: " chat-with-spaces ",
|
| 335 |
+
messageGuid: " msg-with-spaces ",
|
| 336 |
+
emoji: "question",
|
| 337 |
+
opts: {
|
| 338 |
+
serverUrl: "http://localhost:1234",
|
| 339 |
+
password: "test",
|
| 340 |
+
},
|
| 341 |
+
});
|
| 342 |
+
|
| 343 |
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
| 344 |
+
expect(body.chatGuid).toBe("chat-with-spaces");
|
| 345 |
+
expect(body.selectedMessageGuid).toBe("msg-with-spaces");
|
| 346 |
+
});
|
| 347 |
+
|
| 348 |
+
describe("reaction removal aliases", () => {
|
| 349 |
+
it("handles emoji-based removal", async () => {
|
| 350 |
+
mockFetch.mockResolvedValueOnce({
|
| 351 |
+
ok: true,
|
| 352 |
+
text: () => Promise.resolve(""),
|
| 353 |
+
});
|
| 354 |
+
|
| 355 |
+
await sendBlueBubblesReaction({
|
| 356 |
+
chatGuid: "chat-123",
|
| 357 |
+
messageGuid: "msg-123",
|
| 358 |
+
emoji: "👍",
|
| 359 |
+
remove: true,
|
| 360 |
+
opts: {
|
| 361 |
+
serverUrl: "http://localhost:1234",
|
| 362 |
+
password: "test",
|
| 363 |
+
},
|
| 364 |
+
});
|
| 365 |
+
|
| 366 |
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
| 367 |
+
expect(body.reaction).toBe("-like");
|
| 368 |
+
});
|
| 369 |
+
|
| 370 |
+
it("handles text alias removal", async () => {
|
| 371 |
+
mockFetch.mockResolvedValueOnce({
|
| 372 |
+
ok: true,
|
| 373 |
+
text: () => Promise.resolve(""),
|
| 374 |
+
});
|
| 375 |
+
|
| 376 |
+
await sendBlueBubblesReaction({
|
| 377 |
+
chatGuid: "chat-123",
|
| 378 |
+
messageGuid: "msg-123",
|
| 379 |
+
emoji: "haha",
|
| 380 |
+
remove: true,
|
| 381 |
+
opts: {
|
| 382 |
+
serverUrl: "http://localhost:1234",
|
| 383 |
+
password: "test",
|
| 384 |
+
},
|
| 385 |
+
});
|
| 386 |
+
|
| 387 |
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
| 388 |
+
expect(body.reaction).toBe("-laugh");
|
| 389 |
+
});
|
| 390 |
+
});
|
| 391 |
+
});
|
| 392 |
+
});
|
extensions/bluebubbles/src/reactions.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
| 2 |
+
import { resolveBlueBubblesAccount } from "./accounts.js";
|
| 3 |
+
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
| 4 |
+
|
| 5 |
+
export type BlueBubblesReactionOpts = {
|
| 6 |
+
serverUrl?: string;
|
| 7 |
+
password?: string;
|
| 8 |
+
accountId?: string;
|
| 9 |
+
timeoutMs?: number;
|
| 10 |
+
cfg?: OpenClawConfig;
|
| 11 |
+
};
|
| 12 |
+
|
| 13 |
+
const REACTION_TYPES = new Set(["love", "like", "dislike", "laugh", "emphasize", "question"]);
|
| 14 |
+
|
| 15 |
+
const REACTION_ALIASES = new Map<string, string>([
|
| 16 |
+
// General
|
| 17 |
+
["heart", "love"],
|
| 18 |
+
["love", "love"],
|
| 19 |
+
["❤", "love"],
|
| 20 |
+
["❤️", "love"],
|
| 21 |
+
["red_heart", "love"],
|
| 22 |
+
["thumbs_up", "like"],
|
| 23 |
+
["thumbsup", "like"],
|
| 24 |
+
["thumbs-up", "like"],
|
| 25 |
+
["thumbsup", "like"],
|
| 26 |
+
["like", "like"],
|
| 27 |
+
["thumb", "like"],
|
| 28 |
+
["ok", "like"],
|
| 29 |
+
["thumbs_down", "dislike"],
|
| 30 |
+
["thumbsdown", "dislike"],
|
| 31 |
+
["thumbs-down", "dislike"],
|
| 32 |
+
["dislike", "dislike"],
|
| 33 |
+
["boo", "dislike"],
|
| 34 |
+
["no", "dislike"],
|
| 35 |
+
// Laugh
|
| 36 |
+
["haha", "laugh"],
|
| 37 |
+
["lol", "laugh"],
|
| 38 |
+
["lmao", "laugh"],
|
| 39 |
+
["rofl", "laugh"],
|
| 40 |
+
["😂", "laugh"],
|
| 41 |
+
["🤣", "laugh"],
|
| 42 |
+
["xd", "laugh"],
|
| 43 |
+
["laugh", "laugh"],
|
| 44 |
+
// Emphasize / exclaim
|
| 45 |
+
["emphasis", "emphasize"],
|
| 46 |
+
["emphasize", "emphasize"],
|
| 47 |
+
["exclaim", "emphasize"],
|
| 48 |
+
["!!", "emphasize"],
|
| 49 |
+
["‼", "emphasize"],
|
| 50 |
+
["‼️", "emphasize"],
|
| 51 |
+
["❗", "emphasize"],
|
| 52 |
+
["important", "emphasize"],
|
| 53 |
+
["bang", "emphasize"],
|
| 54 |
+
// Question
|
| 55 |
+
["question", "question"],
|
| 56 |
+
["?", "question"],
|
| 57 |
+
["❓", "question"],
|
| 58 |
+
["❔", "question"],
|
| 59 |
+
["ask", "question"],
|
| 60 |
+
// Apple/Messages names
|
| 61 |
+
["loved", "love"],
|
| 62 |
+
["liked", "like"],
|
| 63 |
+
["disliked", "dislike"],
|
| 64 |
+
["laughed", "laugh"],
|
| 65 |
+
["emphasized", "emphasize"],
|
| 66 |
+
["questioned", "question"],
|
| 67 |
+
// Colloquial / informal
|
| 68 |
+
["fire", "love"],
|
| 69 |
+
["🔥", "love"],
|
| 70 |
+
["wow", "emphasize"],
|
| 71 |
+
["!", "emphasize"],
|
| 72 |
+
// Edge: generic emoji name forms
|
| 73 |
+
["heart_eyes", "love"],
|
| 74 |
+
["smile", "laugh"],
|
| 75 |
+
["smiley", "laugh"],
|
| 76 |
+
["happy", "laugh"],
|
| 77 |
+
["joy", "laugh"],
|
| 78 |
+
]);
|
| 79 |
+
|
| 80 |
+
const REACTION_EMOJIS = new Map<string, string>([
|
| 81 |
+
// Love
|
| 82 |
+
["❤️", "love"],
|
| 83 |
+
["❤", "love"],
|
| 84 |
+
["♥️", "love"],
|
| 85 |
+
["♥", "love"],
|
| 86 |
+
["😍", "love"],
|
| 87 |
+
["💕", "love"],
|
| 88 |
+
// Like
|
| 89 |
+
["👍", "like"],
|
| 90 |
+
["👌", "like"],
|
| 91 |
+
// Dislike
|
| 92 |
+
["👎", "dislike"],
|
| 93 |
+
["🙅", "dislike"],
|
| 94 |
+
// Laugh
|
| 95 |
+
["😂", "laugh"],
|
| 96 |
+
["🤣", "laugh"],
|
| 97 |
+
["😆", "laugh"],
|
| 98 |
+
["😁", "laugh"],
|
| 99 |
+
["😹", "laugh"],
|
| 100 |
+
// Emphasize
|
| 101 |
+
["‼️", "emphasize"],
|
| 102 |
+
["‼", "emphasize"],
|
| 103 |
+
["!!", "emphasize"],
|
| 104 |
+
["❗", "emphasize"],
|
| 105 |
+
["❕", "emphasize"],
|
| 106 |
+
["!", "emphasize"],
|
| 107 |
+
// Question
|
| 108 |
+
["❓", "question"],
|
| 109 |
+
["❔", "question"],
|
| 110 |
+
["?", "question"],
|
| 111 |
+
]);
|
| 112 |
+
|
| 113 |
+
function resolveAccount(params: BlueBubblesReactionOpts) {
|
| 114 |
+
const account = resolveBlueBubblesAccount({
|
| 115 |
+
cfg: params.cfg ?? {},
|
| 116 |
+
accountId: params.accountId,
|
| 117 |
+
});
|
| 118 |
+
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
|
| 119 |
+
const password = params.password?.trim() || account.config.password?.trim();
|
| 120 |
+
if (!baseUrl) {
|
| 121 |
+
throw new Error("BlueBubbles serverUrl is required");
|
| 122 |
+
}
|
| 123 |
+
if (!password) {
|
| 124 |
+
throw new Error("BlueBubbles password is required");
|
| 125 |
+
}
|
| 126 |
+
return { baseUrl, password };
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string {
|
| 130 |
+
const trimmed = emoji.trim();
|
| 131 |
+
if (!trimmed) {
|
| 132 |
+
throw new Error("BlueBubbles reaction requires an emoji or name.");
|
| 133 |
+
}
|
| 134 |
+
let raw = trimmed.toLowerCase();
|
| 135 |
+
if (raw.startsWith("-")) {
|
| 136 |
+
raw = raw.slice(1);
|
| 137 |
+
}
|
| 138 |
+
const aliased = REACTION_ALIASES.get(raw) ?? raw;
|
| 139 |
+
const mapped = REACTION_EMOJIS.get(trimmed) ?? REACTION_EMOJIS.get(raw) ?? aliased;
|
| 140 |
+
if (!REACTION_TYPES.has(mapped)) {
|
| 141 |
+
throw new Error(`Unsupported BlueBubbles reaction: ${trimmed}`);
|
| 142 |
+
}
|
| 143 |
+
return remove ? `-${mapped}` : mapped;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
export async function sendBlueBubblesReaction(params: {
|
| 147 |
+
chatGuid: string;
|
| 148 |
+
messageGuid: string;
|
| 149 |
+
emoji: string;
|
| 150 |
+
remove?: boolean;
|
| 151 |
+
partIndex?: number;
|
| 152 |
+
opts?: BlueBubblesReactionOpts;
|
| 153 |
+
}): Promise<void> {
|
| 154 |
+
const chatGuid = params.chatGuid.trim();
|
| 155 |
+
const messageGuid = params.messageGuid.trim();
|
| 156 |
+
if (!chatGuid) {
|
| 157 |
+
throw new Error("BlueBubbles reaction requires chatGuid.");
|
| 158 |
+
}
|
| 159 |
+
if (!messageGuid) {
|
| 160 |
+
throw new Error("BlueBubbles reaction requires messageGuid.");
|
| 161 |
+
}
|
| 162 |
+
const reaction = normalizeBlueBubblesReactionInput(params.emoji, params.remove);
|
| 163 |
+
const { baseUrl, password } = resolveAccount(params.opts ?? {});
|
| 164 |
+
const url = buildBlueBubblesApiUrl({
|
| 165 |
+
baseUrl,
|
| 166 |
+
path: "/api/v1/message/react",
|
| 167 |
+
password,
|
| 168 |
+
});
|
| 169 |
+
const payload = {
|
| 170 |
+
chatGuid,
|
| 171 |
+
selectedMessageGuid: messageGuid,
|
| 172 |
+
reaction,
|
| 173 |
+
partIndex: typeof params.partIndex === "number" ? params.partIndex : 0,
|
| 174 |
+
};
|
| 175 |
+
const res = await blueBubblesFetchWithTimeout(
|
| 176 |
+
url,
|
| 177 |
+
{
|
| 178 |
+
method: "POST",
|
| 179 |
+
headers: { "Content-Type": "application/json" },
|
| 180 |
+
body: JSON.stringify(payload),
|
| 181 |
+
},
|
| 182 |
+
params.opts?.timeoutMs,
|
| 183 |
+
);
|
| 184 |
+
if (!res.ok) {
|
| 185 |
+
const errorText = await res.text();
|
| 186 |
+
throw new Error(`BlueBubbles reaction failed (${res.status}): ${errorText || "unknown"}`);
|
| 187 |
+
}
|
| 188 |
+
}
|
extensions/bluebubbles/src/runtime.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
| 2 |
+
|
| 3 |
+
let runtime: PluginRuntime | null = null;
|
| 4 |
+
|
| 5 |
+
export function setBlueBubblesRuntime(next: PluginRuntime): void {
|
| 6 |
+
runtime = next;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export function getBlueBubblesRuntime(): PluginRuntime {
|
| 10 |
+
if (!runtime) {
|
| 11 |
+
throw new Error("BlueBubbles runtime not initialized");
|
| 12 |
+
}
|
| 13 |
+
return runtime;
|
| 14 |
+
}
|
extensions/bluebubbles/src/send.test.ts
ADDED
|
@@ -0,0 +1,808 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
| 2 |
+
import type { BlueBubblesSendTarget } from "./types.js";
|
| 3 |
+
import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js";
|
| 4 |
+
|
| 5 |
+
vi.mock("./accounts.js", () => ({
|
| 6 |
+
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
|
| 7 |
+
const config = cfg?.channels?.bluebubbles ?? {};
|
| 8 |
+
return {
|
| 9 |
+
accountId: accountId ?? "default",
|
| 10 |
+
enabled: config.enabled !== false,
|
| 11 |
+
configured: Boolean(config.serverUrl && config.password),
|
| 12 |
+
config,
|
| 13 |
+
};
|
| 14 |
+
}),
|
| 15 |
+
}));
|
| 16 |
+
|
| 17 |
+
const mockFetch = vi.fn();
|
| 18 |
+
|
| 19 |
+
describe("send", () => {
|
| 20 |
+
beforeEach(() => {
|
| 21 |
+
vi.stubGlobal("fetch", mockFetch);
|
| 22 |
+
mockFetch.mockReset();
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
afterEach(() => {
|
| 26 |
+
vi.unstubAllGlobals();
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
describe("resolveChatGuidForTarget", () => {
|
| 30 |
+
it("returns chatGuid directly for chat_guid target", async () => {
|
| 31 |
+
const target: BlueBubblesSendTarget = {
|
| 32 |
+
kind: "chat_guid",
|
| 33 |
+
chatGuid: "iMessage;-;+15551234567",
|
| 34 |
+
};
|
| 35 |
+
const result = await resolveChatGuidForTarget({
|
| 36 |
+
baseUrl: "http://localhost:1234",
|
| 37 |
+
password: "test",
|
| 38 |
+
target,
|
| 39 |
+
});
|
| 40 |
+
expect(result).toBe("iMessage;-;+15551234567");
|
| 41 |
+
expect(mockFetch).not.toHaveBeenCalled();
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
it("queries chats to resolve chat_id target", async () => {
|
| 45 |
+
mockFetch.mockResolvedValueOnce({
|
| 46 |
+
ok: true,
|
| 47 |
+
json: () =>
|
| 48 |
+
Promise.resolve({
|
| 49 |
+
data: [
|
| 50 |
+
{ id: 123, guid: "iMessage;-;chat123", participants: [] },
|
| 51 |
+
{ id: 456, guid: "iMessage;-;chat456", participants: [] },
|
| 52 |
+
],
|
| 53 |
+
}),
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 456 };
|
| 57 |
+
const result = await resolveChatGuidForTarget({
|
| 58 |
+
baseUrl: "http://localhost:1234",
|
| 59 |
+
password: "test",
|
| 60 |
+
target,
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
expect(result).toBe("iMessage;-;chat456");
|
| 64 |
+
expect(mockFetch).toHaveBeenCalledWith(
|
| 65 |
+
expect.stringContaining("/api/v1/chat/query"),
|
| 66 |
+
expect.objectContaining({ method: "POST" }),
|
| 67 |
+
);
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
it("queries chats to resolve chat_identifier target", async () => {
|
| 71 |
+
mockFetch.mockResolvedValueOnce({
|
| 72 |
+
ok: true,
|
| 73 |
+
json: () =>
|
| 74 |
+
Promise.resolve({
|
| 75 |
+
data: [
|
| 76 |
+
{
|
| 77 |
+
identifier: "chat123@group.imessage",
|
| 78 |
+
guid: "iMessage;-;chat123",
|
| 79 |
+
participants: [],
|
| 80 |
+
},
|
| 81 |
+
],
|
| 82 |
+
}),
|
| 83 |
+
});
|
| 84 |
+
|
| 85 |
+
const target: BlueBubblesSendTarget = {
|
| 86 |
+
kind: "chat_identifier",
|
| 87 |
+
chatIdentifier: "chat123@group.imessage",
|
| 88 |
+
};
|
| 89 |
+
const result = await resolveChatGuidForTarget({
|
| 90 |
+
baseUrl: "http://localhost:1234",
|
| 91 |
+
password: "test",
|
| 92 |
+
target,
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
expect(result).toBe("iMessage;-;chat123");
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
it("matches chat_identifier against the 3rd component of chat GUID", async () => {
|
| 99 |
+
mockFetch.mockResolvedValueOnce({
|
| 100 |
+
ok: true,
|
| 101 |
+
json: () =>
|
| 102 |
+
Promise.resolve({
|
| 103 |
+
data: [
|
| 104 |
+
{
|
| 105 |
+
guid: "iMessage;+;chat660250192681427962",
|
| 106 |
+
participants: [],
|
| 107 |
+
},
|
| 108 |
+
],
|
| 109 |
+
}),
|
| 110 |
+
});
|
| 111 |
+
|
| 112 |
+
const target: BlueBubblesSendTarget = {
|
| 113 |
+
kind: "chat_identifier",
|
| 114 |
+
chatIdentifier: "chat660250192681427962",
|
| 115 |
+
};
|
| 116 |
+
const result = await resolveChatGuidForTarget({
|
| 117 |
+
baseUrl: "http://localhost:1234",
|
| 118 |
+
password: "test",
|
| 119 |
+
target,
|
| 120 |
+
});
|
| 121 |
+
|
| 122 |
+
expect(result).toBe("iMessage;+;chat660250192681427962");
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
it("resolves handle target by matching participant", async () => {
|
| 126 |
+
mockFetch.mockResolvedValueOnce({
|
| 127 |
+
ok: true,
|
| 128 |
+
json: () =>
|
| 129 |
+
Promise.resolve({
|
| 130 |
+
data: [
|
| 131 |
+
{
|
| 132 |
+
guid: "iMessage;-;+15559999999",
|
| 133 |
+
participants: [{ address: "+15559999999" }],
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
guid: "iMessage;-;+15551234567",
|
| 137 |
+
participants: [{ address: "+15551234567" }],
|
| 138 |
+
},
|
| 139 |
+
],
|
| 140 |
+
}),
|
| 141 |
+
});
|
| 142 |
+
|
| 143 |
+
const target: BlueBubblesSendTarget = {
|
| 144 |
+
kind: "handle",
|
| 145 |
+
address: "+15551234567",
|
| 146 |
+
service: "imessage",
|
| 147 |
+
};
|
| 148 |
+
const result = await resolveChatGuidForTarget({
|
| 149 |
+
baseUrl: "http://localhost:1234",
|
| 150 |
+
password: "test",
|
| 151 |
+
target,
|
| 152 |
+
});
|
| 153 |
+
|
| 154 |
+
expect(result).toBe("iMessage;-;+15551234567");
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
it("prefers direct chat guid when handle also appears in a group chat", async () => {
|
| 158 |
+
mockFetch.mockResolvedValueOnce({
|
| 159 |
+
ok: true,
|
| 160 |
+
json: () =>
|
| 161 |
+
Promise.resolve({
|
| 162 |
+
data: [
|
| 163 |
+
{
|
| 164 |
+
guid: "iMessage;+;group-123",
|
| 165 |
+
participants: [{ address: "+15551234567" }, { address: "+15550001111" }],
|
| 166 |
+
},
|
| 167 |
+
{
|
| 168 |
+
guid: "iMessage;-;+15551234567",
|
| 169 |
+
participants: [{ address: "+15551234567" }],
|
| 170 |
+
},
|
| 171 |
+
],
|
| 172 |
+
}),
|
| 173 |
+
});
|
| 174 |
+
|
| 175 |
+
const target: BlueBubblesSendTarget = {
|
| 176 |
+
kind: "handle",
|
| 177 |
+
address: "+15551234567",
|
| 178 |
+
service: "imessage",
|
| 179 |
+
};
|
| 180 |
+
const result = await resolveChatGuidForTarget({
|
| 181 |
+
baseUrl: "http://localhost:1234",
|
| 182 |
+
password: "test",
|
| 183 |
+
target,
|
| 184 |
+
});
|
| 185 |
+
|
| 186 |
+
expect(result).toBe("iMessage;-;+15551234567");
|
| 187 |
+
});
|
| 188 |
+
|
| 189 |
+
it("returns null when handle only exists in group chat (not DM)", async () => {
|
| 190 |
+
// This is the critical fix: if a phone number only exists as a participant in a group chat
|
| 191 |
+
// (no direct DM chat), we should NOT send to that group. Return null instead.
|
| 192 |
+
mockFetch
|
| 193 |
+
.mockResolvedValueOnce({
|
| 194 |
+
ok: true,
|
| 195 |
+
json: () =>
|
| 196 |
+
Promise.resolve({
|
| 197 |
+
data: [
|
| 198 |
+
{
|
| 199 |
+
guid: "iMessage;+;group-the-council",
|
| 200 |
+
participants: [
|
| 201 |
+
{ address: "+12622102921" },
|
| 202 |
+
{ address: "+15550001111" },
|
| 203 |
+
{ address: "+15550002222" },
|
| 204 |
+
],
|
| 205 |
+
},
|
| 206 |
+
],
|
| 207 |
+
}),
|
| 208 |
+
})
|
| 209 |
+
// Empty second page to stop pagination
|
| 210 |
+
.mockResolvedValueOnce({
|
| 211 |
+
ok: true,
|
| 212 |
+
json: () => Promise.resolve({ data: [] }),
|
| 213 |
+
});
|
| 214 |
+
|
| 215 |
+
const target: BlueBubblesSendTarget = {
|
| 216 |
+
kind: "handle",
|
| 217 |
+
address: "+12622102921",
|
| 218 |
+
service: "imessage",
|
| 219 |
+
};
|
| 220 |
+
const result = await resolveChatGuidForTarget({
|
| 221 |
+
baseUrl: "http://localhost:1234",
|
| 222 |
+
password: "test",
|
| 223 |
+
target,
|
| 224 |
+
});
|
| 225 |
+
|
| 226 |
+
// Should return null, NOT the group chat GUID
|
| 227 |
+
expect(result).toBeNull();
|
| 228 |
+
});
|
| 229 |
+
|
| 230 |
+
it("returns null when chat not found", async () => {
|
| 231 |
+
mockFetch.mockResolvedValueOnce({
|
| 232 |
+
ok: true,
|
| 233 |
+
json: () => Promise.resolve({ data: [] }),
|
| 234 |
+
});
|
| 235 |
+
|
| 236 |
+
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 999 };
|
| 237 |
+
const result = await resolveChatGuidForTarget({
|
| 238 |
+
baseUrl: "http://localhost:1234",
|
| 239 |
+
password: "test",
|
| 240 |
+
target,
|
| 241 |
+
});
|
| 242 |
+
|
| 243 |
+
expect(result).toBeNull();
|
| 244 |
+
});
|
| 245 |
+
|
| 246 |
+
it("handles API error gracefully", async () => {
|
| 247 |
+
mockFetch.mockResolvedValueOnce({
|
| 248 |
+
ok: false,
|
| 249 |
+
status: 500,
|
| 250 |
+
});
|
| 251 |
+
|
| 252 |
+
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 123 };
|
| 253 |
+
const result = await resolveChatGuidForTarget({
|
| 254 |
+
baseUrl: "http://localhost:1234",
|
| 255 |
+
password: "test",
|
| 256 |
+
target,
|
| 257 |
+
});
|
| 258 |
+
|
| 259 |
+
expect(result).toBeNull();
|
| 260 |
+
});
|
| 261 |
+
|
| 262 |
+
it("paginates through chats to find match", async () => {
|
| 263 |
+
mockFetch
|
| 264 |
+
.mockResolvedValueOnce({
|
| 265 |
+
ok: true,
|
| 266 |
+
json: () =>
|
| 267 |
+
Promise.resolve({
|
| 268 |
+
data: Array(500)
|
| 269 |
+
.fill(null)
|
| 270 |
+
.map((_, i) => ({
|
| 271 |
+
id: i,
|
| 272 |
+
guid: `chat-${i}`,
|
| 273 |
+
participants: [],
|
| 274 |
+
})),
|
| 275 |
+
}),
|
| 276 |
+
})
|
| 277 |
+
.mockResolvedValueOnce({
|
| 278 |
+
ok: true,
|
| 279 |
+
json: () =>
|
| 280 |
+
Promise.resolve({
|
| 281 |
+
data: [{ id: 555, guid: "found-chat", participants: [] }],
|
| 282 |
+
}),
|
| 283 |
+
});
|
| 284 |
+
|
| 285 |
+
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 555 };
|
| 286 |
+
const result = await resolveChatGuidForTarget({
|
| 287 |
+
baseUrl: "http://localhost:1234",
|
| 288 |
+
password: "test",
|
| 289 |
+
target,
|
| 290 |
+
});
|
| 291 |
+
|
| 292 |
+
expect(result).toBe("found-chat");
|
| 293 |
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
| 294 |
+
});
|
| 295 |
+
|
| 296 |
+
it("normalizes handle addresses for matching", async () => {
|
| 297 |
+
mockFetch.mockResolvedValueOnce({
|
| 298 |
+
ok: true,
|
| 299 |
+
json: () =>
|
| 300 |
+
Promise.resolve({
|
| 301 |
+
data: [
|
| 302 |
+
{
|
| 303 |
+
guid: "iMessage;-;test@example.com",
|
| 304 |
+
participants: [{ address: "Test@Example.COM" }],
|
| 305 |
+
},
|
| 306 |
+
],
|
| 307 |
+
}),
|
| 308 |
+
});
|
| 309 |
+
|
| 310 |
+
const target: BlueBubblesSendTarget = {
|
| 311 |
+
kind: "handle",
|
| 312 |
+
address: "test@example.com",
|
| 313 |
+
service: "auto",
|
| 314 |
+
};
|
| 315 |
+
const result = await resolveChatGuidForTarget({
|
| 316 |
+
baseUrl: "http://localhost:1234",
|
| 317 |
+
password: "test",
|
| 318 |
+
target,
|
| 319 |
+
});
|
| 320 |
+
|
| 321 |
+
expect(result).toBe("iMessage;-;test@example.com");
|
| 322 |
+
});
|
| 323 |
+
|
| 324 |
+
it("extracts guid from various response formats", async () => {
|
| 325 |
+
mockFetch.mockResolvedValueOnce({
|
| 326 |
+
ok: true,
|
| 327 |
+
json: () =>
|
| 328 |
+
Promise.resolve({
|
| 329 |
+
data: [
|
| 330 |
+
{
|
| 331 |
+
chatGuid: "format1-guid",
|
| 332 |
+
id: 100,
|
| 333 |
+
participants: [],
|
| 334 |
+
},
|
| 335 |
+
],
|
| 336 |
+
}),
|
| 337 |
+
});
|
| 338 |
+
|
| 339 |
+
const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 100 };
|
| 340 |
+
const result = await resolveChatGuidForTarget({
|
| 341 |
+
baseUrl: "http://localhost:1234",
|
| 342 |
+
password: "test",
|
| 343 |
+
target,
|
| 344 |
+
});
|
| 345 |
+
|
| 346 |
+
expect(result).toBe("format1-guid");
|
| 347 |
+
});
|
| 348 |
+
});
|
| 349 |
+
|
| 350 |
+
describe("sendMessageBlueBubbles", () => {
|
| 351 |
+
beforeEach(() => {
|
| 352 |
+
mockFetch.mockReset();
|
| 353 |
+
});
|
| 354 |
+
|
| 355 |
+
it("throws when text is empty", async () => {
|
| 356 |
+
await expect(
|
| 357 |
+
sendMessageBlueBubbles("+15551234567", "", {
|
| 358 |
+
serverUrl: "http://localhost:1234",
|
| 359 |
+
password: "test",
|
| 360 |
+
}),
|
| 361 |
+
).rejects.toThrow("requires text");
|
| 362 |
+
});
|
| 363 |
+
|
| 364 |
+
it("throws when text is whitespace only", async () => {
|
| 365 |
+
await expect(
|
| 366 |
+
sendMessageBlueBubbles("+15551234567", " ", {
|
| 367 |
+
serverUrl: "http://localhost:1234",
|
| 368 |
+
password: "test",
|
| 369 |
+
}),
|
| 370 |
+
).rejects.toThrow("requires text");
|
| 371 |
+
});
|
| 372 |
+
|
| 373 |
+
it("throws when serverUrl is missing", async () => {
|
| 374 |
+
await expect(sendMessageBlueBubbles("+15551234567", "Hello", {})).rejects.toThrow(
|
| 375 |
+
"serverUrl is required",
|
| 376 |
+
);
|
| 377 |
+
});
|
| 378 |
+
|
| 379 |
+
it("throws when password is missing", async () => {
|
| 380 |
+
await expect(
|
| 381 |
+
sendMessageBlueBubbles("+15551234567", "Hello", {
|
| 382 |
+
serverUrl: "http://localhost:1234",
|
| 383 |
+
}),
|
| 384 |
+
).rejects.toThrow("password is required");
|
| 385 |
+
});
|
| 386 |
+
|
| 387 |
+
it("throws when chatGuid cannot be resolved for non-handle targets", async () => {
|
| 388 |
+
mockFetch.mockResolvedValue({
|
| 389 |
+
ok: true,
|
| 390 |
+
json: () => Promise.resolve({ data: [] }),
|
| 391 |
+
});
|
| 392 |
+
|
| 393 |
+
await expect(
|
| 394 |
+
sendMessageBlueBubbles("chat_id:999", "Hello", {
|
| 395 |
+
serverUrl: "http://localhost:1234",
|
| 396 |
+
password: "test",
|
| 397 |
+
}),
|
| 398 |
+
).rejects.toThrow("chatGuid not found");
|
| 399 |
+
});
|
| 400 |
+
|
| 401 |
+
it("sends message successfully", async () => {
|
| 402 |
+
mockFetch
|
| 403 |
+
.mockResolvedValueOnce({
|
| 404 |
+
ok: true,
|
| 405 |
+
json: () =>
|
| 406 |
+
Promise.resolve({
|
| 407 |
+
data: [
|
| 408 |
+
{
|
| 409 |
+
guid: "iMessage;-;+15551234567",
|
| 410 |
+
participants: [{ address: "+15551234567" }],
|
| 411 |
+
},
|
| 412 |
+
],
|
| 413 |
+
}),
|
| 414 |
+
})
|
| 415 |
+
.mockResolvedValueOnce({
|
| 416 |
+
ok: true,
|
| 417 |
+
text: () =>
|
| 418 |
+
Promise.resolve(
|
| 419 |
+
JSON.stringify({
|
| 420 |
+
data: { guid: "msg-uuid-123" },
|
| 421 |
+
}),
|
| 422 |
+
),
|
| 423 |
+
});
|
| 424 |
+
|
| 425 |
+
const result = await sendMessageBlueBubbles("+15551234567", "Hello world!", {
|
| 426 |
+
serverUrl: "http://localhost:1234",
|
| 427 |
+
password: "test",
|
| 428 |
+
});
|
| 429 |
+
|
| 430 |
+
expect(result.messageId).toBe("msg-uuid-123");
|
| 431 |
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
| 432 |
+
|
| 433 |
+
const sendCall = mockFetch.mock.calls[1];
|
| 434 |
+
expect(sendCall[0]).toContain("/api/v1/message/text");
|
| 435 |
+
const body = JSON.parse(sendCall[1].body);
|
| 436 |
+
expect(body.chatGuid).toBe("iMessage;-;+15551234567");
|
| 437 |
+
expect(body.message).toBe("Hello world!");
|
| 438 |
+
expect(body.method).toBeUndefined();
|
| 439 |
+
});
|
| 440 |
+
|
| 441 |
+
it("creates a new chat when handle target is missing", async () => {
|
| 442 |
+
mockFetch
|
| 443 |
+
.mockResolvedValueOnce({
|
| 444 |
+
ok: true,
|
| 445 |
+
json: () => Promise.resolve({ data: [] }),
|
| 446 |
+
})
|
| 447 |
+
.mockResolvedValueOnce({
|
| 448 |
+
ok: true,
|
| 449 |
+
text: () =>
|
| 450 |
+
Promise.resolve(
|
| 451 |
+
JSON.stringify({
|
| 452 |
+
data: { guid: "new-msg-guid" },
|
| 453 |
+
}),
|
| 454 |
+
),
|
| 455 |
+
});
|
| 456 |
+
|
| 457 |
+
const result = await sendMessageBlueBubbles("+15550009999", "Hello new chat", {
|
| 458 |
+
serverUrl: "http://localhost:1234",
|
| 459 |
+
password: "test",
|
| 460 |
+
});
|
| 461 |
+
|
| 462 |
+
expect(result.messageId).toBe("new-msg-guid");
|
| 463 |
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
| 464 |
+
|
| 465 |
+
const createCall = mockFetch.mock.calls[1];
|
| 466 |
+
expect(createCall[0]).toContain("/api/v1/chat/new");
|
| 467 |
+
const body = JSON.parse(createCall[1].body);
|
| 468 |
+
expect(body.addresses).toEqual(["+15550009999"]);
|
| 469 |
+
expect(body.message).toBe("Hello new chat");
|
| 470 |
+
});
|
| 471 |
+
|
| 472 |
+
it("throws when creating a new chat requires Private API", async () => {
|
| 473 |
+
mockFetch
|
| 474 |
+
.mockResolvedValueOnce({
|
| 475 |
+
ok: true,
|
| 476 |
+
json: () => Promise.resolve({ data: [] }),
|
| 477 |
+
})
|
| 478 |
+
.mockResolvedValueOnce({
|
| 479 |
+
ok: false,
|
| 480 |
+
status: 403,
|
| 481 |
+
text: () => Promise.resolve("Private API not enabled"),
|
| 482 |
+
});
|
| 483 |
+
|
| 484 |
+
await expect(
|
| 485 |
+
sendMessageBlueBubbles("+15550008888", "Hello", {
|
| 486 |
+
serverUrl: "http://localhost:1234",
|
| 487 |
+
password: "test",
|
| 488 |
+
}),
|
| 489 |
+
).rejects.toThrow("Private API must be enabled");
|
| 490 |
+
});
|
| 491 |
+
|
| 492 |
+
it("uses private-api when reply metadata is present", async () => {
|
| 493 |
+
mockFetch
|
| 494 |
+
.mockResolvedValueOnce({
|
| 495 |
+
ok: true,
|
| 496 |
+
json: () =>
|
| 497 |
+
Promise.resolve({
|
| 498 |
+
data: [
|
| 499 |
+
{
|
| 500 |
+
guid: "iMessage;-;+15551234567",
|
| 501 |
+
participants: [{ address: "+15551234567" }],
|
| 502 |
+
},
|
| 503 |
+
],
|
| 504 |
+
}),
|
| 505 |
+
})
|
| 506 |
+
.mockResolvedValueOnce({
|
| 507 |
+
ok: true,
|
| 508 |
+
text: () =>
|
| 509 |
+
Promise.resolve(
|
| 510 |
+
JSON.stringify({
|
| 511 |
+
data: { guid: "msg-uuid-124" },
|
| 512 |
+
}),
|
| 513 |
+
),
|
| 514 |
+
});
|
| 515 |
+
|
| 516 |
+
const result = await sendMessageBlueBubbles("+15551234567", "Replying", {
|
| 517 |
+
serverUrl: "http://localhost:1234",
|
| 518 |
+
password: "test",
|
| 519 |
+
replyToMessageGuid: "reply-guid-123",
|
| 520 |
+
replyToPartIndex: 1,
|
| 521 |
+
});
|
| 522 |
+
|
| 523 |
+
expect(result.messageId).toBe("msg-uuid-124");
|
| 524 |
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
| 525 |
+
|
| 526 |
+
const sendCall = mockFetch.mock.calls[1];
|
| 527 |
+
const body = JSON.parse(sendCall[1].body);
|
| 528 |
+
expect(body.method).toBe("private-api");
|
| 529 |
+
expect(body.selectedMessageGuid).toBe("reply-guid-123");
|
| 530 |
+
expect(body.partIndex).toBe(1);
|
| 531 |
+
});
|
| 532 |
+
|
| 533 |
+
it("normalizes effect names and uses private-api for effects", async () => {
|
| 534 |
+
mockFetch
|
| 535 |
+
.mockResolvedValueOnce({
|
| 536 |
+
ok: true,
|
| 537 |
+
json: () =>
|
| 538 |
+
Promise.resolve({
|
| 539 |
+
data: [
|
| 540 |
+
{
|
| 541 |
+
guid: "iMessage;-;+15551234567",
|
| 542 |
+
participants: [{ address: "+15551234567" }],
|
| 543 |
+
},
|
| 544 |
+
],
|
| 545 |
+
}),
|
| 546 |
+
})
|
| 547 |
+
.mockResolvedValueOnce({
|
| 548 |
+
ok: true,
|
| 549 |
+
text: () =>
|
| 550 |
+
Promise.resolve(
|
| 551 |
+
JSON.stringify({
|
| 552 |
+
data: { guid: "msg-uuid-125" },
|
| 553 |
+
}),
|
| 554 |
+
),
|
| 555 |
+
});
|
| 556 |
+
|
| 557 |
+
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
| 558 |
+
serverUrl: "http://localhost:1234",
|
| 559 |
+
password: "test",
|
| 560 |
+
effectId: "invisible ink",
|
| 561 |
+
});
|
| 562 |
+
|
| 563 |
+
expect(result.messageId).toBe("msg-uuid-125");
|
| 564 |
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
| 565 |
+
|
| 566 |
+
const sendCall = mockFetch.mock.calls[1];
|
| 567 |
+
const body = JSON.parse(sendCall[1].body);
|
| 568 |
+
expect(body.method).toBe("private-api");
|
| 569 |
+
expect(body.effectId).toBe("com.apple.MobileSMS.expressivesend.invisibleink");
|
| 570 |
+
});
|
| 571 |
+
|
| 572 |
+
it("sends message with chat_guid target directly", async () => {
|
| 573 |
+
mockFetch.mockResolvedValueOnce({
|
| 574 |
+
ok: true,
|
| 575 |
+
text: () =>
|
| 576 |
+
Promise.resolve(
|
| 577 |
+
JSON.stringify({
|
| 578 |
+
data: { messageId: "direct-msg-123" },
|
| 579 |
+
}),
|
| 580 |
+
),
|
| 581 |
+
});
|
| 582 |
+
|
| 583 |
+
const result = await sendMessageBlueBubbles(
|
| 584 |
+
"chat_guid:iMessage;-;direct-chat",
|
| 585 |
+
"Direct message",
|
| 586 |
+
{
|
| 587 |
+
serverUrl: "http://localhost:1234",
|
| 588 |
+
password: "test",
|
| 589 |
+
},
|
| 590 |
+
);
|
| 591 |
+
|
| 592 |
+
expect(result.messageId).toBe("direct-msg-123");
|
| 593 |
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
| 594 |
+
});
|
| 595 |
+
|
| 596 |
+
it("handles send failure", async () => {
|
| 597 |
+
mockFetch
|
| 598 |
+
.mockResolvedValueOnce({
|
| 599 |
+
ok: true,
|
| 600 |
+
json: () =>
|
| 601 |
+
Promise.resolve({
|
| 602 |
+
data: [
|
| 603 |
+
{
|
| 604 |
+
guid: "iMessage;-;+15551234567",
|
| 605 |
+
participants: [{ address: "+15551234567" }],
|
| 606 |
+
},
|
| 607 |
+
],
|
| 608 |
+
}),
|
| 609 |
+
})
|
| 610 |
+
.mockResolvedValueOnce({
|
| 611 |
+
ok: false,
|
| 612 |
+
status: 500,
|
| 613 |
+
text: () => Promise.resolve("Internal server error"),
|
| 614 |
+
});
|
| 615 |
+
|
| 616 |
+
await expect(
|
| 617 |
+
sendMessageBlueBubbles("+15551234567", "Hello", {
|
| 618 |
+
serverUrl: "http://localhost:1234",
|
| 619 |
+
password: "test",
|
| 620 |
+
}),
|
| 621 |
+
).rejects.toThrow("send failed (500)");
|
| 622 |
+
});
|
| 623 |
+
|
| 624 |
+
it("handles empty response body", async () => {
|
| 625 |
+
mockFetch
|
| 626 |
+
.mockResolvedValueOnce({
|
| 627 |
+
ok: true,
|
| 628 |
+
json: () =>
|
| 629 |
+
Promise.resolve({
|
| 630 |
+
data: [
|
| 631 |
+
{
|
| 632 |
+
guid: "iMessage;-;+15551234567",
|
| 633 |
+
participants: [{ address: "+15551234567" }],
|
| 634 |
+
},
|
| 635 |
+
],
|
| 636 |
+
}),
|
| 637 |
+
})
|
| 638 |
+
.mockResolvedValueOnce({
|
| 639 |
+
ok: true,
|
| 640 |
+
text: () => Promise.resolve(""),
|
| 641 |
+
});
|
| 642 |
+
|
| 643 |
+
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
| 644 |
+
serverUrl: "http://localhost:1234",
|
| 645 |
+
password: "test",
|
| 646 |
+
});
|
| 647 |
+
|
| 648 |
+
expect(result.messageId).toBe("ok");
|
| 649 |
+
});
|
| 650 |
+
|
| 651 |
+
it("handles invalid JSON response body", async () => {
|
| 652 |
+
mockFetch
|
| 653 |
+
.mockResolvedValueOnce({
|
| 654 |
+
ok: true,
|
| 655 |
+
json: () =>
|
| 656 |
+
Promise.resolve({
|
| 657 |
+
data: [
|
| 658 |
+
{
|
| 659 |
+
guid: "iMessage;-;+15551234567",
|
| 660 |
+
participants: [{ address: "+15551234567" }],
|
| 661 |
+
},
|
| 662 |
+
],
|
| 663 |
+
}),
|
| 664 |
+
})
|
| 665 |
+
.mockResolvedValueOnce({
|
| 666 |
+
ok: true,
|
| 667 |
+
text: () => Promise.resolve("not valid json"),
|
| 668 |
+
});
|
| 669 |
+
|
| 670 |
+
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
| 671 |
+
serverUrl: "http://localhost:1234",
|
| 672 |
+
password: "test",
|
| 673 |
+
});
|
| 674 |
+
|
| 675 |
+
expect(result.messageId).toBe("ok");
|
| 676 |
+
});
|
| 677 |
+
|
| 678 |
+
it("extracts messageId from various response formats", async () => {
|
| 679 |
+
mockFetch
|
| 680 |
+
.mockResolvedValueOnce({
|
| 681 |
+
ok: true,
|
| 682 |
+
json: () =>
|
| 683 |
+
Promise.resolve({
|
| 684 |
+
data: [
|
| 685 |
+
{
|
| 686 |
+
guid: "iMessage;-;+15551234567",
|
| 687 |
+
participants: [{ address: "+15551234567" }],
|
| 688 |
+
},
|
| 689 |
+
],
|
| 690 |
+
}),
|
| 691 |
+
})
|
| 692 |
+
.mockResolvedValueOnce({
|
| 693 |
+
ok: true,
|
| 694 |
+
text: () =>
|
| 695 |
+
Promise.resolve(
|
| 696 |
+
JSON.stringify({
|
| 697 |
+
id: "numeric-id-456",
|
| 698 |
+
}),
|
| 699 |
+
),
|
| 700 |
+
});
|
| 701 |
+
|
| 702 |
+
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
| 703 |
+
serverUrl: "http://localhost:1234",
|
| 704 |
+
password: "test",
|
| 705 |
+
});
|
| 706 |
+
|
| 707 |
+
expect(result.messageId).toBe("numeric-id-456");
|
| 708 |
+
});
|
| 709 |
+
|
| 710 |
+
it("extracts messageGuid from response payload", async () => {
|
| 711 |
+
mockFetch
|
| 712 |
+
.mockResolvedValueOnce({
|
| 713 |
+
ok: true,
|
| 714 |
+
json: () =>
|
| 715 |
+
Promise.resolve({
|
| 716 |
+
data: [
|
| 717 |
+
{
|
| 718 |
+
guid: "iMessage;-;+15551234567",
|
| 719 |
+
participants: [{ address: "+15551234567" }],
|
| 720 |
+
},
|
| 721 |
+
],
|
| 722 |
+
}),
|
| 723 |
+
})
|
| 724 |
+
.mockResolvedValueOnce({
|
| 725 |
+
ok: true,
|
| 726 |
+
text: () =>
|
| 727 |
+
Promise.resolve(
|
| 728 |
+
JSON.stringify({
|
| 729 |
+
data: { messageGuid: "msg-guid-789" },
|
| 730 |
+
}),
|
| 731 |
+
),
|
| 732 |
+
});
|
| 733 |
+
|
| 734 |
+
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
| 735 |
+
serverUrl: "http://localhost:1234",
|
| 736 |
+
password: "test",
|
| 737 |
+
});
|
| 738 |
+
|
| 739 |
+
expect(result.messageId).toBe("msg-guid-789");
|
| 740 |
+
});
|
| 741 |
+
|
| 742 |
+
it("resolves credentials from config", async () => {
|
| 743 |
+
mockFetch
|
| 744 |
+
.mockResolvedValueOnce({
|
| 745 |
+
ok: true,
|
| 746 |
+
json: () =>
|
| 747 |
+
Promise.resolve({
|
| 748 |
+
data: [
|
| 749 |
+
{
|
| 750 |
+
guid: "iMessage;-;+15551234567",
|
| 751 |
+
participants: [{ address: "+15551234567" }],
|
| 752 |
+
},
|
| 753 |
+
],
|
| 754 |
+
}),
|
| 755 |
+
})
|
| 756 |
+
.mockResolvedValueOnce({
|
| 757 |
+
ok: true,
|
| 758 |
+
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-123" } })),
|
| 759 |
+
});
|
| 760 |
+
|
| 761 |
+
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
| 762 |
+
cfg: {
|
| 763 |
+
channels: {
|
| 764 |
+
bluebubbles: {
|
| 765 |
+
serverUrl: "http://config-server:5678",
|
| 766 |
+
password: "config-pass",
|
| 767 |
+
},
|
| 768 |
+
},
|
| 769 |
+
},
|
| 770 |
+
});
|
| 771 |
+
|
| 772 |
+
expect(result.messageId).toBe("msg-123");
|
| 773 |
+
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
| 774 |
+
expect(calledUrl).toContain("config-server:5678");
|
| 775 |
+
});
|
| 776 |
+
|
| 777 |
+
it("includes tempGuid in request payload", async () => {
|
| 778 |
+
mockFetch
|
| 779 |
+
.mockResolvedValueOnce({
|
| 780 |
+
ok: true,
|
| 781 |
+
json: () =>
|
| 782 |
+
Promise.resolve({
|
| 783 |
+
data: [
|
| 784 |
+
{
|
| 785 |
+
guid: "iMessage;-;+15551234567",
|
| 786 |
+
participants: [{ address: "+15551234567" }],
|
| 787 |
+
},
|
| 788 |
+
],
|
| 789 |
+
}),
|
| 790 |
+
})
|
| 791 |
+
.mockResolvedValueOnce({
|
| 792 |
+
ok: true,
|
| 793 |
+
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg" } })),
|
| 794 |
+
});
|
| 795 |
+
|
| 796 |
+
await sendMessageBlueBubbles("+15551234567", "Hello", {
|
| 797 |
+
serverUrl: "http://localhost:1234",
|
| 798 |
+
password: "test",
|
| 799 |
+
});
|
| 800 |
+
|
| 801 |
+
const sendCall = mockFetch.mock.calls[1];
|
| 802 |
+
const body = JSON.parse(sendCall[1].body);
|
| 803 |
+
expect(body.tempGuid).toBeDefined();
|
| 804 |
+
expect(typeof body.tempGuid).toBe("string");
|
| 805 |
+
expect(body.tempGuid.length).toBeGreaterThan(0);
|
| 806 |
+
});
|
| 807 |
+
});
|
| 808 |
+
});
|
extensions/bluebubbles/src/send.ts
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
| 2 |
+
import crypto from "node:crypto";
|
| 3 |
+
import { resolveBlueBubblesAccount } from "./accounts.js";
|
| 4 |
+
import {
|
| 5 |
+
extractHandleFromChatGuid,
|
| 6 |
+
normalizeBlueBubblesHandle,
|
| 7 |
+
parseBlueBubblesTarget,
|
| 8 |
+
} from "./targets.js";
|
| 9 |
+
import {
|
| 10 |
+
blueBubblesFetchWithTimeout,
|
| 11 |
+
buildBlueBubblesApiUrl,
|
| 12 |
+
type BlueBubblesSendTarget,
|
| 13 |
+
} from "./types.js";
|
| 14 |
+
|
| 15 |
+
export type BlueBubblesSendOpts = {
|
| 16 |
+
serverUrl?: string;
|
| 17 |
+
password?: string;
|
| 18 |
+
accountId?: string;
|
| 19 |
+
timeoutMs?: number;
|
| 20 |
+
cfg?: OpenClawConfig;
|
| 21 |
+
/** Message GUID to reply to (reply threading) */
|
| 22 |
+
replyToMessageGuid?: string;
|
| 23 |
+
/** Part index for reply (default: 0) */
|
| 24 |
+
replyToPartIndex?: number;
|
| 25 |
+
/** Effect ID or short name for message effects (e.g., "slam", "balloons") */
|
| 26 |
+
effectId?: string;
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
export type BlueBubblesSendResult = {
|
| 30 |
+
messageId: string;
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
/** Maps short effect names to full Apple effect IDs */
|
| 34 |
+
const EFFECT_MAP: Record<string, string> = {
|
| 35 |
+
// Bubble effects
|
| 36 |
+
slam: "com.apple.MobileSMS.expressivesend.impact",
|
| 37 |
+
loud: "com.apple.MobileSMS.expressivesend.loud",
|
| 38 |
+
gentle: "com.apple.MobileSMS.expressivesend.gentle",
|
| 39 |
+
invisible: "com.apple.MobileSMS.expressivesend.invisibleink",
|
| 40 |
+
"invisible-ink": "com.apple.MobileSMS.expressivesend.invisibleink",
|
| 41 |
+
"invisible ink": "com.apple.MobileSMS.expressivesend.invisibleink",
|
| 42 |
+
invisibleink: "com.apple.MobileSMS.expressivesend.invisibleink",
|
| 43 |
+
// Screen effects
|
| 44 |
+
echo: "com.apple.messages.effect.CKEchoEffect",
|
| 45 |
+
spotlight: "com.apple.messages.effect.CKSpotlightEffect",
|
| 46 |
+
balloons: "com.apple.messages.effect.CKHappyBirthdayEffect",
|
| 47 |
+
confetti: "com.apple.messages.effect.CKConfettiEffect",
|
| 48 |
+
love: "com.apple.messages.effect.CKHeartEffect",
|
| 49 |
+
heart: "com.apple.messages.effect.CKHeartEffect",
|
| 50 |
+
hearts: "com.apple.messages.effect.CKHeartEffect",
|
| 51 |
+
lasers: "com.apple.messages.effect.CKLasersEffect",
|
| 52 |
+
fireworks: "com.apple.messages.effect.CKFireworksEffect",
|
| 53 |
+
celebration: "com.apple.messages.effect.CKSparklesEffect",
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
function resolveEffectId(raw?: string): string | undefined {
|
| 57 |
+
if (!raw) {
|
| 58 |
+
return undefined;
|
| 59 |
+
}
|
| 60 |
+
const trimmed = raw.trim().toLowerCase();
|
| 61 |
+
if (EFFECT_MAP[trimmed]) {
|
| 62 |
+
return EFFECT_MAP[trimmed];
|
| 63 |
+
}
|
| 64 |
+
const normalized = trimmed.replace(/[\s_]+/g, "-");
|
| 65 |
+
if (EFFECT_MAP[normalized]) {
|
| 66 |
+
return EFFECT_MAP[normalized];
|
| 67 |
+
}
|
| 68 |
+
const compact = trimmed.replace(/[\s_-]+/g, "");
|
| 69 |
+
if (EFFECT_MAP[compact]) {
|
| 70 |
+
return EFFECT_MAP[compact];
|
| 71 |
+
}
|
| 72 |
+
return raw;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
function resolveSendTarget(raw: string): BlueBubblesSendTarget {
|
| 76 |
+
const parsed = parseBlueBubblesTarget(raw);
|
| 77 |
+
if (parsed.kind === "handle") {
|
| 78 |
+
return {
|
| 79 |
+
kind: "handle",
|
| 80 |
+
address: normalizeBlueBubblesHandle(parsed.to),
|
| 81 |
+
service: parsed.service,
|
| 82 |
+
};
|
| 83 |
+
}
|
| 84 |
+
if (parsed.kind === "chat_id") {
|
| 85 |
+
return { kind: "chat_id", chatId: parsed.chatId };
|
| 86 |
+
}
|
| 87 |
+
if (parsed.kind === "chat_guid") {
|
| 88 |
+
return { kind: "chat_guid", chatGuid: parsed.chatGuid };
|
| 89 |
+
}
|
| 90 |
+
return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
function extractMessageId(payload: unknown): string {
|
| 94 |
+
if (!payload || typeof payload !== "object") {
|
| 95 |
+
return "unknown";
|
| 96 |
+
}
|
| 97 |
+
const record = payload as Record<string, unknown>;
|
| 98 |
+
const data =
|
| 99 |
+
record.data && typeof record.data === "object"
|
| 100 |
+
? (record.data as Record<string, unknown>)
|
| 101 |
+
: null;
|
| 102 |
+
const candidates = [
|
| 103 |
+
record.messageId,
|
| 104 |
+
record.messageGuid,
|
| 105 |
+
record.message_guid,
|
| 106 |
+
record.guid,
|
| 107 |
+
record.id,
|
| 108 |
+
data?.messageId,
|
| 109 |
+
data?.messageGuid,
|
| 110 |
+
data?.message_guid,
|
| 111 |
+
data?.message_id,
|
| 112 |
+
data?.guid,
|
| 113 |
+
data?.id,
|
| 114 |
+
];
|
| 115 |
+
for (const candidate of candidates) {
|
| 116 |
+
if (typeof candidate === "string" && candidate.trim()) {
|
| 117 |
+
return candidate.trim();
|
| 118 |
+
}
|
| 119 |
+
if (typeof candidate === "number" && Number.isFinite(candidate)) {
|
| 120 |
+
return String(candidate);
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
return "unknown";
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
type BlueBubblesChatRecord = Record<string, unknown>;
|
| 127 |
+
|
| 128 |
+
function extractChatGuid(chat: BlueBubblesChatRecord): string | null {
|
| 129 |
+
const candidates = [
|
| 130 |
+
chat.chatGuid,
|
| 131 |
+
chat.guid,
|
| 132 |
+
chat.chat_guid,
|
| 133 |
+
chat.identifier,
|
| 134 |
+
chat.chatIdentifier,
|
| 135 |
+
chat.chat_identifier,
|
| 136 |
+
];
|
| 137 |
+
for (const candidate of candidates) {
|
| 138 |
+
if (typeof candidate === "string" && candidate.trim()) {
|
| 139 |
+
return candidate.trim();
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
return null;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
function extractChatId(chat: BlueBubblesChatRecord): number | null {
|
| 146 |
+
const candidates = [chat.chatId, chat.id, chat.chat_id];
|
| 147 |
+
for (const candidate of candidates) {
|
| 148 |
+
if (typeof candidate === "number" && Number.isFinite(candidate)) {
|
| 149 |
+
return candidate;
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
return null;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
function extractChatIdentifierFromChatGuid(chatGuid: string): string | null {
|
| 156 |
+
const parts = chatGuid.split(";");
|
| 157 |
+
if (parts.length < 3) {
|
| 158 |
+
return null;
|
| 159 |
+
}
|
| 160 |
+
const identifier = parts[2]?.trim();
|
| 161 |
+
return identifier ? identifier : null;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] {
|
| 165 |
+
const raw =
|
| 166 |
+
(Array.isArray(chat.participants) ? chat.participants : null) ??
|
| 167 |
+
(Array.isArray(chat.handles) ? chat.handles : null) ??
|
| 168 |
+
(Array.isArray(chat.participantHandles) ? chat.participantHandles : null);
|
| 169 |
+
if (!raw) {
|
| 170 |
+
return [];
|
| 171 |
+
}
|
| 172 |
+
const out: string[] = [];
|
| 173 |
+
for (const entry of raw) {
|
| 174 |
+
if (typeof entry === "string") {
|
| 175 |
+
out.push(entry);
|
| 176 |
+
continue;
|
| 177 |
+
}
|
| 178 |
+
if (entry && typeof entry === "object") {
|
| 179 |
+
const record = entry as Record<string, unknown>;
|
| 180 |
+
const candidate =
|
| 181 |
+
(typeof record.address === "string" && record.address) ||
|
| 182 |
+
(typeof record.handle === "string" && record.handle) ||
|
| 183 |
+
(typeof record.id === "string" && record.id) ||
|
| 184 |
+
(typeof record.identifier === "string" && record.identifier);
|
| 185 |
+
if (candidate) {
|
| 186 |
+
out.push(candidate);
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
return out;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
async function queryChats(params: {
|
| 194 |
+
baseUrl: string;
|
| 195 |
+
password: string;
|
| 196 |
+
timeoutMs?: number;
|
| 197 |
+
offset: number;
|
| 198 |
+
limit: number;
|
| 199 |
+
}): Promise<BlueBubblesChatRecord[]> {
|
| 200 |
+
const url = buildBlueBubblesApiUrl({
|
| 201 |
+
baseUrl: params.baseUrl,
|
| 202 |
+
path: "/api/v1/chat/query",
|
| 203 |
+
password: params.password,
|
| 204 |
+
});
|
| 205 |
+
const res = await blueBubblesFetchWithTimeout(
|
| 206 |
+
url,
|
| 207 |
+
{
|
| 208 |
+
method: "POST",
|
| 209 |
+
headers: { "Content-Type": "application/json" },
|
| 210 |
+
body: JSON.stringify({
|
| 211 |
+
limit: params.limit,
|
| 212 |
+
offset: params.offset,
|
| 213 |
+
with: ["participants"],
|
| 214 |
+
}),
|
| 215 |
+
},
|
| 216 |
+
params.timeoutMs,
|
| 217 |
+
);
|
| 218 |
+
if (!res.ok) {
|
| 219 |
+
return [];
|
| 220 |
+
}
|
| 221 |
+
const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
| 222 |
+
const data = payload && typeof payload.data !== "undefined" ? (payload.data as unknown) : null;
|
| 223 |
+
return Array.isArray(data) ? (data as BlueBubblesChatRecord[]) : [];
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
export async function resolveChatGuidForTarget(params: {
|
| 227 |
+
baseUrl: string;
|
| 228 |
+
password: string;
|
| 229 |
+
timeoutMs?: number;
|
| 230 |
+
target: BlueBubblesSendTarget;
|
| 231 |
+
}): Promise<string | null> {
|
| 232 |
+
if (params.target.kind === "chat_guid") {
|
| 233 |
+
return params.target.chatGuid;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
const normalizedHandle =
|
| 237 |
+
params.target.kind === "handle" ? normalizeBlueBubblesHandle(params.target.address) : "";
|
| 238 |
+
const targetChatId = params.target.kind === "chat_id" ? params.target.chatId : null;
|
| 239 |
+
const targetChatIdentifier =
|
| 240 |
+
params.target.kind === "chat_identifier" ? params.target.chatIdentifier : null;
|
| 241 |
+
|
| 242 |
+
const limit = 500;
|
| 243 |
+
let participantMatch: string | null = null;
|
| 244 |
+
for (let offset = 0; offset < 5000; offset += limit) {
|
| 245 |
+
const chats = await queryChats({
|
| 246 |
+
baseUrl: params.baseUrl,
|
| 247 |
+
password: params.password,
|
| 248 |
+
timeoutMs: params.timeoutMs,
|
| 249 |
+
offset,
|
| 250 |
+
limit,
|
| 251 |
+
});
|
| 252 |
+
if (chats.length === 0) {
|
| 253 |
+
break;
|
| 254 |
+
}
|
| 255 |
+
for (const chat of chats) {
|
| 256 |
+
if (targetChatId != null) {
|
| 257 |
+
const chatId = extractChatId(chat);
|
| 258 |
+
if (chatId != null && chatId === targetChatId) {
|
| 259 |
+
return extractChatGuid(chat);
|
| 260 |
+
}
|
| 261 |
+
}
|
| 262 |
+
if (targetChatIdentifier) {
|
| 263 |
+
const guid = extractChatGuid(chat);
|
| 264 |
+
if (guid) {
|
| 265 |
+
// Back-compat: some callers might pass a full chat GUID.
|
| 266 |
+
if (guid === targetChatIdentifier) {
|
| 267 |
+
return guid;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
// Primary match: BlueBubbles `chat_identifier:*` targets correspond to the
|
| 271 |
+
// third component of the chat GUID: `service;(+|-) ;identifier`.
|
| 272 |
+
const guidIdentifier = extractChatIdentifierFromChatGuid(guid);
|
| 273 |
+
if (guidIdentifier && guidIdentifier === targetChatIdentifier) {
|
| 274 |
+
return guid;
|
| 275 |
+
}
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
const identifier =
|
| 279 |
+
typeof chat.identifier === "string"
|
| 280 |
+
? chat.identifier
|
| 281 |
+
: typeof chat.chatIdentifier === "string"
|
| 282 |
+
? chat.chatIdentifier
|
| 283 |
+
: typeof chat.chat_identifier === "string"
|
| 284 |
+
? chat.chat_identifier
|
| 285 |
+
: "";
|
| 286 |
+
if (identifier && identifier === targetChatIdentifier) {
|
| 287 |
+
return guid ?? extractChatGuid(chat);
|
| 288 |
+
}
|
| 289 |
+
}
|
| 290 |
+
if (normalizedHandle) {
|
| 291 |
+
const guid = extractChatGuid(chat);
|
| 292 |
+
const directHandle = guid ? extractHandleFromChatGuid(guid) : null;
|
| 293 |
+
if (directHandle && directHandle === normalizedHandle) {
|
| 294 |
+
return guid;
|
| 295 |
+
}
|
| 296 |
+
if (!participantMatch && guid) {
|
| 297 |
+
// Only consider DM chats (`;-;` separator) as participant matches.
|
| 298 |
+
// Group chats (`;+;` separator) should never match when searching by handle/phone.
|
| 299 |
+
// This prevents routing "send to +1234567890" to a group chat that contains that number.
|
| 300 |
+
const isDmChat = guid.includes(";-;");
|
| 301 |
+
if (isDmChat) {
|
| 302 |
+
const participants = extractParticipantAddresses(chat).map((entry) =>
|
| 303 |
+
normalizeBlueBubblesHandle(entry),
|
| 304 |
+
);
|
| 305 |
+
if (participants.includes(normalizedHandle)) {
|
| 306 |
+
participantMatch = guid;
|
| 307 |
+
}
|
| 308 |
+
}
|
| 309 |
+
}
|
| 310 |
+
}
|
| 311 |
+
}
|
| 312 |
+
}
|
| 313 |
+
return participantMatch;
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
/**
|
| 317 |
+
* Creates a new chat (DM) and optionally sends an initial message.
|
| 318 |
+
* Requires Private API to be enabled in BlueBubbles.
|
| 319 |
+
*/
|
| 320 |
+
async function createNewChatWithMessage(params: {
|
| 321 |
+
baseUrl: string;
|
| 322 |
+
password: string;
|
| 323 |
+
address: string;
|
| 324 |
+
message: string;
|
| 325 |
+
timeoutMs?: number;
|
| 326 |
+
}): Promise<BlueBubblesSendResult> {
|
| 327 |
+
const url = buildBlueBubblesApiUrl({
|
| 328 |
+
baseUrl: params.baseUrl,
|
| 329 |
+
path: "/api/v1/chat/new",
|
| 330 |
+
password: params.password,
|
| 331 |
+
});
|
| 332 |
+
const payload = {
|
| 333 |
+
addresses: [params.address],
|
| 334 |
+
message: params.message,
|
| 335 |
+
};
|
| 336 |
+
const res = await blueBubblesFetchWithTimeout(
|
| 337 |
+
url,
|
| 338 |
+
{
|
| 339 |
+
method: "POST",
|
| 340 |
+
headers: { "Content-Type": "application/json" },
|
| 341 |
+
body: JSON.stringify(payload),
|
| 342 |
+
},
|
| 343 |
+
params.timeoutMs,
|
| 344 |
+
);
|
| 345 |
+
if (!res.ok) {
|
| 346 |
+
const errorText = await res.text();
|
| 347 |
+
// Check for Private API not enabled error
|
| 348 |
+
if (
|
| 349 |
+
res.status === 400 ||
|
| 350 |
+
res.status === 403 ||
|
| 351 |
+
errorText.toLowerCase().includes("private api")
|
| 352 |
+
) {
|
| 353 |
+
throw new Error(
|
| 354 |
+
`BlueBubbles send failed: Cannot create new chat - Private API must be enabled. Original error: ${errorText || res.status}`,
|
| 355 |
+
);
|
| 356 |
+
}
|
| 357 |
+
throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`);
|
| 358 |
+
}
|
| 359 |
+
const body = await res.text();
|
| 360 |
+
if (!body) {
|
| 361 |
+
return { messageId: "ok" };
|
| 362 |
+
}
|
| 363 |
+
try {
|
| 364 |
+
const parsed = JSON.parse(body) as unknown;
|
| 365 |
+
return { messageId: extractMessageId(parsed) };
|
| 366 |
+
} catch {
|
| 367 |
+
return { messageId: "ok" };
|
| 368 |
+
}
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
export async function sendMessageBlueBubbles(
|
| 372 |
+
to: string,
|
| 373 |
+
text: string,
|
| 374 |
+
opts: BlueBubblesSendOpts = {},
|
| 375 |
+
): Promise<BlueBubblesSendResult> {
|
| 376 |
+
const trimmedText = text ?? "";
|
| 377 |
+
if (!trimmedText.trim()) {
|
| 378 |
+
throw new Error("BlueBubbles send requires text");
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
const account = resolveBlueBubblesAccount({
|
| 382 |
+
cfg: opts.cfg ?? {},
|
| 383 |
+
accountId: opts.accountId,
|
| 384 |
+
});
|
| 385 |
+
const baseUrl = opts.serverUrl?.trim() || account.config.serverUrl?.trim();
|
| 386 |
+
const password = opts.password?.trim() || account.config.password?.trim();
|
| 387 |
+
if (!baseUrl) {
|
| 388 |
+
throw new Error("BlueBubbles serverUrl is required");
|
| 389 |
+
}
|
| 390 |
+
if (!password) {
|
| 391 |
+
throw new Error("BlueBubbles password is required");
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
const target = resolveSendTarget(to);
|
| 395 |
+
const chatGuid = await resolveChatGuidForTarget({
|
| 396 |
+
baseUrl,
|
| 397 |
+
password,
|
| 398 |
+
timeoutMs: opts.timeoutMs,
|
| 399 |
+
target,
|
| 400 |
+
});
|
| 401 |
+
if (!chatGuid) {
|
| 402 |
+
// If target is a phone number/handle and no existing chat found,
|
| 403 |
+
// auto-create a new DM chat using the /api/v1/chat/new endpoint
|
| 404 |
+
if (target.kind === "handle") {
|
| 405 |
+
return createNewChatWithMessage({
|
| 406 |
+
baseUrl,
|
| 407 |
+
password,
|
| 408 |
+
address: target.address,
|
| 409 |
+
message: trimmedText,
|
| 410 |
+
timeoutMs: opts.timeoutMs,
|
| 411 |
+
});
|
| 412 |
+
}
|
| 413 |
+
throw new Error(
|
| 414 |
+
"BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
|
| 415 |
+
);
|
| 416 |
+
}
|
| 417 |
+
const effectId = resolveEffectId(opts.effectId);
|
| 418 |
+
const needsPrivateApi = Boolean(opts.replyToMessageGuid || effectId);
|
| 419 |
+
const payload: Record<string, unknown> = {
|
| 420 |
+
chatGuid,
|
| 421 |
+
tempGuid: crypto.randomUUID(),
|
| 422 |
+
message: trimmedText,
|
| 423 |
+
};
|
| 424 |
+
if (needsPrivateApi) {
|
| 425 |
+
payload.method = "private-api";
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
// Add reply threading support
|
| 429 |
+
if (opts.replyToMessageGuid) {
|
| 430 |
+
payload.selectedMessageGuid = opts.replyToMessageGuid;
|
| 431 |
+
payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0;
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
// Add message effects support
|
| 435 |
+
if (effectId) {
|
| 436 |
+
payload.effectId = effectId;
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
const url = buildBlueBubblesApiUrl({
|
| 440 |
+
baseUrl,
|
| 441 |
+
path: "/api/v1/message/text",
|
| 442 |
+
password,
|
| 443 |
+
});
|
| 444 |
+
const res = await blueBubblesFetchWithTimeout(
|
| 445 |
+
url,
|
| 446 |
+
{
|
| 447 |
+
method: "POST",
|
| 448 |
+
headers: { "Content-Type": "application/json" },
|
| 449 |
+
body: JSON.stringify(payload),
|
| 450 |
+
},
|
| 451 |
+
opts.timeoutMs,
|
| 452 |
+
);
|
| 453 |
+
if (!res.ok) {
|
| 454 |
+
const errorText = await res.text();
|
| 455 |
+
throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`);
|
| 456 |
+
}
|
| 457 |
+
const body = await res.text();
|
| 458 |
+
if (!body) {
|
| 459 |
+
return { messageId: "ok" };
|
| 460 |
+
}
|
| 461 |
+
try {
|
| 462 |
+
const parsed = JSON.parse(body) as unknown;
|
| 463 |
+
return { messageId: extractMessageId(parsed) };
|
| 464 |
+
} catch {
|
| 465 |
+
return { messageId: "ok" };
|
| 466 |
+
}
|
| 467 |
+
}
|
extensions/bluebubbles/src/targets.test.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
looksLikeBlueBubblesTargetId,
|
| 4 |
+
normalizeBlueBubblesMessagingTarget,
|
| 5 |
+
parseBlueBubblesTarget,
|
| 6 |
+
parseBlueBubblesAllowTarget,
|
| 7 |
+
} from "./targets.js";
|
| 8 |
+
|
| 9 |
+
describe("normalizeBlueBubblesMessagingTarget", () => {
|
| 10 |
+
it("normalizes chat_guid targets", () => {
|
| 11 |
+
expect(normalizeBlueBubblesMessagingTarget("chat_guid:ABC-123")).toBe("chat_guid:ABC-123");
|
| 12 |
+
});
|
| 13 |
+
|
| 14 |
+
it("normalizes group numeric targets to chat_id", () => {
|
| 15 |
+
expect(normalizeBlueBubblesMessagingTarget("group:123")).toBe("chat_id:123");
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
it("strips provider prefix and normalizes handles", () => {
|
| 19 |
+
expect(normalizeBlueBubblesMessagingTarget("bluebubbles:imessage:User@Example.com")).toBe(
|
| 20 |
+
"imessage:user@example.com",
|
| 21 |
+
);
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
it("extracts handle from DM chat_guid for cross-context matching", () => {
|
| 25 |
+
// DM format: service;-;handle
|
| 26 |
+
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;+19257864429")).toBe(
|
| 27 |
+
"+19257864429",
|
| 28 |
+
);
|
| 29 |
+
expect(normalizeBlueBubblesMessagingTarget("chat_guid:SMS;-;+15551234567")).toBe(
|
| 30 |
+
"+15551234567",
|
| 31 |
+
);
|
| 32 |
+
// Email handles
|
| 33 |
+
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;user@example.com")).toBe(
|
| 34 |
+
"user@example.com",
|
| 35 |
+
);
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
it("preserves group chat_guid format", () => {
|
| 39 |
+
// Group format: service;+;groupId
|
| 40 |
+
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;+;chat123456789")).toBe(
|
| 41 |
+
"chat_guid:iMessage;+;chat123456789",
|
| 42 |
+
);
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
it("normalizes raw chat_guid values", () => {
|
| 46 |
+
expect(normalizeBlueBubblesMessagingTarget("iMessage;+;chat660250192681427962")).toBe(
|
| 47 |
+
"chat_guid:iMessage;+;chat660250192681427962",
|
| 48 |
+
);
|
| 49 |
+
expect(normalizeBlueBubblesMessagingTarget("iMessage;-;+19257864429")).toBe("+19257864429");
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
it("normalizes chat<digits> pattern to chat_identifier format", () => {
|
| 53 |
+
expect(normalizeBlueBubblesMessagingTarget("chat660250192681427962")).toBe(
|
| 54 |
+
"chat_identifier:chat660250192681427962",
|
| 55 |
+
);
|
| 56 |
+
expect(normalizeBlueBubblesMessagingTarget("chat123")).toBe("chat_identifier:chat123");
|
| 57 |
+
expect(normalizeBlueBubblesMessagingTarget("Chat456789")).toBe("chat_identifier:Chat456789");
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
it("normalizes UUID/hex chat identifiers", () => {
|
| 61 |
+
expect(normalizeBlueBubblesMessagingTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(
|
| 62 |
+
"chat_identifier:8b9c1a10536d4d86a336ea03ab7151cc",
|
| 63 |
+
);
|
| 64 |
+
expect(normalizeBlueBubblesMessagingTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(
|
| 65 |
+
"chat_identifier:1C2D3E4F-1234-5678-9ABC-DEF012345678",
|
| 66 |
+
);
|
| 67 |
+
});
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
describe("looksLikeBlueBubblesTargetId", () => {
|
| 71 |
+
it("accepts chat targets", () => {
|
| 72 |
+
expect(looksLikeBlueBubblesTargetId("chat_guid:ABC-123")).toBe(true);
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
it("accepts email handles", () => {
|
| 76 |
+
expect(looksLikeBlueBubblesTargetId("user@example.com")).toBe(true);
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
it("accepts phone numbers with punctuation", () => {
|
| 80 |
+
expect(looksLikeBlueBubblesTargetId("+1 (555) 123-4567")).toBe(true);
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
it("accepts raw chat_guid values", () => {
|
| 84 |
+
expect(looksLikeBlueBubblesTargetId("iMessage;+;chat660250192681427962")).toBe(true);
|
| 85 |
+
});
|
| 86 |
+
|
| 87 |
+
it("accepts chat<digits> pattern as chat_id", () => {
|
| 88 |
+
expect(looksLikeBlueBubblesTargetId("chat660250192681427962")).toBe(true);
|
| 89 |
+
expect(looksLikeBlueBubblesTargetId("chat123")).toBe(true);
|
| 90 |
+
expect(looksLikeBlueBubblesTargetId("Chat456789")).toBe(true);
|
| 91 |
+
});
|
| 92 |
+
|
| 93 |
+
it("accepts UUID/hex chat identifiers", () => {
|
| 94 |
+
expect(looksLikeBlueBubblesTargetId("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(true);
|
| 95 |
+
expect(looksLikeBlueBubblesTargetId("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(true);
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
it("rejects display names", () => {
|
| 99 |
+
expect(looksLikeBlueBubblesTargetId("Jane Doe")).toBe(false);
|
| 100 |
+
});
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
describe("parseBlueBubblesTarget", () => {
|
| 104 |
+
it("parses chat<digits> pattern as chat_identifier", () => {
|
| 105 |
+
expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({
|
| 106 |
+
kind: "chat_identifier",
|
| 107 |
+
chatIdentifier: "chat660250192681427962",
|
| 108 |
+
});
|
| 109 |
+
expect(parseBlueBubblesTarget("chat123")).toEqual({
|
| 110 |
+
kind: "chat_identifier",
|
| 111 |
+
chatIdentifier: "chat123",
|
| 112 |
+
});
|
| 113 |
+
expect(parseBlueBubblesTarget("Chat456789")).toEqual({
|
| 114 |
+
kind: "chat_identifier",
|
| 115 |
+
chatIdentifier: "Chat456789",
|
| 116 |
+
});
|
| 117 |
+
});
|
| 118 |
+
|
| 119 |
+
it("parses UUID/hex chat identifiers as chat_identifier", () => {
|
| 120 |
+
expect(parseBlueBubblesTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
|
| 121 |
+
kind: "chat_identifier",
|
| 122 |
+
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
|
| 123 |
+
});
|
| 124 |
+
expect(parseBlueBubblesTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
|
| 125 |
+
kind: "chat_identifier",
|
| 126 |
+
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
|
| 127 |
+
});
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
it("parses explicit chat_id: prefix", () => {
|
| 131 |
+
expect(parseBlueBubblesTarget("chat_id:123")).toEqual({ kind: "chat_id", chatId: 123 });
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
it("parses phone numbers as handles", () => {
|
| 135 |
+
expect(parseBlueBubblesTarget("+19257864429")).toEqual({
|
| 136 |
+
kind: "handle",
|
| 137 |
+
to: "+19257864429",
|
| 138 |
+
service: "auto",
|
| 139 |
+
});
|
| 140 |
+
});
|
| 141 |
+
|
| 142 |
+
it("parses raw chat_guid format", () => {
|
| 143 |
+
expect(parseBlueBubblesTarget("iMessage;+;chat660250192681427962")).toEqual({
|
| 144 |
+
kind: "chat_guid",
|
| 145 |
+
chatGuid: "iMessage;+;chat660250192681427962",
|
| 146 |
+
});
|
| 147 |
+
});
|
| 148 |
+
});
|
| 149 |
+
|
| 150 |
+
describe("parseBlueBubblesAllowTarget", () => {
|
| 151 |
+
it("parses chat<digits> pattern as chat_identifier", () => {
|
| 152 |
+
expect(parseBlueBubblesAllowTarget("chat660250192681427962")).toEqual({
|
| 153 |
+
kind: "chat_identifier",
|
| 154 |
+
chatIdentifier: "chat660250192681427962",
|
| 155 |
+
});
|
| 156 |
+
expect(parseBlueBubblesAllowTarget("chat123")).toEqual({
|
| 157 |
+
kind: "chat_identifier",
|
| 158 |
+
chatIdentifier: "chat123",
|
| 159 |
+
});
|
| 160 |
+
});
|
| 161 |
+
|
| 162 |
+
it("parses UUID/hex chat identifiers as chat_identifier", () => {
|
| 163 |
+
expect(parseBlueBubblesAllowTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
|
| 164 |
+
kind: "chat_identifier",
|
| 165 |
+
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
|
| 166 |
+
});
|
| 167 |
+
expect(parseBlueBubblesAllowTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
|
| 168 |
+
kind: "chat_identifier",
|
| 169 |
+
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
|
| 170 |
+
});
|
| 171 |
+
});
|
| 172 |
+
|
| 173 |
+
it("parses explicit chat_id: prefix", () => {
|
| 174 |
+
expect(parseBlueBubblesAllowTarget("chat_id:456")).toEqual({ kind: "chat_id", chatId: 456 });
|
| 175 |
+
});
|
| 176 |
+
|
| 177 |
+
it("parses phone numbers as handles", () => {
|
| 178 |
+
expect(parseBlueBubblesAllowTarget("+19257864429")).toEqual({
|
| 179 |
+
kind: "handle",
|
| 180 |
+
handle: "+19257864429",
|
| 181 |
+
});
|
| 182 |
+
});
|
| 183 |
+
});
|
extensions/bluebubbles/src/targets.ts
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type BlueBubblesService = "imessage" | "sms" | "auto";
|
| 2 |
+
|
| 3 |
+
export type BlueBubblesTarget =
|
| 4 |
+
| { kind: "chat_id"; chatId: number }
|
| 5 |
+
| { kind: "chat_guid"; chatGuid: string }
|
| 6 |
+
| { kind: "chat_identifier"; chatIdentifier: string }
|
| 7 |
+
| { kind: "handle"; to: string; service: BlueBubblesService };
|
| 8 |
+
|
| 9 |
+
export type BlueBubblesAllowTarget =
|
| 10 |
+
| { kind: "chat_id"; chatId: number }
|
| 11 |
+
| { kind: "chat_guid"; chatGuid: string }
|
| 12 |
+
| { kind: "chat_identifier"; chatIdentifier: string }
|
| 13 |
+
| { kind: "handle"; handle: string };
|
| 14 |
+
|
| 15 |
+
const CHAT_ID_PREFIXES = ["chat_id:", "chatid:", "chat:"];
|
| 16 |
+
const CHAT_GUID_PREFIXES = ["chat_guid:", "chatguid:", "guid:"];
|
| 17 |
+
const CHAT_IDENTIFIER_PREFIXES = ["chat_identifier:", "chatidentifier:", "chatident:"];
|
| 18 |
+
const SERVICE_PREFIXES: Array<{ prefix: string; service: BlueBubblesService }> = [
|
| 19 |
+
{ prefix: "imessage:", service: "imessage" },
|
| 20 |
+
{ prefix: "sms:", service: "sms" },
|
| 21 |
+
{ prefix: "auto:", service: "auto" },
|
| 22 |
+
];
|
| 23 |
+
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;
|
| 24 |
+
const CHAT_IDENTIFIER_HEX_RE = /^[0-9a-f]{24,64}$/i;
|
| 25 |
+
|
| 26 |
+
function parseRawChatGuid(value: string): string | null {
|
| 27 |
+
const trimmed = value.trim();
|
| 28 |
+
if (!trimmed) {
|
| 29 |
+
return null;
|
| 30 |
+
}
|
| 31 |
+
const parts = trimmed.split(";");
|
| 32 |
+
if (parts.length !== 3) {
|
| 33 |
+
return null;
|
| 34 |
+
}
|
| 35 |
+
const service = parts[0]?.trim();
|
| 36 |
+
const separator = parts[1]?.trim();
|
| 37 |
+
const identifier = parts[2]?.trim();
|
| 38 |
+
if (!service || !identifier) {
|
| 39 |
+
return null;
|
| 40 |
+
}
|
| 41 |
+
if (separator !== "+" && separator !== "-") {
|
| 42 |
+
return null;
|
| 43 |
+
}
|
| 44 |
+
return `${service};${separator};${identifier}`;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
function stripPrefix(value: string, prefix: string): string {
|
| 48 |
+
return value.slice(prefix.length).trim();
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function stripBlueBubblesPrefix(value: string): string {
|
| 52 |
+
const trimmed = value.trim();
|
| 53 |
+
if (!trimmed) {
|
| 54 |
+
return "";
|
| 55 |
+
}
|
| 56 |
+
if (!trimmed.toLowerCase().startsWith("bluebubbles:")) {
|
| 57 |
+
return trimmed;
|
| 58 |
+
}
|
| 59 |
+
return trimmed.slice("bluebubbles:".length).trim();
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
function looksLikeRawChatIdentifier(value: string): boolean {
|
| 63 |
+
const trimmed = value.trim();
|
| 64 |
+
if (!trimmed) {
|
| 65 |
+
return false;
|
| 66 |
+
}
|
| 67 |
+
if (/^chat\d+$/i.test(trimmed)) {
|
| 68 |
+
return true;
|
| 69 |
+
}
|
| 70 |
+
return CHAT_IDENTIFIER_UUID_RE.test(trimmed) || CHAT_IDENTIFIER_HEX_RE.test(trimmed);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
export function normalizeBlueBubblesHandle(raw: string): string {
|
| 74 |
+
const trimmed = raw.trim();
|
| 75 |
+
if (!trimmed) {
|
| 76 |
+
return "";
|
| 77 |
+
}
|
| 78 |
+
const lowered = trimmed.toLowerCase();
|
| 79 |
+
if (lowered.startsWith("imessage:")) {
|
| 80 |
+
return normalizeBlueBubblesHandle(trimmed.slice(9));
|
| 81 |
+
}
|
| 82 |
+
if (lowered.startsWith("sms:")) {
|
| 83 |
+
return normalizeBlueBubblesHandle(trimmed.slice(4));
|
| 84 |
+
}
|
| 85 |
+
if (lowered.startsWith("auto:")) {
|
| 86 |
+
return normalizeBlueBubblesHandle(trimmed.slice(5));
|
| 87 |
+
}
|
| 88 |
+
if (trimmed.includes("@")) {
|
| 89 |
+
return trimmed.toLowerCase();
|
| 90 |
+
}
|
| 91 |
+
return trimmed.replace(/\s+/g, "");
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
/**
|
| 95 |
+
* Extracts the handle from a chat_guid if it's a DM (1:1 chat).
|
| 96 |
+
* BlueBubbles chat_guid format for DM: "service;-;handle" (e.g., "iMessage;-;+19257864429")
|
| 97 |
+
* Group chat format: "service;+;groupId" (has "+" instead of "-")
|
| 98 |
+
*/
|
| 99 |
+
export function extractHandleFromChatGuid(chatGuid: string): string | null {
|
| 100 |
+
const parts = chatGuid.split(";");
|
| 101 |
+
// DM format: service;-;handle (3 parts, middle is "-")
|
| 102 |
+
if (parts.length === 3 && parts[1] === "-") {
|
| 103 |
+
const handle = parts[2]?.trim();
|
| 104 |
+
if (handle) {
|
| 105 |
+
return normalizeBlueBubblesHandle(handle);
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
return null;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
export function normalizeBlueBubblesMessagingTarget(raw: string): string | undefined {
|
| 112 |
+
let trimmed = raw.trim();
|
| 113 |
+
if (!trimmed) {
|
| 114 |
+
return undefined;
|
| 115 |
+
}
|
| 116 |
+
trimmed = stripBlueBubblesPrefix(trimmed);
|
| 117 |
+
if (!trimmed) {
|
| 118 |
+
return undefined;
|
| 119 |
+
}
|
| 120 |
+
try {
|
| 121 |
+
const parsed = parseBlueBubblesTarget(trimmed);
|
| 122 |
+
if (parsed.kind === "chat_id") {
|
| 123 |
+
return `chat_id:${parsed.chatId}`;
|
| 124 |
+
}
|
| 125 |
+
if (parsed.kind === "chat_guid") {
|
| 126 |
+
// For DM chat_guids, normalize to just the handle for easier comparison.
|
| 127 |
+
// This allows "chat_guid:iMessage;-;+1234567890" to match "+1234567890".
|
| 128 |
+
const handle = extractHandleFromChatGuid(parsed.chatGuid);
|
| 129 |
+
if (handle) {
|
| 130 |
+
return handle;
|
| 131 |
+
}
|
| 132 |
+
// For group chats or unrecognized formats, keep the full chat_guid
|
| 133 |
+
return `chat_guid:${parsed.chatGuid}`;
|
| 134 |
+
}
|
| 135 |
+
if (parsed.kind === "chat_identifier") {
|
| 136 |
+
return `chat_identifier:${parsed.chatIdentifier}`;
|
| 137 |
+
}
|
| 138 |
+
const handle = normalizeBlueBubblesHandle(parsed.to);
|
| 139 |
+
if (!handle) {
|
| 140 |
+
return undefined;
|
| 141 |
+
}
|
| 142 |
+
return parsed.service === "auto" ? handle : `${parsed.service}:${handle}`;
|
| 143 |
+
} catch {
|
| 144 |
+
return trimmed;
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string): boolean {
|
| 149 |
+
const trimmed = raw.trim();
|
| 150 |
+
if (!trimmed) {
|
| 151 |
+
return false;
|
| 152 |
+
}
|
| 153 |
+
const candidate = stripBlueBubblesPrefix(trimmed);
|
| 154 |
+
if (!candidate) {
|
| 155 |
+
return false;
|
| 156 |
+
}
|
| 157 |
+
if (parseRawChatGuid(candidate)) {
|
| 158 |
+
return true;
|
| 159 |
+
}
|
| 160 |
+
const lowered = candidate.toLowerCase();
|
| 161 |
+
if (/^(imessage|sms|auto):/.test(lowered)) {
|
| 162 |
+
return true;
|
| 163 |
+
}
|
| 164 |
+
if (
|
| 165 |
+
/^(chat_id|chatid|chat|chat_guid|chatguid|guid|chat_identifier|chatidentifier|chatident|group):/.test(
|
| 166 |
+
lowered,
|
| 167 |
+
)
|
| 168 |
+
) {
|
| 169 |
+
return true;
|
| 170 |
+
}
|
| 171 |
+
// Recognize chat<digits> patterns (e.g., "chat660250192681427962") as chat IDs
|
| 172 |
+
if (/^chat\d+$/i.test(candidate)) {
|
| 173 |
+
return true;
|
| 174 |
+
}
|
| 175 |
+
if (looksLikeRawChatIdentifier(candidate)) {
|
| 176 |
+
return true;
|
| 177 |
+
}
|
| 178 |
+
if (candidate.includes("@")) {
|
| 179 |
+
return true;
|
| 180 |
+
}
|
| 181 |
+
const digitsOnly = candidate.replace(/[\s().-]/g, "");
|
| 182 |
+
if (/^\+?\d{3,}$/.test(digitsOnly)) {
|
| 183 |
+
return true;
|
| 184 |
+
}
|
| 185 |
+
if (normalized) {
|
| 186 |
+
const normalizedTrimmed = normalized.trim();
|
| 187 |
+
if (!normalizedTrimmed) {
|
| 188 |
+
return false;
|
| 189 |
+
}
|
| 190 |
+
const normalizedLower = normalizedTrimmed.toLowerCase();
|
| 191 |
+
if (
|
| 192 |
+
/^(imessage|sms|auto):/.test(normalizedLower) ||
|
| 193 |
+
/^(chat_id|chat_guid|chat_identifier):/.test(normalizedLower)
|
| 194 |
+
) {
|
| 195 |
+
return true;
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
return false;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
|
| 202 |
+
const trimmed = stripBlueBubblesPrefix(raw);
|
| 203 |
+
if (!trimmed) {
|
| 204 |
+
throw new Error("BlueBubbles target is required");
|
| 205 |
+
}
|
| 206 |
+
const lower = trimmed.toLowerCase();
|
| 207 |
+
|
| 208 |
+
for (const { prefix, service } of SERVICE_PREFIXES) {
|
| 209 |
+
if (lower.startsWith(prefix)) {
|
| 210 |
+
const remainder = stripPrefix(trimmed, prefix);
|
| 211 |
+
if (!remainder) {
|
| 212 |
+
throw new Error(`${prefix} target is required`);
|
| 213 |
+
}
|
| 214 |
+
const remainderLower = remainder.toLowerCase();
|
| 215 |
+
const isChatTarget =
|
| 216 |
+
CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
|
| 217 |
+
CHAT_GUID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
|
| 218 |
+
CHAT_IDENTIFIER_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
|
| 219 |
+
remainderLower.startsWith("group:");
|
| 220 |
+
if (isChatTarget) {
|
| 221 |
+
return parseBlueBubblesTarget(remainder);
|
| 222 |
+
}
|
| 223 |
+
return { kind: "handle", to: remainder, service };
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
for (const prefix of CHAT_ID_PREFIXES) {
|
| 228 |
+
if (lower.startsWith(prefix)) {
|
| 229 |
+
const value = stripPrefix(trimmed, prefix);
|
| 230 |
+
const chatId = Number.parseInt(value, 10);
|
| 231 |
+
if (!Number.isFinite(chatId)) {
|
| 232 |
+
throw new Error(`Invalid chat_id: ${value}`);
|
| 233 |
+
}
|
| 234 |
+
return { kind: "chat_id", chatId };
|
| 235 |
+
}
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
for (const prefix of CHAT_GUID_PREFIXES) {
|
| 239 |
+
if (lower.startsWith(prefix)) {
|
| 240 |
+
const value = stripPrefix(trimmed, prefix);
|
| 241 |
+
if (!value) {
|
| 242 |
+
throw new Error("chat_guid is required");
|
| 243 |
+
}
|
| 244 |
+
return { kind: "chat_guid", chatGuid: value };
|
| 245 |
+
}
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
for (const prefix of CHAT_IDENTIFIER_PREFIXES) {
|
| 249 |
+
if (lower.startsWith(prefix)) {
|
| 250 |
+
const value = stripPrefix(trimmed, prefix);
|
| 251 |
+
if (!value) {
|
| 252 |
+
throw new Error("chat_identifier is required");
|
| 253 |
+
}
|
| 254 |
+
return { kind: "chat_identifier", chatIdentifier: value };
|
| 255 |
+
}
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
if (lower.startsWith("group:")) {
|
| 259 |
+
const value = stripPrefix(trimmed, "group:");
|
| 260 |
+
const chatId = Number.parseInt(value, 10);
|
| 261 |
+
if (Number.isFinite(chatId)) {
|
| 262 |
+
return { kind: "chat_id", chatId };
|
| 263 |
+
}
|
| 264 |
+
if (!value) {
|
| 265 |
+
throw new Error("group target is required");
|
| 266 |
+
}
|
| 267 |
+
return { kind: "chat_guid", chatGuid: value };
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
const rawChatGuid = parseRawChatGuid(trimmed);
|
| 271 |
+
if (rawChatGuid) {
|
| 272 |
+
return { kind: "chat_guid", chatGuid: rawChatGuid };
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
// Handle chat<digits> pattern (e.g., "chat660250192681427962") as chat_identifier
|
| 276 |
+
// These are BlueBubbles chat identifiers (the third part of a chat GUID), not numeric IDs
|
| 277 |
+
if (/^chat\d+$/i.test(trimmed)) {
|
| 278 |
+
return { kind: "chat_identifier", chatIdentifier: trimmed };
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
// Handle UUID/hex chat identifiers (e.g., "8b9c1a10536d4d86a336ea03ab7151cc")
|
| 282 |
+
if (looksLikeRawChatIdentifier(trimmed)) {
|
| 283 |
+
return { kind: "chat_identifier", chatIdentifier: trimmed };
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
return { kind: "handle", to: trimmed, service: "auto" };
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget {
|
| 290 |
+
const trimmed = raw.trim();
|
| 291 |
+
if (!trimmed) {
|
| 292 |
+
return { kind: "handle", handle: "" };
|
| 293 |
+
}
|
| 294 |
+
const lower = trimmed.toLowerCase();
|
| 295 |
+
|
| 296 |
+
for (const { prefix } of SERVICE_PREFIXES) {
|
| 297 |
+
if (lower.startsWith(prefix)) {
|
| 298 |
+
const remainder = stripPrefix(trimmed, prefix);
|
| 299 |
+
if (!remainder) {
|
| 300 |
+
return { kind: "handle", handle: "" };
|
| 301 |
+
}
|
| 302 |
+
return parseBlueBubblesAllowTarget(remainder);
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
for (const prefix of CHAT_ID_PREFIXES) {
|
| 307 |
+
if (lower.startsWith(prefix)) {
|
| 308 |
+
const value = stripPrefix(trimmed, prefix);
|
| 309 |
+
const chatId = Number.parseInt(value, 10);
|
| 310 |
+
if (Number.isFinite(chatId)) {
|
| 311 |
+
return { kind: "chat_id", chatId };
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
for (const prefix of CHAT_GUID_PREFIXES) {
|
| 317 |
+
if (lower.startsWith(prefix)) {
|
| 318 |
+
const value = stripPrefix(trimmed, prefix);
|
| 319 |
+
if (value) {
|
| 320 |
+
return { kind: "chat_guid", chatGuid: value };
|
| 321 |
+
}
|
| 322 |
+
}
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
for (const prefix of CHAT_IDENTIFIER_PREFIXES) {
|
| 326 |
+
if (lower.startsWith(prefix)) {
|
| 327 |
+
const value = stripPrefix(trimmed, prefix);
|
| 328 |
+
if (value) {
|
| 329 |
+
return { kind: "chat_identifier", chatIdentifier: value };
|
| 330 |
+
}
|
| 331 |
+
}
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
if (lower.startsWith("group:")) {
|
| 335 |
+
const value = stripPrefix(trimmed, "group:");
|
| 336 |
+
const chatId = Number.parseInt(value, 10);
|
| 337 |
+
if (Number.isFinite(chatId)) {
|
| 338 |
+
return { kind: "chat_id", chatId };
|
| 339 |
+
}
|
| 340 |
+
if (value) {
|
| 341 |
+
return { kind: "chat_guid", chatGuid: value };
|
| 342 |
+
}
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
// Handle chat<digits> pattern (e.g., "chat660250192681427962") as chat_identifier
|
| 346 |
+
// These are BlueBubbles chat identifiers (the third part of a chat GUID), not numeric IDs
|
| 347 |
+
if (/^chat\d+$/i.test(trimmed)) {
|
| 348 |
+
return { kind: "chat_identifier", chatIdentifier: trimmed };
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
// Handle UUID/hex chat identifiers (e.g., "8b9c1a10536d4d86a336ea03ab7151cc")
|
| 352 |
+
if (looksLikeRawChatIdentifier(trimmed)) {
|
| 353 |
+
return { kind: "chat_identifier", chatIdentifier: trimmed };
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
return { kind: "handle", handle: normalizeBlueBubblesHandle(trimmed) };
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
export function isAllowedBlueBubblesSender(params: {
|
| 360 |
+
allowFrom: Array<string | number>;
|
| 361 |
+
sender: string;
|
| 362 |
+
chatId?: number | null;
|
| 363 |
+
chatGuid?: string | null;
|
| 364 |
+
chatIdentifier?: string | null;
|
| 365 |
+
}): boolean {
|
| 366 |
+
const allowFrom = params.allowFrom.map((entry) => String(entry).trim());
|
| 367 |
+
if (allowFrom.length === 0) {
|
| 368 |
+
return true;
|
| 369 |
+
}
|
| 370 |
+
if (allowFrom.includes("*")) {
|
| 371 |
+
return true;
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
const senderNormalized = normalizeBlueBubblesHandle(params.sender);
|
| 375 |
+
const chatId = params.chatId ?? undefined;
|
| 376 |
+
const chatGuid = params.chatGuid?.trim();
|
| 377 |
+
const chatIdentifier = params.chatIdentifier?.trim();
|
| 378 |
+
|
| 379 |
+
for (const entry of allowFrom) {
|
| 380 |
+
if (!entry) {
|
| 381 |
+
continue;
|
| 382 |
+
}
|
| 383 |
+
const parsed = parseBlueBubblesAllowTarget(entry);
|
| 384 |
+
if (parsed.kind === "chat_id" && chatId !== undefined) {
|
| 385 |
+
if (parsed.chatId === chatId) {
|
| 386 |
+
return true;
|
| 387 |
+
}
|
| 388 |
+
} else if (parsed.kind === "chat_guid" && chatGuid) {
|
| 389 |
+
if (parsed.chatGuid === chatGuid) {
|
| 390 |
+
return true;
|
| 391 |
+
}
|
| 392 |
+
} else if (parsed.kind === "chat_identifier" && chatIdentifier) {
|
| 393 |
+
if (parsed.chatIdentifier === chatIdentifier) {
|
| 394 |
+
return true;
|
| 395 |
+
}
|
| 396 |
+
} else if (parsed.kind === "handle" && senderNormalized) {
|
| 397 |
+
if (parsed.handle === senderNormalized) {
|
| 398 |
+
return true;
|
| 399 |
+
}
|
| 400 |
+
}
|
| 401 |
+
}
|
| 402 |
+
return false;
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
export function formatBlueBubblesChatTarget(params: {
|
| 406 |
+
chatId?: number | null;
|
| 407 |
+
chatGuid?: string | null;
|
| 408 |
+
chatIdentifier?: string | null;
|
| 409 |
+
}): string {
|
| 410 |
+
if (params.chatId && Number.isFinite(params.chatId)) {
|
| 411 |
+
return `chat_id:${params.chatId}`;
|
| 412 |
+
}
|
| 413 |
+
const guid = params.chatGuid?.trim();
|
| 414 |
+
if (guid) {
|
| 415 |
+
return `chat_guid:${guid}`;
|
| 416 |
+
}
|
| 417 |
+
const identifier = params.chatIdentifier?.trim();
|
| 418 |
+
if (identifier) {
|
| 419 |
+
return `chat_identifier:${identifier}`;
|
| 420 |
+
}
|
| 421 |
+
return "";
|
| 422 |
+
}
|
extensions/bluebubbles/src/types.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";
|
| 2 |
+
export type GroupPolicy = "open" | "disabled" | "allowlist";
|
| 3 |
+
|
| 4 |
+
export type BlueBubblesGroupConfig = {
|
| 5 |
+
/** If true, only respond in this group when mentioned. */
|
| 6 |
+
requireMention?: boolean;
|
| 7 |
+
/** Optional tool policy overrides for this group. */
|
| 8 |
+
tools?: { allow?: string[]; deny?: string[] };
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
export type BlueBubblesAccountConfig = {
|
| 12 |
+
/** Optional display name for this account (used in CLI/UI lists). */
|
| 13 |
+
name?: string;
|
| 14 |
+
/** Optional provider capability tags used for agent/runtime guidance. */
|
| 15 |
+
capabilities?: string[];
|
| 16 |
+
/** Allow channel-initiated config writes (default: true). */
|
| 17 |
+
configWrites?: boolean;
|
| 18 |
+
/** If false, do not start this BlueBubbles account. Default: true. */
|
| 19 |
+
enabled?: boolean;
|
| 20 |
+
/** Base URL for the BlueBubbles API. */
|
| 21 |
+
serverUrl?: string;
|
| 22 |
+
/** Password for BlueBubbles API authentication. */
|
| 23 |
+
password?: string;
|
| 24 |
+
/** Webhook path for the gateway HTTP server. */
|
| 25 |
+
webhookPath?: string;
|
| 26 |
+
/** Direct message access policy (default: pairing). */
|
| 27 |
+
dmPolicy?: DmPolicy;
|
| 28 |
+
allowFrom?: Array<string | number>;
|
| 29 |
+
/** Optional allowlist for group senders. */
|
| 30 |
+
groupAllowFrom?: Array<string | number>;
|
| 31 |
+
/** Group message handling policy. */
|
| 32 |
+
groupPolicy?: GroupPolicy;
|
| 33 |
+
/** Max group messages to keep as history context (0 disables). */
|
| 34 |
+
historyLimit?: number;
|
| 35 |
+
/** Max DM turns to keep as history context. */
|
| 36 |
+
dmHistoryLimit?: number;
|
| 37 |
+
/** Per-DM config overrides keyed by user ID. */
|
| 38 |
+
dms?: Record<string, unknown>;
|
| 39 |
+
/** Outbound text chunk size (chars). Default: 4000. */
|
| 40 |
+
textChunkLimit?: number;
|
| 41 |
+
/** Chunking mode: "newline" (default) splits on every newline; "length" splits by size. */
|
| 42 |
+
chunkMode?: "length" | "newline";
|
| 43 |
+
blockStreaming?: boolean;
|
| 44 |
+
/** Merge streamed block replies before sending. */
|
| 45 |
+
blockStreamingCoalesce?: Record<string, unknown>;
|
| 46 |
+
/** Max outbound media size in MB. */
|
| 47 |
+
mediaMaxMb?: number;
|
| 48 |
+
/** Send read receipts for incoming messages (default: true). */
|
| 49 |
+
sendReadReceipts?: boolean;
|
| 50 |
+
/** Per-group configuration keyed by chat GUID or identifier. */
|
| 51 |
+
groups?: Record<string, BlueBubblesGroupConfig>;
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
export type BlueBubblesActionConfig = {
|
| 55 |
+
reactions?: boolean;
|
| 56 |
+
edit?: boolean;
|
| 57 |
+
unsend?: boolean;
|
| 58 |
+
reply?: boolean;
|
| 59 |
+
sendWithEffect?: boolean;
|
| 60 |
+
renameGroup?: boolean;
|
| 61 |
+
addParticipant?: boolean;
|
| 62 |
+
removeParticipant?: boolean;
|
| 63 |
+
leaveGroup?: boolean;
|
| 64 |
+
sendAttachment?: boolean;
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
export type BlueBubblesConfig = {
|
| 68 |
+
/** Optional per-account BlueBubbles configuration (multi-account). */
|
| 69 |
+
accounts?: Record<string, BlueBubblesAccountConfig>;
|
| 70 |
+
/** Per-action tool gating (default: true for all). */
|
| 71 |
+
actions?: BlueBubblesActionConfig;
|
| 72 |
+
} & BlueBubblesAccountConfig;
|
| 73 |
+
|
| 74 |
+
export type BlueBubblesSendTarget =
|
| 75 |
+
| { kind: "chat_id"; chatId: number }
|
| 76 |
+
| { kind: "chat_guid"; chatGuid: string }
|
| 77 |
+
| { kind: "chat_identifier"; chatIdentifier: string }
|
| 78 |
+
| { kind: "handle"; address: string; service?: "imessage" | "sms" | "auto" };
|
| 79 |
+
|
| 80 |
+
export type BlueBubblesAttachment = {
|
| 81 |
+
guid?: string;
|
| 82 |
+
uti?: string;
|
| 83 |
+
mimeType?: string;
|
| 84 |
+
transferName?: string;
|
| 85 |
+
totalBytes?: number;
|
| 86 |
+
height?: number;
|
| 87 |
+
width?: number;
|
| 88 |
+
originalROWID?: number;
|
| 89 |
+
};
|
| 90 |
+
|
| 91 |
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
| 92 |
+
|
| 93 |
+
export function normalizeBlueBubblesServerUrl(raw: string): string {
|
| 94 |
+
const trimmed = raw.trim();
|
| 95 |
+
if (!trimmed) {
|
| 96 |
+
throw new Error("BlueBubbles serverUrl is required");
|
| 97 |
+
}
|
| 98 |
+
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
|
| 99 |
+
return withScheme.replace(/\/+$/, "");
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
export function buildBlueBubblesApiUrl(params: {
|
| 103 |
+
baseUrl: string;
|
| 104 |
+
path: string;
|
| 105 |
+
password?: string;
|
| 106 |
+
}): string {
|
| 107 |
+
const normalized = normalizeBlueBubblesServerUrl(params.baseUrl);
|
| 108 |
+
const url = new URL(params.path, `${normalized}/`);
|
| 109 |
+
if (params.password) {
|
| 110 |
+
url.searchParams.set("password", params.password);
|
| 111 |
+
}
|
| 112 |
+
return url.toString();
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
export async function blueBubblesFetchWithTimeout(
|
| 116 |
+
url: string,
|
| 117 |
+
init: RequestInit,
|
| 118 |
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
| 119 |
+
) {
|
| 120 |
+
const controller = new AbortController();
|
| 121 |
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
| 122 |
+
try {
|
| 123 |
+
return await fetch(url, { ...init, signal: controller.signal });
|
| 124 |
+
} finally {
|
| 125 |
+
clearTimeout(timer);
|
| 126 |
+
}
|
| 127 |
+
}
|
extensions/copilot-proxy/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copilot Proxy (OpenClaw plugin)
|
| 2 |
+
|
| 3 |
+
Provider plugin for the **Copilot Proxy** VS Code extension.
|
| 4 |
+
|
| 5 |
+
## Enable
|
| 6 |
+
|
| 7 |
+
Bundled plugins are disabled by default. Enable this one:
|
| 8 |
+
|
| 9 |
+
```bash
|
| 10 |
+
openclaw plugins enable copilot-proxy
|
| 11 |
+
```
|
| 12 |
+
|
| 13 |
+
Restart the Gateway after enabling.
|
| 14 |
+
|
| 15 |
+
## Authenticate
|
| 16 |
+
|
| 17 |
+
```bash
|
| 18 |
+
openclaw models auth login --provider copilot-proxy --set-default
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
## Notes
|
| 22 |
+
|
| 23 |
+
- Copilot Proxy must be running in VS Code.
|
| 24 |
+
- Base URL must include `/v1`.
|
extensions/copilot-proxy/index.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
| 2 |
+
|
| 3 |
+
const DEFAULT_BASE_URL = "http://localhost:3000/v1";
|
| 4 |
+
const DEFAULT_API_KEY = "n/a";
|
| 5 |
+
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
| 6 |
+
const DEFAULT_MAX_TOKENS = 8192;
|
| 7 |
+
const DEFAULT_MODEL_IDS = [
|
| 8 |
+
"gpt-5.2",
|
| 9 |
+
"gpt-5.2-codex",
|
| 10 |
+
"gpt-5.1",
|
| 11 |
+
"gpt-5.1-codex",
|
| 12 |
+
"gpt-5.1-codex-max",
|
| 13 |
+
"gpt-5-mini",
|
| 14 |
+
"claude-opus-4.5",
|
| 15 |
+
"claude-sonnet-4.5",
|
| 16 |
+
"claude-haiku-4.5",
|
| 17 |
+
"gemini-3-pro",
|
| 18 |
+
"gemini-3-flash",
|
| 19 |
+
"grok-code-fast-1",
|
| 20 |
+
] as const;
|
| 21 |
+
|
| 22 |
+
function normalizeBaseUrl(value: string): string {
|
| 23 |
+
const trimmed = value.trim();
|
| 24 |
+
if (!trimmed) {
|
| 25 |
+
return DEFAULT_BASE_URL;
|
| 26 |
+
}
|
| 27 |
+
let normalized = trimmed;
|
| 28 |
+
while (normalized.endsWith("/")) {
|
| 29 |
+
normalized = normalized.slice(0, -1);
|
| 30 |
+
}
|
| 31 |
+
if (!normalized.endsWith("/v1")) {
|
| 32 |
+
normalized = `${normalized}/v1`;
|
| 33 |
+
}
|
| 34 |
+
return normalized;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function validateBaseUrl(value: string): string | undefined {
|
| 38 |
+
const normalized = normalizeBaseUrl(value);
|
| 39 |
+
try {
|
| 40 |
+
new URL(normalized);
|
| 41 |
+
} catch {
|
| 42 |
+
return "Enter a valid URL";
|
| 43 |
+
}
|
| 44 |
+
return undefined;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
function parseModelIds(input: string): string[] {
|
| 48 |
+
const parsed = input
|
| 49 |
+
.split(/[\n,]/)
|
| 50 |
+
.map((model) => model.trim())
|
| 51 |
+
.filter(Boolean);
|
| 52 |
+
return Array.from(new Set(parsed));
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function buildModelDefinition(modelId: string) {
|
| 56 |
+
return {
|
| 57 |
+
id: modelId,
|
| 58 |
+
name: modelId,
|
| 59 |
+
api: "openai-completions",
|
| 60 |
+
reasoning: false,
|
| 61 |
+
input: ["text", "image"],
|
| 62 |
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
| 63 |
+
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
| 64 |
+
maxTokens: DEFAULT_MAX_TOKENS,
|
| 65 |
+
};
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
const copilotProxyPlugin = {
|
| 69 |
+
id: "copilot-proxy",
|
| 70 |
+
name: "Copilot Proxy",
|
| 71 |
+
description: "Local Copilot Proxy (VS Code LM) provider plugin",
|
| 72 |
+
configSchema: emptyPluginConfigSchema(),
|
| 73 |
+
register(api) {
|
| 74 |
+
api.registerProvider({
|
| 75 |
+
id: "copilot-proxy",
|
| 76 |
+
label: "Copilot Proxy",
|
| 77 |
+
docsPath: "/providers/models",
|
| 78 |
+
auth: [
|
| 79 |
+
{
|
| 80 |
+
id: "local",
|
| 81 |
+
label: "Local proxy",
|
| 82 |
+
hint: "Configure base URL + models for the Copilot Proxy server",
|
| 83 |
+
kind: "custom",
|
| 84 |
+
run: async (ctx) => {
|
| 85 |
+
const baseUrlInput = await ctx.prompter.text({
|
| 86 |
+
message: "Copilot Proxy base URL",
|
| 87 |
+
initialValue: DEFAULT_BASE_URL,
|
| 88 |
+
validate: validateBaseUrl,
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
const modelInput = await ctx.prompter.text({
|
| 92 |
+
message: "Model IDs (comma-separated)",
|
| 93 |
+
initialValue: DEFAULT_MODEL_IDS.join(", "),
|
| 94 |
+
validate: (value) =>
|
| 95 |
+
parseModelIds(value).length > 0 ? undefined : "Enter at least one model id",
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
const baseUrl = normalizeBaseUrl(baseUrlInput);
|
| 99 |
+
const modelIds = parseModelIds(modelInput);
|
| 100 |
+
const defaultModelId = modelIds[0] ?? DEFAULT_MODEL_IDS[0];
|
| 101 |
+
const defaultModelRef = `copilot-proxy/${defaultModelId}`;
|
| 102 |
+
|
| 103 |
+
return {
|
| 104 |
+
profiles: [
|
| 105 |
+
{
|
| 106 |
+
profileId: "copilot-proxy:local",
|
| 107 |
+
credential: {
|
| 108 |
+
type: "token",
|
| 109 |
+
provider: "copilot-proxy",
|
| 110 |
+
token: DEFAULT_API_KEY,
|
| 111 |
+
},
|
| 112 |
+
},
|
| 113 |
+
],
|
| 114 |
+
configPatch: {
|
| 115 |
+
models: {
|
| 116 |
+
providers: {
|
| 117 |
+
"copilot-proxy": {
|
| 118 |
+
baseUrl,
|
| 119 |
+
apiKey: DEFAULT_API_KEY,
|
| 120 |
+
api: "openai-completions",
|
| 121 |
+
authHeader: false,
|
| 122 |
+
models: modelIds.map((modelId) => buildModelDefinition(modelId)),
|
| 123 |
+
},
|
| 124 |
+
},
|
| 125 |
+
},
|
| 126 |
+
agents: {
|
| 127 |
+
defaults: {
|
| 128 |
+
models: Object.fromEntries(
|
| 129 |
+
modelIds.map((modelId) => [`copilot-proxy/${modelId}`, {}]),
|
| 130 |
+
),
|
| 131 |
+
},
|
| 132 |
+
},
|
| 133 |
+
},
|
| 134 |
+
defaultModel: defaultModelRef,
|
| 135 |
+
notes: [
|
| 136 |
+
"Start the Copilot Proxy VS Code extension before using these models.",
|
| 137 |
+
"Copilot Proxy serves /v1/chat/completions; base URL must include /v1.",
|
| 138 |
+
"Model availability depends on your Copilot plan; edit models.providers.copilot-proxy if needed.",
|
| 139 |
+
],
|
| 140 |
+
};
|
| 141 |
+
},
|
| 142 |
+
},
|
| 143 |
+
],
|
| 144 |
+
});
|
| 145 |
+
},
|
| 146 |
+
};
|
| 147 |
+
|
| 148 |
+
export default copilotProxyPlugin;
|
extensions/copilot-proxy/openclaw.plugin.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "copilot-proxy",
|
| 3 |
+
"providers": ["copilot-proxy"],
|
| 4 |
+
"configSchema": {
|
| 5 |
+
"type": "object",
|
| 6 |
+
"additionalProperties": false,
|
| 7 |
+
"properties": {}
|
| 8 |
+
}
|
| 9 |
+
}
|
extensions/copilot-proxy/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "@openclaw/copilot-proxy",
|
| 3 |
+
"version": "2026.1.30",
|
| 4 |
+
"description": "OpenClaw Copilot Proxy provider plugin",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"devDependencies": {
|
| 7 |
+
"openclaw": "workspace:*"
|
| 8 |
+
},
|
| 9 |
+
"openclaw": {
|
| 10 |
+
"extensions": [
|
| 11 |
+
"./index.ts"
|
| 12 |
+
]
|
| 13 |
+
}
|
| 14 |
+
}
|
extensions/diagnostics-otel/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
| 2 |
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
| 3 |
+
import { createDiagnosticsOtelService } from "./src/service.js";
|
| 4 |
+
|
| 5 |
+
const plugin = {
|
| 6 |
+
id: "diagnostics-otel",
|
| 7 |
+
name: "Diagnostics OpenTelemetry",
|
| 8 |
+
description: "Export diagnostics events to OpenTelemetry",
|
| 9 |
+
configSchema: emptyPluginConfigSchema(),
|
| 10 |
+
register(api: OpenClawPluginApi) {
|
| 11 |
+
api.registerService(createDiagnosticsOtelService());
|
| 12 |
+
},
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
export default plugin;
|
extensions/diagnostics-otel/openclaw.plugin.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "diagnostics-otel",
|
| 3 |
+
"configSchema": {
|
| 4 |
+
"type": "object",
|
| 5 |
+
"additionalProperties": false,
|
| 6 |
+
"properties": {}
|
| 7 |
+
}
|
| 8 |
+
}
|
extensions/diagnostics-otel/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "@openclaw/diagnostics-otel",
|
| 3 |
+
"version": "2026.1.30",
|
| 4 |
+
"description": "OpenClaw diagnostics OpenTelemetry exporter",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"dependencies": {
|
| 7 |
+
"@opentelemetry/api": "^1.9.0",
|
| 8 |
+
"@opentelemetry/api-logs": "^0.211.0",
|
| 9 |
+
"@opentelemetry/exporter-logs-otlp-http": "^0.211.0",
|
| 10 |
+
"@opentelemetry/exporter-metrics-otlp-http": "^0.211.0",
|
| 11 |
+
"@opentelemetry/exporter-trace-otlp-http": "^0.211.0",
|
| 12 |
+
"@opentelemetry/resources": "^2.5.0",
|
| 13 |
+
"@opentelemetry/sdk-logs": "^0.211.0",
|
| 14 |
+
"@opentelemetry/sdk-metrics": "^2.5.0",
|
| 15 |
+
"@opentelemetry/sdk-node": "^0.211.0",
|
| 16 |
+
"@opentelemetry/sdk-trace-base": "^2.5.0",
|
| 17 |
+
"@opentelemetry/semantic-conventions": "^1.39.0"
|
| 18 |
+
},
|
| 19 |
+
"devDependencies": {
|
| 20 |
+
"openclaw": "workspace:*"
|
| 21 |
+
},
|
| 22 |
+
"openclaw": {
|
| 23 |
+
"extensions": [
|
| 24 |
+
"./index.ts"
|
| 25 |
+
]
|
| 26 |
+
}
|
| 27 |
+
}
|
extensions/diagnostics-otel/src/service.test.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
| 2 |
+
|
| 3 |
+
const registerLogTransportMock = vi.hoisted(() => vi.fn());
|
| 4 |
+
|
| 5 |
+
const telemetryState = vi.hoisted(() => {
|
| 6 |
+
const counters = new Map<string, { add: ReturnType<typeof vi.fn> }>();
|
| 7 |
+
const histograms = new Map<string, { record: ReturnType<typeof vi.fn> }>();
|
| 8 |
+
const tracer = {
|
| 9 |
+
startSpan: vi.fn((_name: string, _opts?: unknown) => ({
|
| 10 |
+
end: vi.fn(),
|
| 11 |
+
setStatus: vi.fn(),
|
| 12 |
+
})),
|
| 13 |
+
};
|
| 14 |
+
const meter = {
|
| 15 |
+
createCounter: vi.fn((name: string) => {
|
| 16 |
+
const counter = { add: vi.fn() };
|
| 17 |
+
counters.set(name, counter);
|
| 18 |
+
return counter;
|
| 19 |
+
}),
|
| 20 |
+
createHistogram: vi.fn((name: string) => {
|
| 21 |
+
const histogram = { record: vi.fn() };
|
| 22 |
+
histograms.set(name, histogram);
|
| 23 |
+
return histogram;
|
| 24 |
+
}),
|
| 25 |
+
};
|
| 26 |
+
return { counters, histograms, tracer, meter };
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
const sdkStart = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
| 30 |
+
const sdkShutdown = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
| 31 |
+
const logEmit = vi.hoisted(() => vi.fn());
|
| 32 |
+
const logShutdown = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
| 33 |
+
|
| 34 |
+
vi.mock("@opentelemetry/api", () => ({
|
| 35 |
+
metrics: {
|
| 36 |
+
getMeter: () => telemetryState.meter,
|
| 37 |
+
},
|
| 38 |
+
trace: {
|
| 39 |
+
getTracer: () => telemetryState.tracer,
|
| 40 |
+
},
|
| 41 |
+
SpanStatusCode: {
|
| 42 |
+
ERROR: 2,
|
| 43 |
+
},
|
| 44 |
+
}));
|
| 45 |
+
|
| 46 |
+
vi.mock("@opentelemetry/sdk-node", () => ({
|
| 47 |
+
NodeSDK: class {
|
| 48 |
+
start = sdkStart;
|
| 49 |
+
shutdown = sdkShutdown;
|
| 50 |
+
},
|
| 51 |
+
}));
|
| 52 |
+
|
| 53 |
+
vi.mock("@opentelemetry/exporter-metrics-otlp-http", () => ({
|
| 54 |
+
OTLPMetricExporter: class {},
|
| 55 |
+
}));
|
| 56 |
+
|
| 57 |
+
vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({
|
| 58 |
+
OTLPTraceExporter: class {},
|
| 59 |
+
}));
|
| 60 |
+
|
| 61 |
+
vi.mock("@opentelemetry/exporter-logs-otlp-http", () => ({
|
| 62 |
+
OTLPLogExporter: class {},
|
| 63 |
+
}));
|
| 64 |
+
|
| 65 |
+
vi.mock("@opentelemetry/sdk-logs", () => ({
|
| 66 |
+
BatchLogRecordProcessor: class {},
|
| 67 |
+
LoggerProvider: class {
|
| 68 |
+
addLogRecordProcessor = vi.fn();
|
| 69 |
+
getLogger = vi.fn(() => ({
|
| 70 |
+
emit: logEmit,
|
| 71 |
+
}));
|
| 72 |
+
shutdown = logShutdown;
|
| 73 |
+
},
|
| 74 |
+
}));
|
| 75 |
+
|
| 76 |
+
vi.mock("@opentelemetry/sdk-metrics", () => ({
|
| 77 |
+
PeriodicExportingMetricReader: class {},
|
| 78 |
+
}));
|
| 79 |
+
|
| 80 |
+
vi.mock("@opentelemetry/sdk-trace-base", () => ({
|
| 81 |
+
ParentBasedSampler: class {},
|
| 82 |
+
TraceIdRatioBasedSampler: class {},
|
| 83 |
+
}));
|
| 84 |
+
|
| 85 |
+
vi.mock("@opentelemetry/resources", () => ({
|
| 86 |
+
Resource: class {
|
| 87 |
+
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
|
| 88 |
+
constructor(_value?: unknown) {}
|
| 89 |
+
},
|
| 90 |
+
}));
|
| 91 |
+
|
| 92 |
+
vi.mock("@opentelemetry/semantic-conventions", () => ({
|
| 93 |
+
SemanticResourceAttributes: {
|
| 94 |
+
SERVICE_NAME: "service.name",
|
| 95 |
+
},
|
| 96 |
+
}));
|
| 97 |
+
|
| 98 |
+
vi.mock("openclaw/plugin-sdk", async () => {
|
| 99 |
+
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk")>("openclaw/plugin-sdk");
|
| 100 |
+
return {
|
| 101 |
+
...actual,
|
| 102 |
+
registerLogTransport: registerLogTransportMock,
|
| 103 |
+
};
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
+
import { emitDiagnosticEvent } from "openclaw/plugin-sdk";
|
| 107 |
+
import { createDiagnosticsOtelService } from "./service.js";
|
| 108 |
+
|
| 109 |
+
describe("diagnostics-otel service", () => {
|
| 110 |
+
beforeEach(() => {
|
| 111 |
+
telemetryState.counters.clear();
|
| 112 |
+
telemetryState.histograms.clear();
|
| 113 |
+
telemetryState.tracer.startSpan.mockClear();
|
| 114 |
+
telemetryState.meter.createCounter.mockClear();
|
| 115 |
+
telemetryState.meter.createHistogram.mockClear();
|
| 116 |
+
sdkStart.mockClear();
|
| 117 |
+
sdkShutdown.mockClear();
|
| 118 |
+
logEmit.mockClear();
|
| 119 |
+
logShutdown.mockClear();
|
| 120 |
+
registerLogTransportMock.mockReset();
|
| 121 |
+
});
|
| 122 |
+
|
| 123 |
+
test("records message-flow metrics and spans", async () => {
|
| 124 |
+
const registeredTransports: Array<(logObj: Record<string, unknown>) => void> = [];
|
| 125 |
+
const stopTransport = vi.fn();
|
| 126 |
+
registerLogTransportMock.mockImplementation((transport) => {
|
| 127 |
+
registeredTransports.push(transport);
|
| 128 |
+
return stopTransport;
|
| 129 |
+
});
|
| 130 |
+
|
| 131 |
+
const service = createDiagnosticsOtelService();
|
| 132 |
+
await service.start({
|
| 133 |
+
config: {
|
| 134 |
+
diagnostics: {
|
| 135 |
+
enabled: true,
|
| 136 |
+
otel: {
|
| 137 |
+
enabled: true,
|
| 138 |
+
endpoint: "http://otel-collector:4318",
|
| 139 |
+
protocol: "http/protobuf",
|
| 140 |
+
traces: true,
|
| 141 |
+
metrics: true,
|
| 142 |
+
logs: true,
|
| 143 |
+
},
|
| 144 |
+
},
|
| 145 |
+
},
|
| 146 |
+
logger: {
|
| 147 |
+
info: vi.fn(),
|
| 148 |
+
warn: vi.fn(),
|
| 149 |
+
error: vi.fn(),
|
| 150 |
+
debug: vi.fn(),
|
| 151 |
+
},
|
| 152 |
+
});
|
| 153 |
+
|
| 154 |
+
emitDiagnosticEvent({
|
| 155 |
+
type: "webhook.received",
|
| 156 |
+
channel: "telegram",
|
| 157 |
+
updateType: "telegram-post",
|
| 158 |
+
});
|
| 159 |
+
emitDiagnosticEvent({
|
| 160 |
+
type: "webhook.processed",
|
| 161 |
+
channel: "telegram",
|
| 162 |
+
updateType: "telegram-post",
|
| 163 |
+
durationMs: 120,
|
| 164 |
+
});
|
| 165 |
+
emitDiagnosticEvent({
|
| 166 |
+
type: "message.queued",
|
| 167 |
+
channel: "telegram",
|
| 168 |
+
source: "telegram",
|
| 169 |
+
queueDepth: 2,
|
| 170 |
+
});
|
| 171 |
+
emitDiagnosticEvent({
|
| 172 |
+
type: "message.processed",
|
| 173 |
+
channel: "telegram",
|
| 174 |
+
outcome: "completed",
|
| 175 |
+
durationMs: 55,
|
| 176 |
+
});
|
| 177 |
+
emitDiagnosticEvent({
|
| 178 |
+
type: "queue.lane.dequeue",
|
| 179 |
+
lane: "main",
|
| 180 |
+
queueSize: 3,
|
| 181 |
+
waitMs: 10,
|
| 182 |
+
});
|
| 183 |
+
emitDiagnosticEvent({
|
| 184 |
+
type: "session.stuck",
|
| 185 |
+
state: "processing",
|
| 186 |
+
ageMs: 125_000,
|
| 187 |
+
});
|
| 188 |
+
emitDiagnosticEvent({
|
| 189 |
+
type: "run.attempt",
|
| 190 |
+
runId: "run-1",
|
| 191 |
+
attempt: 2,
|
| 192 |
+
});
|
| 193 |
+
|
| 194 |
+
expect(telemetryState.counters.get("openclaw.webhook.received")?.add).toHaveBeenCalled();
|
| 195 |
+
expect(
|
| 196 |
+
telemetryState.histograms.get("openclaw.webhook.duration_ms")?.record,
|
| 197 |
+
).toHaveBeenCalled();
|
| 198 |
+
expect(telemetryState.counters.get("openclaw.message.queued")?.add).toHaveBeenCalled();
|
| 199 |
+
expect(telemetryState.counters.get("openclaw.message.processed")?.add).toHaveBeenCalled();
|
| 200 |
+
expect(
|
| 201 |
+
telemetryState.histograms.get("openclaw.message.duration_ms")?.record,
|
| 202 |
+
).toHaveBeenCalled();
|
| 203 |
+
expect(telemetryState.histograms.get("openclaw.queue.wait_ms")?.record).toHaveBeenCalled();
|
| 204 |
+
expect(telemetryState.counters.get("openclaw.session.stuck")?.add).toHaveBeenCalled();
|
| 205 |
+
expect(
|
| 206 |
+
telemetryState.histograms.get("openclaw.session.stuck_age_ms")?.record,
|
| 207 |
+
).toHaveBeenCalled();
|
| 208 |
+
expect(telemetryState.counters.get("openclaw.run.attempt")?.add).toHaveBeenCalled();
|
| 209 |
+
|
| 210 |
+
const spanNames = telemetryState.tracer.startSpan.mock.calls.map((call) => call[0]);
|
| 211 |
+
expect(spanNames).toContain("openclaw.webhook.processed");
|
| 212 |
+
expect(spanNames).toContain("openclaw.message.processed");
|
| 213 |
+
expect(spanNames).toContain("openclaw.session.stuck");
|
| 214 |
+
|
| 215 |
+
expect(registerLogTransportMock).toHaveBeenCalledTimes(1);
|
| 216 |
+
expect(registeredTransports).toHaveLength(1);
|
| 217 |
+
registeredTransports[0]?.({
|
| 218 |
+
0: '{"subsystem":"diagnostic"}',
|
| 219 |
+
1: "hello",
|
| 220 |
+
_meta: { logLevelName: "INFO", date: new Date() },
|
| 221 |
+
});
|
| 222 |
+
expect(logEmit).toHaveBeenCalled();
|
| 223 |
+
|
| 224 |
+
await service.stop?.();
|
| 225 |
+
});
|
| 226 |
+
});
|
extensions/diagnostics-otel/src/service.ts
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { SeverityNumber } from "@opentelemetry/api-logs";
|
| 2 |
+
import type { DiagnosticEventPayload, OpenClawPluginService } from "openclaw/plugin-sdk";
|
| 3 |
+
import { metrics, trace, SpanStatusCode } from "@opentelemetry/api";
|
| 4 |
+
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
|
| 5 |
+
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
|
| 6 |
+
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
| 7 |
+
import { Resource } from "@opentelemetry/resources";
|
| 8 |
+
import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
|
| 9 |
+
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
|
| 10 |
+
import { NodeSDK } from "@opentelemetry/sdk-node";
|
| 11 |
+
import { ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base";
|
| 12 |
+
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
|
| 13 |
+
import { onDiagnosticEvent, registerLogTransport } from "openclaw/plugin-sdk";
|
| 14 |
+
|
| 15 |
+
const DEFAULT_SERVICE_NAME = "openclaw";
|
| 16 |
+
|
| 17 |
+
function normalizeEndpoint(endpoint?: string): string | undefined {
|
| 18 |
+
const trimmed = endpoint?.trim();
|
| 19 |
+
return trimmed ? trimmed.replace(/\/+$/, "") : undefined;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function resolveOtelUrl(endpoint: string | undefined, path: string): string | undefined {
|
| 23 |
+
if (!endpoint) {
|
| 24 |
+
return undefined;
|
| 25 |
+
}
|
| 26 |
+
if (endpoint.includes("/v1/")) {
|
| 27 |
+
return endpoint;
|
| 28 |
+
}
|
| 29 |
+
return `${endpoint}/${path}`;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function resolveSampleRate(value: number | undefined): number | undefined {
|
| 33 |
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
| 34 |
+
return undefined;
|
| 35 |
+
}
|
| 36 |
+
if (value < 0 || value > 1) {
|
| 37 |
+
return undefined;
|
| 38 |
+
}
|
| 39 |
+
return value;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export function createDiagnosticsOtelService(): OpenClawPluginService {
|
| 43 |
+
let sdk: NodeSDK | null = null;
|
| 44 |
+
let logProvider: LoggerProvider | null = null;
|
| 45 |
+
let stopLogTransport: (() => void) | null = null;
|
| 46 |
+
let unsubscribe: (() => void) | null = null;
|
| 47 |
+
|
| 48 |
+
return {
|
| 49 |
+
id: "diagnostics-otel",
|
| 50 |
+
async start(ctx) {
|
| 51 |
+
const cfg = ctx.config.diagnostics;
|
| 52 |
+
const otel = cfg?.otel;
|
| 53 |
+
if (!cfg?.enabled || !otel?.enabled) {
|
| 54 |
+
return;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
const protocol = otel.protocol ?? process.env.OTEL_EXPORTER_OTLP_PROTOCOL ?? "http/protobuf";
|
| 58 |
+
if (protocol !== "http/protobuf") {
|
| 59 |
+
ctx.logger.warn(`diagnostics-otel: unsupported protocol ${protocol}`);
|
| 60 |
+
return;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
const endpoint = normalizeEndpoint(otel.endpoint ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT);
|
| 64 |
+
const headers = otel.headers ?? undefined;
|
| 65 |
+
const serviceName =
|
| 66 |
+
otel.serviceName?.trim() || process.env.OTEL_SERVICE_NAME || DEFAULT_SERVICE_NAME;
|
| 67 |
+
const sampleRate = resolveSampleRate(otel.sampleRate);
|
| 68 |
+
|
| 69 |
+
const tracesEnabled = otel.traces !== false;
|
| 70 |
+
const metricsEnabled = otel.metrics !== false;
|
| 71 |
+
const logsEnabled = otel.logs === true;
|
| 72 |
+
if (!tracesEnabled && !metricsEnabled && !logsEnabled) {
|
| 73 |
+
return;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
const resource = new Resource({
|
| 77 |
+
[SemanticResourceAttributes.SERVICE_NAME]: serviceName,
|
| 78 |
+
});
|
| 79 |
+
|
| 80 |
+
const traceUrl = resolveOtelUrl(endpoint, "v1/traces");
|
| 81 |
+
const metricUrl = resolveOtelUrl(endpoint, "v1/metrics");
|
| 82 |
+
const logUrl = resolveOtelUrl(endpoint, "v1/logs");
|
| 83 |
+
const traceExporter = tracesEnabled
|
| 84 |
+
? new OTLPTraceExporter({
|
| 85 |
+
...(traceUrl ? { url: traceUrl } : {}),
|
| 86 |
+
...(headers ? { headers } : {}),
|
| 87 |
+
})
|
| 88 |
+
: undefined;
|
| 89 |
+
|
| 90 |
+
const metricExporter = metricsEnabled
|
| 91 |
+
? new OTLPMetricExporter({
|
| 92 |
+
...(metricUrl ? { url: metricUrl } : {}),
|
| 93 |
+
...(headers ? { headers } : {}),
|
| 94 |
+
})
|
| 95 |
+
: undefined;
|
| 96 |
+
|
| 97 |
+
const metricReader = metricExporter
|
| 98 |
+
? new PeriodicExportingMetricReader({
|
| 99 |
+
exporter: metricExporter,
|
| 100 |
+
...(typeof otel.flushIntervalMs === "number"
|
| 101 |
+
? { exportIntervalMillis: Math.max(1000, otel.flushIntervalMs) }
|
| 102 |
+
: {}),
|
| 103 |
+
})
|
| 104 |
+
: undefined;
|
| 105 |
+
|
| 106 |
+
if (tracesEnabled || metricsEnabled) {
|
| 107 |
+
sdk = new NodeSDK({
|
| 108 |
+
resource,
|
| 109 |
+
...(traceExporter ? { traceExporter } : {}),
|
| 110 |
+
...(metricReader ? { metricReader } : {}),
|
| 111 |
+
...(sampleRate !== undefined
|
| 112 |
+
? {
|
| 113 |
+
sampler: new ParentBasedSampler({
|
| 114 |
+
root: new TraceIdRatioBasedSampler(sampleRate),
|
| 115 |
+
}),
|
| 116 |
+
}
|
| 117 |
+
: {}),
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
sdk.start();
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
const logSeverityMap: Record<string, SeverityNumber> = {
|
| 124 |
+
TRACE: 1 as SeverityNumber,
|
| 125 |
+
DEBUG: 5 as SeverityNumber,
|
| 126 |
+
INFO: 9 as SeverityNumber,
|
| 127 |
+
WARN: 13 as SeverityNumber,
|
| 128 |
+
ERROR: 17 as SeverityNumber,
|
| 129 |
+
FATAL: 21 as SeverityNumber,
|
| 130 |
+
};
|
| 131 |
+
|
| 132 |
+
const meter = metrics.getMeter("openclaw");
|
| 133 |
+
const tracer = trace.getTracer("openclaw");
|
| 134 |
+
|
| 135 |
+
const tokensCounter = meter.createCounter("openclaw.tokens", {
|
| 136 |
+
unit: "1",
|
| 137 |
+
description: "Token usage by type",
|
| 138 |
+
});
|
| 139 |
+
const costCounter = meter.createCounter("openclaw.cost.usd", {
|
| 140 |
+
unit: "1",
|
| 141 |
+
description: "Estimated model cost (USD)",
|
| 142 |
+
});
|
| 143 |
+
const durationHistogram = meter.createHistogram("openclaw.run.duration_ms", {
|
| 144 |
+
unit: "ms",
|
| 145 |
+
description: "Agent run duration",
|
| 146 |
+
});
|
| 147 |
+
const contextHistogram = meter.createHistogram("openclaw.context.tokens", {
|
| 148 |
+
unit: "1",
|
| 149 |
+
description: "Context window size and usage",
|
| 150 |
+
});
|
| 151 |
+
const webhookReceivedCounter = meter.createCounter("openclaw.webhook.received", {
|
| 152 |
+
unit: "1",
|
| 153 |
+
description: "Webhook requests received",
|
| 154 |
+
});
|
| 155 |
+
const webhookErrorCounter = meter.createCounter("openclaw.webhook.error", {
|
| 156 |
+
unit: "1",
|
| 157 |
+
description: "Webhook processing errors",
|
| 158 |
+
});
|
| 159 |
+
const webhookDurationHistogram = meter.createHistogram("openclaw.webhook.duration_ms", {
|
| 160 |
+
unit: "ms",
|
| 161 |
+
description: "Webhook processing duration",
|
| 162 |
+
});
|
| 163 |
+
const messageQueuedCounter = meter.createCounter("openclaw.message.queued", {
|
| 164 |
+
unit: "1",
|
| 165 |
+
description: "Messages queued for processing",
|
| 166 |
+
});
|
| 167 |
+
const messageProcessedCounter = meter.createCounter("openclaw.message.processed", {
|
| 168 |
+
unit: "1",
|
| 169 |
+
description: "Messages processed by outcome",
|
| 170 |
+
});
|
| 171 |
+
const messageDurationHistogram = meter.createHistogram("openclaw.message.duration_ms", {
|
| 172 |
+
unit: "ms",
|
| 173 |
+
description: "Message processing duration",
|
| 174 |
+
});
|
| 175 |
+
const queueDepthHistogram = meter.createHistogram("openclaw.queue.depth", {
|
| 176 |
+
unit: "1",
|
| 177 |
+
description: "Queue depth on enqueue/dequeue",
|
| 178 |
+
});
|
| 179 |
+
const queueWaitHistogram = meter.createHistogram("openclaw.queue.wait_ms", {
|
| 180 |
+
unit: "ms",
|
| 181 |
+
description: "Queue wait time before execution",
|
| 182 |
+
});
|
| 183 |
+
const laneEnqueueCounter = meter.createCounter("openclaw.queue.lane.enqueue", {
|
| 184 |
+
unit: "1",
|
| 185 |
+
description: "Command queue lane enqueue events",
|
| 186 |
+
});
|
| 187 |
+
const laneDequeueCounter = meter.createCounter("openclaw.queue.lane.dequeue", {
|
| 188 |
+
unit: "1",
|
| 189 |
+
description: "Command queue lane dequeue events",
|
| 190 |
+
});
|
| 191 |
+
const sessionStateCounter = meter.createCounter("openclaw.session.state", {
|
| 192 |
+
unit: "1",
|
| 193 |
+
description: "Session state transitions",
|
| 194 |
+
});
|
| 195 |
+
const sessionStuckCounter = meter.createCounter("openclaw.session.stuck", {
|
| 196 |
+
unit: "1",
|
| 197 |
+
description: "Sessions stuck in processing",
|
| 198 |
+
});
|
| 199 |
+
const sessionStuckAgeHistogram = meter.createHistogram("openclaw.session.stuck_age_ms", {
|
| 200 |
+
unit: "ms",
|
| 201 |
+
description: "Age of stuck sessions",
|
| 202 |
+
});
|
| 203 |
+
const runAttemptCounter = meter.createCounter("openclaw.run.attempt", {
|
| 204 |
+
unit: "1",
|
| 205 |
+
description: "Run attempts",
|
| 206 |
+
});
|
| 207 |
+
|
| 208 |
+
if (logsEnabled) {
|
| 209 |
+
const logExporter = new OTLPLogExporter({
|
| 210 |
+
...(logUrl ? { url: logUrl } : {}),
|
| 211 |
+
...(headers ? { headers } : {}),
|
| 212 |
+
});
|
| 213 |
+
logProvider = new LoggerProvider({ resource });
|
| 214 |
+
logProvider.addLogRecordProcessor(
|
| 215 |
+
new BatchLogRecordProcessor(
|
| 216 |
+
logExporter,
|
| 217 |
+
typeof otel.flushIntervalMs === "number"
|
| 218 |
+
? { scheduledDelayMillis: Math.max(1000, otel.flushIntervalMs) }
|
| 219 |
+
: {},
|
| 220 |
+
),
|
| 221 |
+
);
|
| 222 |
+
const otelLogger = logProvider.getLogger("openclaw");
|
| 223 |
+
|
| 224 |
+
stopLogTransport = registerLogTransport((logObj) => {
|
| 225 |
+
const safeStringify = (value: unknown) => {
|
| 226 |
+
try {
|
| 227 |
+
return JSON.stringify(value);
|
| 228 |
+
} catch {
|
| 229 |
+
return String(value);
|
| 230 |
+
}
|
| 231 |
+
};
|
| 232 |
+
const meta = (logObj as Record<string, unknown>)._meta as
|
| 233 |
+
| {
|
| 234 |
+
logLevelName?: string;
|
| 235 |
+
date?: Date;
|
| 236 |
+
name?: string;
|
| 237 |
+
parentNames?: string[];
|
| 238 |
+
path?: {
|
| 239 |
+
filePath?: string;
|
| 240 |
+
fileLine?: string;
|
| 241 |
+
fileColumn?: string;
|
| 242 |
+
filePathWithLine?: string;
|
| 243 |
+
method?: string;
|
| 244 |
+
};
|
| 245 |
+
}
|
| 246 |
+
| undefined;
|
| 247 |
+
const logLevelName = meta?.logLevelName ?? "INFO";
|
| 248 |
+
const severityNumber = logSeverityMap[logLevelName] ?? (9 as SeverityNumber);
|
| 249 |
+
|
| 250 |
+
const numericArgs = Object.entries(logObj)
|
| 251 |
+
.filter(([key]) => /^\d+$/.test(key))
|
| 252 |
+
.toSorted((a, b) => Number(a[0]) - Number(b[0]))
|
| 253 |
+
.map(([, value]) => value);
|
| 254 |
+
|
| 255 |
+
let bindings: Record<string, unknown> | undefined;
|
| 256 |
+
if (typeof numericArgs[0] === "string" && numericArgs[0].trim().startsWith("{")) {
|
| 257 |
+
try {
|
| 258 |
+
const parsed = JSON.parse(numericArgs[0]);
|
| 259 |
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
| 260 |
+
bindings = parsed as Record<string, unknown>;
|
| 261 |
+
numericArgs.shift();
|
| 262 |
+
}
|
| 263 |
+
} catch {
|
| 264 |
+
// ignore malformed json bindings
|
| 265 |
+
}
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
let message = "";
|
| 269 |
+
if (numericArgs.length > 0 && typeof numericArgs[numericArgs.length - 1] === "string") {
|
| 270 |
+
message = String(numericArgs.pop());
|
| 271 |
+
} else if (numericArgs.length === 1) {
|
| 272 |
+
message = safeStringify(numericArgs[0]);
|
| 273 |
+
numericArgs.length = 0;
|
| 274 |
+
}
|
| 275 |
+
if (!message) {
|
| 276 |
+
message = "log";
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
const attributes: Record<string, string | number | boolean> = {
|
| 280 |
+
"openclaw.log.level": logLevelName,
|
| 281 |
+
};
|
| 282 |
+
if (meta?.name) {
|
| 283 |
+
attributes["openclaw.logger"] = meta.name;
|
| 284 |
+
}
|
| 285 |
+
if (meta?.parentNames?.length) {
|
| 286 |
+
attributes["openclaw.logger.parents"] = meta.parentNames.join(".");
|
| 287 |
+
}
|
| 288 |
+
if (bindings) {
|
| 289 |
+
for (const [key, value] of Object.entries(bindings)) {
|
| 290 |
+
if (
|
| 291 |
+
typeof value === "string" ||
|
| 292 |
+
typeof value === "number" ||
|
| 293 |
+
typeof value === "boolean"
|
| 294 |
+
) {
|
| 295 |
+
attributes[`openclaw.${key}`] = value;
|
| 296 |
+
} else if (value != null) {
|
| 297 |
+
attributes[`openclaw.${key}`] = safeStringify(value);
|
| 298 |
+
}
|
| 299 |
+
}
|
| 300 |
+
}
|
| 301 |
+
if (numericArgs.length > 0) {
|
| 302 |
+
attributes["openclaw.log.args"] = safeStringify(numericArgs);
|
| 303 |
+
}
|
| 304 |
+
if (meta?.path?.filePath) {
|
| 305 |
+
attributes["code.filepath"] = meta.path.filePath;
|
| 306 |
+
}
|
| 307 |
+
if (meta?.path?.fileLine) {
|
| 308 |
+
attributes["code.lineno"] = Number(meta.path.fileLine);
|
| 309 |
+
}
|
| 310 |
+
if (meta?.path?.method) {
|
| 311 |
+
attributes["code.function"] = meta.path.method;
|
| 312 |
+
}
|
| 313 |
+
if (meta?.path?.filePathWithLine) {
|
| 314 |
+
attributes["openclaw.code.location"] = meta.path.filePathWithLine;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
otelLogger.emit({
|
| 318 |
+
body: message,
|
| 319 |
+
severityText: logLevelName,
|
| 320 |
+
severityNumber,
|
| 321 |
+
attributes,
|
| 322 |
+
timestamp: meta?.date ?? new Date(),
|
| 323 |
+
});
|
| 324 |
+
});
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
const spanWithDuration = (
|
| 328 |
+
name: string,
|
| 329 |
+
attributes: Record<string, string | number>,
|
| 330 |
+
durationMs?: number,
|
| 331 |
+
) => {
|
| 332 |
+
const startTime =
|
| 333 |
+
typeof durationMs === "number" ? Date.now() - Math.max(0, durationMs) : undefined;
|
| 334 |
+
const span = tracer.startSpan(name, {
|
| 335 |
+
attributes,
|
| 336 |
+
...(startTime ? { startTime } : {}),
|
| 337 |
+
});
|
| 338 |
+
return span;
|
| 339 |
+
};
|
| 340 |
+
|
| 341 |
+
const recordModelUsage = (evt: Extract<DiagnosticEventPayload, { type: "model.usage" }>) => {
|
| 342 |
+
const attrs = {
|
| 343 |
+
"openclaw.channel": evt.channel ?? "unknown",
|
| 344 |
+
"openclaw.provider": evt.provider ?? "unknown",
|
| 345 |
+
"openclaw.model": evt.model ?? "unknown",
|
| 346 |
+
};
|
| 347 |
+
|
| 348 |
+
const usage = evt.usage;
|
| 349 |
+
if (usage.input) {
|
| 350 |
+
tokensCounter.add(usage.input, { ...attrs, "openclaw.token": "input" });
|
| 351 |
+
}
|
| 352 |
+
if (usage.output) {
|
| 353 |
+
tokensCounter.add(usage.output, { ...attrs, "openclaw.token": "output" });
|
| 354 |
+
}
|
| 355 |
+
if (usage.cacheRead) {
|
| 356 |
+
tokensCounter.add(usage.cacheRead, { ...attrs, "openclaw.token": "cache_read" });
|
| 357 |
+
}
|
| 358 |
+
if (usage.cacheWrite) {
|
| 359 |
+
tokensCounter.add(usage.cacheWrite, { ...attrs, "openclaw.token": "cache_write" });
|
| 360 |
+
}
|
| 361 |
+
if (usage.promptTokens) {
|
| 362 |
+
tokensCounter.add(usage.promptTokens, { ...attrs, "openclaw.token": "prompt" });
|
| 363 |
+
}
|
| 364 |
+
if (usage.total) {
|
| 365 |
+
tokensCounter.add(usage.total, { ...attrs, "openclaw.token": "total" });
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
if (evt.costUsd) {
|
| 369 |
+
costCounter.add(evt.costUsd, attrs);
|
| 370 |
+
}
|
| 371 |
+
if (evt.durationMs) {
|
| 372 |
+
durationHistogram.record(evt.durationMs, attrs);
|
| 373 |
+
}
|
| 374 |
+
if (evt.context?.limit) {
|
| 375 |
+
contextHistogram.record(evt.context.limit, {
|
| 376 |
+
...attrs,
|
| 377 |
+
"openclaw.context": "limit",
|
| 378 |
+
});
|
| 379 |
+
}
|
| 380 |
+
if (evt.context?.used) {
|
| 381 |
+
contextHistogram.record(evt.context.used, {
|
| 382 |
+
...attrs,
|
| 383 |
+
"openclaw.context": "used",
|
| 384 |
+
});
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
if (!tracesEnabled) {
|
| 388 |
+
return;
|
| 389 |
+
}
|
| 390 |
+
const spanAttrs: Record<string, string | number> = {
|
| 391 |
+
...attrs,
|
| 392 |
+
"openclaw.sessionKey": evt.sessionKey ?? "",
|
| 393 |
+
"openclaw.sessionId": evt.sessionId ?? "",
|
| 394 |
+
"openclaw.tokens.input": usage.input ?? 0,
|
| 395 |
+
"openclaw.tokens.output": usage.output ?? 0,
|
| 396 |
+
"openclaw.tokens.cache_read": usage.cacheRead ?? 0,
|
| 397 |
+
"openclaw.tokens.cache_write": usage.cacheWrite ?? 0,
|
| 398 |
+
"openclaw.tokens.total": usage.total ?? 0,
|
| 399 |
+
};
|
| 400 |
+
|
| 401 |
+
const span = spanWithDuration("openclaw.model.usage", spanAttrs, evt.durationMs);
|
| 402 |
+
span.end();
|
| 403 |
+
};
|
| 404 |
+
|
| 405 |
+
const recordWebhookReceived = (
|
| 406 |
+
evt: Extract<DiagnosticEventPayload, { type: "webhook.received" }>,
|
| 407 |
+
) => {
|
| 408 |
+
const attrs = {
|
| 409 |
+
"openclaw.channel": evt.channel ?? "unknown",
|
| 410 |
+
"openclaw.webhook": evt.updateType ?? "unknown",
|
| 411 |
+
};
|
| 412 |
+
webhookReceivedCounter.add(1, attrs);
|
| 413 |
+
};
|
| 414 |
+
|
| 415 |
+
const recordWebhookProcessed = (
|
| 416 |
+
evt: Extract<DiagnosticEventPayload, { type: "webhook.processed" }>,
|
| 417 |
+
) => {
|
| 418 |
+
const attrs = {
|
| 419 |
+
"openclaw.channel": evt.channel ?? "unknown",
|
| 420 |
+
"openclaw.webhook": evt.updateType ?? "unknown",
|
| 421 |
+
};
|
| 422 |
+
if (typeof evt.durationMs === "number") {
|
| 423 |
+
webhookDurationHistogram.record(evt.durationMs, attrs);
|
| 424 |
+
}
|
| 425 |
+
if (!tracesEnabled) {
|
| 426 |
+
return;
|
| 427 |
+
}
|
| 428 |
+
const spanAttrs: Record<string, string | number> = { ...attrs };
|
| 429 |
+
if (evt.chatId !== undefined) {
|
| 430 |
+
spanAttrs["openclaw.chatId"] = String(evt.chatId);
|
| 431 |
+
}
|
| 432 |
+
const span = spanWithDuration("openclaw.webhook.processed", spanAttrs, evt.durationMs);
|
| 433 |
+
span.end();
|
| 434 |
+
};
|
| 435 |
+
|
| 436 |
+
const recordWebhookError = (
|
| 437 |
+
evt: Extract<DiagnosticEventPayload, { type: "webhook.error" }>,
|
| 438 |
+
) => {
|
| 439 |
+
const attrs = {
|
| 440 |
+
"openclaw.channel": evt.channel ?? "unknown",
|
| 441 |
+
"openclaw.webhook": evt.updateType ?? "unknown",
|
| 442 |
+
};
|
| 443 |
+
webhookErrorCounter.add(1, attrs);
|
| 444 |
+
if (!tracesEnabled) {
|
| 445 |
+
return;
|
| 446 |
+
}
|
| 447 |
+
const spanAttrs: Record<string, string | number> = {
|
| 448 |
+
...attrs,
|
| 449 |
+
"openclaw.error": evt.error,
|
| 450 |
+
};
|
| 451 |
+
if (evt.chatId !== undefined) {
|
| 452 |
+
spanAttrs["openclaw.chatId"] = String(evt.chatId);
|
| 453 |
+
}
|
| 454 |
+
const span = tracer.startSpan("openclaw.webhook.error", {
|
| 455 |
+
attributes: spanAttrs,
|
| 456 |
+
});
|
| 457 |
+
span.setStatus({ code: SpanStatusCode.ERROR, message: evt.error });
|
| 458 |
+
span.end();
|
| 459 |
+
};
|
| 460 |
+
|
| 461 |
+
const recordMessageQueued = (
|
| 462 |
+
evt: Extract<DiagnosticEventPayload, { type: "message.queued" }>,
|
| 463 |
+
) => {
|
| 464 |
+
const attrs = {
|
| 465 |
+
"openclaw.channel": evt.channel ?? "unknown",
|
| 466 |
+
"openclaw.source": evt.source ?? "unknown",
|
| 467 |
+
};
|
| 468 |
+
messageQueuedCounter.add(1, attrs);
|
| 469 |
+
if (typeof evt.queueDepth === "number") {
|
| 470 |
+
queueDepthHistogram.record(evt.queueDepth, attrs);
|
| 471 |
+
}
|
| 472 |
+
};
|
| 473 |
+
|
| 474 |
+
const recordMessageProcessed = (
|
| 475 |
+
evt: Extract<DiagnosticEventPayload, { type: "message.processed" }>,
|
| 476 |
+
) => {
|
| 477 |
+
const attrs = {
|
| 478 |
+
"openclaw.channel": evt.channel ?? "unknown",
|
| 479 |
+
"openclaw.outcome": evt.outcome ?? "unknown",
|
| 480 |
+
};
|
| 481 |
+
messageProcessedCounter.add(1, attrs);
|
| 482 |
+
if (typeof evt.durationMs === "number") {
|
| 483 |
+
messageDurationHistogram.record(evt.durationMs, attrs);
|
| 484 |
+
}
|
| 485 |
+
if (!tracesEnabled) {
|
| 486 |
+
return;
|
| 487 |
+
}
|
| 488 |
+
const spanAttrs: Record<string, string | number> = { ...attrs };
|
| 489 |
+
if (evt.sessionKey) {
|
| 490 |
+
spanAttrs["openclaw.sessionKey"] = evt.sessionKey;
|
| 491 |
+
}
|
| 492 |
+
if (evt.sessionId) {
|
| 493 |
+
spanAttrs["openclaw.sessionId"] = evt.sessionId;
|
| 494 |
+
}
|
| 495 |
+
if (evt.chatId !== undefined) {
|
| 496 |
+
spanAttrs["openclaw.chatId"] = String(evt.chatId);
|
| 497 |
+
}
|
| 498 |
+
if (evt.messageId !== undefined) {
|
| 499 |
+
spanAttrs["openclaw.messageId"] = String(evt.messageId);
|
| 500 |
+
}
|
| 501 |
+
if (evt.reason) {
|
| 502 |
+
spanAttrs["openclaw.reason"] = evt.reason;
|
| 503 |
+
}
|
| 504 |
+
const span = spanWithDuration("openclaw.message.processed", spanAttrs, evt.durationMs);
|
| 505 |
+
if (evt.outcome === "error") {
|
| 506 |
+
span.setStatus({ code: SpanStatusCode.ERROR, message: evt.error });
|
| 507 |
+
}
|
| 508 |
+
span.end();
|
| 509 |
+
};
|
| 510 |
+
|
| 511 |
+
const recordLaneEnqueue = (
|
| 512 |
+
evt: Extract<DiagnosticEventPayload, { type: "queue.lane.enqueue" }>,
|
| 513 |
+
) => {
|
| 514 |
+
const attrs = { "openclaw.lane": evt.lane };
|
| 515 |
+
laneEnqueueCounter.add(1, attrs);
|
| 516 |
+
queueDepthHistogram.record(evt.queueSize, attrs);
|
| 517 |
+
};
|
| 518 |
+
|
| 519 |
+
const recordLaneDequeue = (
|
| 520 |
+
evt: Extract<DiagnosticEventPayload, { type: "queue.lane.dequeue" }>,
|
| 521 |
+
) => {
|
| 522 |
+
const attrs = { "openclaw.lane": evt.lane };
|
| 523 |
+
laneDequeueCounter.add(1, attrs);
|
| 524 |
+
queueDepthHistogram.record(evt.queueSize, attrs);
|
| 525 |
+
if (typeof evt.waitMs === "number") {
|
| 526 |
+
queueWaitHistogram.record(evt.waitMs, attrs);
|
| 527 |
+
}
|
| 528 |
+
};
|
| 529 |
+
|
| 530 |
+
const recordSessionState = (
|
| 531 |
+
evt: Extract<DiagnosticEventPayload, { type: "session.state" }>,
|
| 532 |
+
) => {
|
| 533 |
+
const attrs: Record<string, string> = { "openclaw.state": evt.state };
|
| 534 |
+
if (evt.reason) {
|
| 535 |
+
attrs["openclaw.reason"] = evt.reason;
|
| 536 |
+
}
|
| 537 |
+
sessionStateCounter.add(1, attrs);
|
| 538 |
+
};
|
| 539 |
+
|
| 540 |
+
const recordSessionStuck = (
|
| 541 |
+
evt: Extract<DiagnosticEventPayload, { type: "session.stuck" }>,
|
| 542 |
+
) => {
|
| 543 |
+
const attrs: Record<string, string> = { "openclaw.state": evt.state };
|
| 544 |
+
sessionStuckCounter.add(1, attrs);
|
| 545 |
+
if (typeof evt.ageMs === "number") {
|
| 546 |
+
sessionStuckAgeHistogram.record(evt.ageMs, attrs);
|
| 547 |
+
}
|
| 548 |
+
if (!tracesEnabled) {
|
| 549 |
+
return;
|
| 550 |
+
}
|
| 551 |
+
const spanAttrs: Record<string, string | number> = { ...attrs };
|
| 552 |
+
if (evt.sessionKey) {
|
| 553 |
+
spanAttrs["openclaw.sessionKey"] = evt.sessionKey;
|
| 554 |
+
}
|
| 555 |
+
if (evt.sessionId) {
|
| 556 |
+
spanAttrs["openclaw.sessionId"] = evt.sessionId;
|
| 557 |
+
}
|
| 558 |
+
spanAttrs["openclaw.queueDepth"] = evt.queueDepth ?? 0;
|
| 559 |
+
spanAttrs["openclaw.ageMs"] = evt.ageMs;
|
| 560 |
+
const span = tracer.startSpan("openclaw.session.stuck", { attributes: spanAttrs });
|
| 561 |
+
span.setStatus({ code: SpanStatusCode.ERROR, message: "session stuck" });
|
| 562 |
+
span.end();
|
| 563 |
+
};
|
| 564 |
+
|
| 565 |
+
const recordRunAttempt = (evt: Extract<DiagnosticEventPayload, { type: "run.attempt" }>) => {
|
| 566 |
+
runAttemptCounter.add(1, { "openclaw.attempt": evt.attempt });
|
| 567 |
+
};
|
| 568 |
+
|
| 569 |
+
const recordHeartbeat = (
|
| 570 |
+
evt: Extract<DiagnosticEventPayload, { type: "diagnostic.heartbeat" }>,
|
| 571 |
+
) => {
|
| 572 |
+
queueDepthHistogram.record(evt.queued, { "openclaw.channel": "heartbeat" });
|
| 573 |
+
};
|
| 574 |
+
|
| 575 |
+
unsubscribe = onDiagnosticEvent((evt: DiagnosticEventPayload) => {
|
| 576 |
+
switch (evt.type) {
|
| 577 |
+
case "model.usage":
|
| 578 |
+
recordModelUsage(evt);
|
| 579 |
+
return;
|
| 580 |
+
case "webhook.received":
|
| 581 |
+
recordWebhookReceived(evt);
|
| 582 |
+
return;
|
| 583 |
+
case "webhook.processed":
|
| 584 |
+
recordWebhookProcessed(evt);
|
| 585 |
+
return;
|
| 586 |
+
case "webhook.error":
|
| 587 |
+
recordWebhookError(evt);
|
| 588 |
+
return;
|
| 589 |
+
case "message.queued":
|
| 590 |
+
recordMessageQueued(evt);
|
| 591 |
+
return;
|
| 592 |
+
case "message.processed":
|
| 593 |
+
recordMessageProcessed(evt);
|
| 594 |
+
return;
|
| 595 |
+
case "queue.lane.enqueue":
|
| 596 |
+
recordLaneEnqueue(evt);
|
| 597 |
+
return;
|
| 598 |
+
case "queue.lane.dequeue":
|
| 599 |
+
recordLaneDequeue(evt);
|
| 600 |
+
return;
|
| 601 |
+
case "session.state":
|
| 602 |
+
recordSessionState(evt);
|
| 603 |
+
return;
|
| 604 |
+
case "session.stuck":
|
| 605 |
+
recordSessionStuck(evt);
|
| 606 |
+
return;
|
| 607 |
+
case "run.attempt":
|
| 608 |
+
recordRunAttempt(evt);
|
| 609 |
+
return;
|
| 610 |
+
case "diagnostic.heartbeat":
|
| 611 |
+
recordHeartbeat(evt);
|
| 612 |
+
return;
|
| 613 |
+
}
|
| 614 |
+
});
|
| 615 |
+
|
| 616 |
+
if (logsEnabled) {
|
| 617 |
+
ctx.logger.info("diagnostics-otel: logs exporter enabled (OTLP/HTTP)");
|
| 618 |
+
}
|
| 619 |
+
},
|
| 620 |
+
async stop() {
|
| 621 |
+
unsubscribe?.();
|
| 622 |
+
unsubscribe = null;
|
| 623 |
+
stopLogTransport?.();
|
| 624 |
+
stopLogTransport = null;
|
| 625 |
+
if (logProvider) {
|
| 626 |
+
await logProvider.shutdown().catch(() => undefined);
|
| 627 |
+
logProvider = null;
|
| 628 |
+
}
|
| 629 |
+
if (sdk) {
|
| 630 |
+
await sdk.shutdown().catch(() => undefined);
|
| 631 |
+
sdk = null;
|
| 632 |
+
}
|
| 633 |
+
},
|
| 634 |
+
} satisfies OpenClawPluginService;
|
| 635 |
+
}
|
extensions/discord/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
| 2 |
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
| 3 |
+
import { discordPlugin } from "./src/channel.js";
|
| 4 |
+
import { setDiscordRuntime } from "./src/runtime.js";
|
| 5 |
+
|
| 6 |
+
const plugin = {
|
| 7 |
+
id: "discord",
|
| 8 |
+
name: "Discord",
|
| 9 |
+
description: "Discord channel plugin",
|
| 10 |
+
configSchema: emptyPluginConfigSchema(),
|
| 11 |
+
register(api: OpenClawPluginApi) {
|
| 12 |
+
setDiscordRuntime(api.runtime);
|
| 13 |
+
api.registerChannel({ plugin: discordPlugin });
|
| 14 |
+
},
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
export default plugin;
|
extensions/discord/openclaw.plugin.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "discord",
|
| 3 |
+
"channels": ["discord"],
|
| 4 |
+
"configSchema": {
|
| 5 |
+
"type": "object",
|
| 6 |
+
"additionalProperties": false,
|
| 7 |
+
"properties": {}
|
| 8 |
+
}
|
| 9 |
+
}
|
extensions/discord/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "@openclaw/discord",
|
| 3 |
+
"version": "2026.1.30",
|
| 4 |
+
"description": "OpenClaw Discord channel plugin",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"devDependencies": {
|
| 7 |
+
"openclaw": "workspace:*"
|
| 8 |
+
},
|
| 9 |
+
"openclaw": {
|
| 10 |
+
"extensions": [
|
| 11 |
+
"./index.ts"
|
| 12 |
+
]
|
| 13 |
+
}
|
| 14 |
+
}
|
extensions/discord/src/channel.ts
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
applyAccountNameToChannelSection,
|
| 3 |
+
buildChannelConfigSchema,
|
| 4 |
+
collectDiscordAuditChannelIds,
|
| 5 |
+
collectDiscordStatusIssues,
|
| 6 |
+
DEFAULT_ACCOUNT_ID,
|
| 7 |
+
deleteAccountFromConfigSection,
|
| 8 |
+
discordOnboardingAdapter,
|
| 9 |
+
DiscordConfigSchema,
|
| 10 |
+
formatPairingApproveHint,
|
| 11 |
+
getChatChannelMeta,
|
| 12 |
+
listDiscordAccountIds,
|
| 13 |
+
listDiscordDirectoryGroupsFromConfig,
|
| 14 |
+
listDiscordDirectoryPeersFromConfig,
|
| 15 |
+
looksLikeDiscordTargetId,
|
| 16 |
+
migrateBaseNameToDefaultAccount,
|
| 17 |
+
normalizeAccountId,
|
| 18 |
+
normalizeDiscordMessagingTarget,
|
| 19 |
+
PAIRING_APPROVED_MESSAGE,
|
| 20 |
+
resolveDiscordAccount,
|
| 21 |
+
resolveDefaultDiscordAccountId,
|
| 22 |
+
resolveDiscordGroupRequireMention,
|
| 23 |
+
resolveDiscordGroupToolPolicy,
|
| 24 |
+
setAccountEnabledInConfigSection,
|
| 25 |
+
type ChannelMessageActionAdapter,
|
| 26 |
+
type ChannelPlugin,
|
| 27 |
+
type ResolvedDiscordAccount,
|
| 28 |
+
} from "openclaw/plugin-sdk";
|
| 29 |
+
import { getDiscordRuntime } from "./runtime.js";
|
| 30 |
+
|
| 31 |
+
const meta = getChatChannelMeta("discord");
|
| 32 |
+
|
| 33 |
+
const discordMessageActions: ChannelMessageActionAdapter = {
|
| 34 |
+
listActions: (ctx) => getDiscordRuntime().channel.discord.messageActions.listActions(ctx),
|
| 35 |
+
extractToolSend: (ctx) => getDiscordRuntime().channel.discord.messageActions.extractToolSend(ctx),
|
| 36 |
+
handleAction: async (ctx) =>
|
| 37 |
+
await getDiscordRuntime().channel.discord.messageActions.handleAction(ctx),
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
| 41 |
+
id: "discord",
|
| 42 |
+
meta: {
|
| 43 |
+
...meta,
|
| 44 |
+
},
|
| 45 |
+
onboarding: discordOnboardingAdapter,
|
| 46 |
+
pairing: {
|
| 47 |
+
idLabel: "discordUserId",
|
| 48 |
+
normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""),
|
| 49 |
+
notifyApproval: async ({ id }) => {
|
| 50 |
+
await getDiscordRuntime().channel.discord.sendMessageDiscord(
|
| 51 |
+
`user:${id}`,
|
| 52 |
+
PAIRING_APPROVED_MESSAGE,
|
| 53 |
+
);
|
| 54 |
+
},
|
| 55 |
+
},
|
| 56 |
+
capabilities: {
|
| 57 |
+
chatTypes: ["direct", "channel", "thread"],
|
| 58 |
+
polls: true,
|
| 59 |
+
reactions: true,
|
| 60 |
+
threads: true,
|
| 61 |
+
media: true,
|
| 62 |
+
nativeCommands: true,
|
| 63 |
+
},
|
| 64 |
+
streaming: {
|
| 65 |
+
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
| 66 |
+
},
|
| 67 |
+
reload: { configPrefixes: ["channels.discord"] },
|
| 68 |
+
configSchema: buildChannelConfigSchema(DiscordConfigSchema),
|
| 69 |
+
config: {
|
| 70 |
+
listAccountIds: (cfg) => listDiscordAccountIds(cfg),
|
| 71 |
+
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
|
| 72 |
+
defaultAccountId: (cfg) => resolveDefaultDiscordAccountId(cfg),
|
| 73 |
+
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
| 74 |
+
setAccountEnabledInConfigSection({
|
| 75 |
+
cfg,
|
| 76 |
+
sectionKey: "discord",
|
| 77 |
+
accountId,
|
| 78 |
+
enabled,
|
| 79 |
+
allowTopLevel: true,
|
| 80 |
+
}),
|
| 81 |
+
deleteAccount: ({ cfg, accountId }) =>
|
| 82 |
+
deleteAccountFromConfigSection({
|
| 83 |
+
cfg,
|
| 84 |
+
sectionKey: "discord",
|
| 85 |
+
accountId,
|
| 86 |
+
clearBaseFields: ["token", "name"],
|
| 87 |
+
}),
|
| 88 |
+
isConfigured: (account) => Boolean(account.token?.trim()),
|
| 89 |
+
describeAccount: (account) => ({
|
| 90 |
+
accountId: account.accountId,
|
| 91 |
+
name: account.name,
|
| 92 |
+
enabled: account.enabled,
|
| 93 |
+
configured: Boolean(account.token?.trim()),
|
| 94 |
+
tokenSource: account.tokenSource,
|
| 95 |
+
}),
|
| 96 |
+
resolveAllowFrom: ({ cfg, accountId }) =>
|
| 97 |
+
(resolveDiscordAccount({ cfg, accountId }).config.dm?.allowFrom ?? []).map((entry) =>
|
| 98 |
+
String(entry),
|
| 99 |
+
),
|
| 100 |
+
formatAllowFrom: ({ allowFrom }) =>
|
| 101 |
+
allowFrom
|
| 102 |
+
.map((entry) => String(entry).trim())
|
| 103 |
+
.filter(Boolean)
|
| 104 |
+
.map((entry) => entry.toLowerCase()),
|
| 105 |
+
},
|
| 106 |
+
security: {
|
| 107 |
+
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
| 108 |
+
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
| 109 |
+
const useAccountPath = Boolean(cfg.channels?.discord?.accounts?.[resolvedAccountId]);
|
| 110 |
+
const allowFromPath = useAccountPath
|
| 111 |
+
? `channels.discord.accounts.${resolvedAccountId}.dm.`
|
| 112 |
+
: "channels.discord.dm.";
|
| 113 |
+
return {
|
| 114 |
+
policy: account.config.dm?.policy ?? "pairing",
|
| 115 |
+
allowFrom: account.config.dm?.allowFrom ?? [],
|
| 116 |
+
allowFromPath,
|
| 117 |
+
approveHint: formatPairingApproveHint("discord"),
|
| 118 |
+
normalizeEntry: (raw) => raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"),
|
| 119 |
+
};
|
| 120 |
+
},
|
| 121 |
+
collectWarnings: ({ account, cfg }) => {
|
| 122 |
+
const warnings: string[] = [];
|
| 123 |
+
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
| 124 |
+
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open";
|
| 125 |
+
const guildEntries = account.config.guilds ?? {};
|
| 126 |
+
const guildsConfigured = Object.keys(guildEntries).length > 0;
|
| 127 |
+
const channelAllowlistConfigured = guildsConfigured;
|
| 128 |
+
|
| 129 |
+
if (groupPolicy === "open") {
|
| 130 |
+
if (channelAllowlistConfigured) {
|
| 131 |
+
warnings.push(
|
| 132 |
+
`- Discord guilds: groupPolicy="open" allows any channel not explicitly denied to trigger (mention-gated). Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds.<id>.channels.`,
|
| 133 |
+
);
|
| 134 |
+
} else {
|
| 135 |
+
warnings.push(
|
| 136 |
+
`- Discord guilds: groupPolicy="open" with no guild/channel allowlist; any channel can trigger (mention-gated). Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds.<id>.channels.`,
|
| 137 |
+
);
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
return warnings;
|
| 142 |
+
},
|
| 143 |
+
},
|
| 144 |
+
groups: {
|
| 145 |
+
resolveRequireMention: resolveDiscordGroupRequireMention,
|
| 146 |
+
resolveToolPolicy: resolveDiscordGroupToolPolicy,
|
| 147 |
+
},
|
| 148 |
+
mentions: {
|
| 149 |
+
stripPatterns: () => ["<@!?\\d+>"],
|
| 150 |
+
},
|
| 151 |
+
threading: {
|
| 152 |
+
resolveReplyToMode: ({ cfg }) => cfg.channels?.discord?.replyToMode ?? "off",
|
| 153 |
+
},
|
| 154 |
+
messaging: {
|
| 155 |
+
normalizeTarget: normalizeDiscordMessagingTarget,
|
| 156 |
+
targetResolver: {
|
| 157 |
+
looksLikeId: looksLikeDiscordTargetId,
|
| 158 |
+
hint: "<channelId|user:ID|channel:ID>",
|
| 159 |
+
},
|
| 160 |
+
},
|
| 161 |
+
directory: {
|
| 162 |
+
self: async () => null,
|
| 163 |
+
listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params),
|
| 164 |
+
listGroups: async (params) => listDiscordDirectoryGroupsFromConfig(params),
|
| 165 |
+
listPeersLive: async (params) =>
|
| 166 |
+
getDiscordRuntime().channel.discord.listDirectoryPeersLive(params),
|
| 167 |
+
listGroupsLive: async (params) =>
|
| 168 |
+
getDiscordRuntime().channel.discord.listDirectoryGroupsLive(params),
|
| 169 |
+
},
|
| 170 |
+
resolver: {
|
| 171 |
+
resolveTargets: async ({ cfg, accountId, inputs, kind }) => {
|
| 172 |
+
const account = resolveDiscordAccount({ cfg, accountId });
|
| 173 |
+
const token = account.token?.trim();
|
| 174 |
+
if (!token) {
|
| 175 |
+
return inputs.map((input) => ({
|
| 176 |
+
input,
|
| 177 |
+
resolved: false,
|
| 178 |
+
note: "missing Discord token",
|
| 179 |
+
}));
|
| 180 |
+
}
|
| 181 |
+
if (kind === "group") {
|
| 182 |
+
const resolved = await getDiscordRuntime().channel.discord.resolveChannelAllowlist({
|
| 183 |
+
token,
|
| 184 |
+
entries: inputs,
|
| 185 |
+
});
|
| 186 |
+
return resolved.map((entry) => ({
|
| 187 |
+
input: entry.input,
|
| 188 |
+
resolved: entry.resolved,
|
| 189 |
+
id: entry.channelId ?? entry.guildId,
|
| 190 |
+
name:
|
| 191 |
+
entry.channelName ??
|
| 192 |
+
entry.guildName ??
|
| 193 |
+
(entry.guildId && !entry.channelId ? entry.guildId : undefined),
|
| 194 |
+
note: entry.note,
|
| 195 |
+
}));
|
| 196 |
+
}
|
| 197 |
+
const resolved = await getDiscordRuntime().channel.discord.resolveUserAllowlist({
|
| 198 |
+
token,
|
| 199 |
+
entries: inputs,
|
| 200 |
+
});
|
| 201 |
+
return resolved.map((entry) => ({
|
| 202 |
+
input: entry.input,
|
| 203 |
+
resolved: entry.resolved,
|
| 204 |
+
id: entry.id,
|
| 205 |
+
name: entry.name,
|
| 206 |
+
note: entry.note,
|
| 207 |
+
}));
|
| 208 |
+
},
|
| 209 |
+
},
|
| 210 |
+
actions: discordMessageActions,
|
| 211 |
+
setup: {
|
| 212 |
+
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
| 213 |
+
applyAccountName: ({ cfg, accountId, name }) =>
|
| 214 |
+
applyAccountNameToChannelSection({
|
| 215 |
+
cfg,
|
| 216 |
+
channelKey: "discord",
|
| 217 |
+
accountId,
|
| 218 |
+
name,
|
| 219 |
+
}),
|
| 220 |
+
validateInput: ({ accountId, input }) => {
|
| 221 |
+
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
|
| 222 |
+
return "DISCORD_BOT_TOKEN can only be used for the default account.";
|
| 223 |
+
}
|
| 224 |
+
if (!input.useEnv && !input.token) {
|
| 225 |
+
return "Discord requires token (or --use-env).";
|
| 226 |
+
}
|
| 227 |
+
return null;
|
| 228 |
+
},
|
| 229 |
+
applyAccountConfig: ({ cfg, accountId, input }) => {
|
| 230 |
+
const namedConfig = applyAccountNameToChannelSection({
|
| 231 |
+
cfg,
|
| 232 |
+
channelKey: "discord",
|
| 233 |
+
accountId,
|
| 234 |
+
name: input.name,
|
| 235 |
+
});
|
| 236 |
+
const next =
|
| 237 |
+
accountId !== DEFAULT_ACCOUNT_ID
|
| 238 |
+
? migrateBaseNameToDefaultAccount({
|
| 239 |
+
cfg: namedConfig,
|
| 240 |
+
channelKey: "discord",
|
| 241 |
+
})
|
| 242 |
+
: namedConfig;
|
| 243 |
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
| 244 |
+
return {
|
| 245 |
+
...next,
|
| 246 |
+
channels: {
|
| 247 |
+
...next.channels,
|
| 248 |
+
discord: {
|
| 249 |
+
...next.channels?.discord,
|
| 250 |
+
enabled: true,
|
| 251 |
+
...(input.useEnv ? {} : input.token ? { token: input.token } : {}),
|
| 252 |
+
},
|
| 253 |
+
},
|
| 254 |
+
};
|
| 255 |
+
}
|
| 256 |
+
return {
|
| 257 |
+
...next,
|
| 258 |
+
channels: {
|
| 259 |
+
...next.channels,
|
| 260 |
+
discord: {
|
| 261 |
+
...next.channels?.discord,
|
| 262 |
+
enabled: true,
|
| 263 |
+
accounts: {
|
| 264 |
+
...next.channels?.discord?.accounts,
|
| 265 |
+
[accountId]: {
|
| 266 |
+
...next.channels?.discord?.accounts?.[accountId],
|
| 267 |
+
enabled: true,
|
| 268 |
+
...(input.token ? { token: input.token } : {}),
|
| 269 |
+
},
|
| 270 |
+
},
|
| 271 |
+
},
|
| 272 |
+
},
|
| 273 |
+
};
|
| 274 |
+
},
|
| 275 |
+
},
|
| 276 |
+
outbound: {
|
| 277 |
+
deliveryMode: "direct",
|
| 278 |
+
chunker: null,
|
| 279 |
+
textChunkLimit: 2000,
|
| 280 |
+
pollMaxOptions: 10,
|
| 281 |
+
sendText: async ({ to, text, accountId, deps, replyToId }) => {
|
| 282 |
+
const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
|
| 283 |
+
const result = await send(to, text, {
|
| 284 |
+
verbose: false,
|
| 285 |
+
replyTo: replyToId ?? undefined,
|
| 286 |
+
accountId: accountId ?? undefined,
|
| 287 |
+
});
|
| 288 |
+
return { channel: "discord", ...result };
|
| 289 |
+
},
|
| 290 |
+
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
|
| 291 |
+
const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
|
| 292 |
+
const result = await send(to, text, {
|
| 293 |
+
verbose: false,
|
| 294 |
+
mediaUrl,
|
| 295 |
+
replyTo: replyToId ?? undefined,
|
| 296 |
+
accountId: accountId ?? undefined,
|
| 297 |
+
});
|
| 298 |
+
return { channel: "discord", ...result };
|
| 299 |
+
},
|
| 300 |
+
sendPoll: async ({ to, poll, accountId }) =>
|
| 301 |
+
await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, {
|
| 302 |
+
accountId: accountId ?? undefined,
|
| 303 |
+
}),
|
| 304 |
+
},
|
| 305 |
+
status: {
|
| 306 |
+
defaultRuntime: {
|
| 307 |
+
accountId: DEFAULT_ACCOUNT_ID,
|
| 308 |
+
running: false,
|
| 309 |
+
lastStartAt: null,
|
| 310 |
+
lastStopAt: null,
|
| 311 |
+
lastError: null,
|
| 312 |
+
},
|
| 313 |
+
collectStatusIssues: collectDiscordStatusIssues,
|
| 314 |
+
buildChannelSummary: ({ snapshot }) => ({
|
| 315 |
+
configured: snapshot.configured ?? false,
|
| 316 |
+
tokenSource: snapshot.tokenSource ?? "none",
|
| 317 |
+
running: snapshot.running ?? false,
|
| 318 |
+
lastStartAt: snapshot.lastStartAt ?? null,
|
| 319 |
+
lastStopAt: snapshot.lastStopAt ?? null,
|
| 320 |
+
lastError: snapshot.lastError ?? null,
|
| 321 |
+
probe: snapshot.probe,
|
| 322 |
+
lastProbeAt: snapshot.lastProbeAt ?? null,
|
| 323 |
+
}),
|
| 324 |
+
probeAccount: async ({ account, timeoutMs }) =>
|
| 325 |
+
getDiscordRuntime().channel.discord.probeDiscord(account.token, timeoutMs, {
|
| 326 |
+
includeApplication: true,
|
| 327 |
+
}),
|
| 328 |
+
auditAccount: async ({ account, timeoutMs, cfg }) => {
|
| 329 |
+
const { channelIds, unresolvedChannels } = collectDiscordAuditChannelIds({
|
| 330 |
+
cfg,
|
| 331 |
+
accountId: account.accountId,
|
| 332 |
+
});
|
| 333 |
+
if (!channelIds.length && unresolvedChannels === 0) {
|
| 334 |
+
return undefined;
|
| 335 |
+
}
|
| 336 |
+
const botToken = account.token?.trim();
|
| 337 |
+
if (!botToken) {
|
| 338 |
+
return {
|
| 339 |
+
ok: unresolvedChannels === 0,
|
| 340 |
+
checkedChannels: 0,
|
| 341 |
+
unresolvedChannels,
|
| 342 |
+
channels: [],
|
| 343 |
+
elapsedMs: 0,
|
| 344 |
+
};
|
| 345 |
+
}
|
| 346 |
+
const audit = await getDiscordRuntime().channel.discord.auditChannelPermissions({
|
| 347 |
+
token: botToken,
|
| 348 |
+
accountId: account.accountId,
|
| 349 |
+
channelIds,
|
| 350 |
+
timeoutMs,
|
| 351 |
+
});
|
| 352 |
+
return { ...audit, unresolvedChannels };
|
| 353 |
+
},
|
| 354 |
+
buildAccountSnapshot: ({ account, runtime, probe, audit }) => {
|
| 355 |
+
const configured = Boolean(account.token?.trim());
|
| 356 |
+
const app = runtime?.application ?? (probe as { application?: unknown })?.application;
|
| 357 |
+
const bot = runtime?.bot ?? (probe as { bot?: unknown })?.bot;
|
| 358 |
+
return {
|
| 359 |
+
accountId: account.accountId,
|
| 360 |
+
name: account.name,
|
| 361 |
+
enabled: account.enabled,
|
| 362 |
+
configured,
|
| 363 |
+
tokenSource: account.tokenSource,
|
| 364 |
+
running: runtime?.running ?? false,
|
| 365 |
+
lastStartAt: runtime?.lastStartAt ?? null,
|
| 366 |
+
lastStopAt: runtime?.lastStopAt ?? null,
|
| 367 |
+
lastError: runtime?.lastError ?? null,
|
| 368 |
+
application: app ?? undefined,
|
| 369 |
+
bot: bot ?? undefined,
|
| 370 |
+
probe,
|
| 371 |
+
audit,
|
| 372 |
+
lastInboundAt: runtime?.lastInboundAt ?? null,
|
| 373 |
+
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
| 374 |
+
};
|
| 375 |
+
},
|
| 376 |
+
},
|
| 377 |
+
gateway: {
|
| 378 |
+
startAccount: async (ctx) => {
|
| 379 |
+
const account = ctx.account;
|
| 380 |
+
const token = account.token.trim();
|
| 381 |
+
let discordBotLabel = "";
|
| 382 |
+
try {
|
| 383 |
+
const probe = await getDiscordRuntime().channel.discord.probeDiscord(token, 2500, {
|
| 384 |
+
includeApplication: true,
|
| 385 |
+
});
|
| 386 |
+
const username = probe.ok ? probe.bot?.username?.trim() : null;
|
| 387 |
+
if (username) {
|
| 388 |
+
discordBotLabel = ` (@${username})`;
|
| 389 |
+
}
|
| 390 |
+
ctx.setStatus({
|
| 391 |
+
accountId: account.accountId,
|
| 392 |
+
bot: probe.bot,
|
| 393 |
+
application: probe.application,
|
| 394 |
+
});
|
| 395 |
+
const messageContent = probe.application?.intents?.messageContent;
|
| 396 |
+
if (messageContent === "disabled") {
|
| 397 |
+
ctx.log?.warn(
|
| 398 |
+
`[${account.accountId}] Discord Message Content Intent is disabled; bot may not respond to channel messages. Enable it in Discord Dev Portal (Bot → Privileged Gateway Intents) or require mentions.`,
|
| 399 |
+
);
|
| 400 |
+
} else if (messageContent === "limited") {
|
| 401 |
+
ctx.log?.info(
|
| 402 |
+
`[${account.accountId}] Discord Message Content Intent is limited; bots under 100 servers can use it without verification.`,
|
| 403 |
+
);
|
| 404 |
+
}
|
| 405 |
+
} catch (err) {
|
| 406 |
+
if (getDiscordRuntime().logging.shouldLogVerbose()) {
|
| 407 |
+
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
|
| 408 |
+
}
|
| 409 |
+
}
|
| 410 |
+
ctx.log?.info(`[${account.accountId}] starting provider${discordBotLabel}`);
|
| 411 |
+
return getDiscordRuntime().channel.discord.monitorDiscordProvider({
|
| 412 |
+
token,
|
| 413 |
+
accountId: account.accountId,
|
| 414 |
+
config: ctx.cfg,
|
| 415 |
+
runtime: ctx.runtime,
|
| 416 |
+
abortSignal: ctx.abortSignal,
|
| 417 |
+
mediaMaxMb: account.config.mediaMaxMb,
|
| 418 |
+
historyLimit: account.config.historyLimit,
|
| 419 |
+
});
|
| 420 |
+
},
|
| 421 |
+
},
|
| 422 |
+
};
|
extensions/discord/src/runtime.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
| 2 |
+
|
| 3 |
+
let runtime: PluginRuntime | null = null;
|
| 4 |
+
|
| 5 |
+
export function setDiscordRuntime(next: PluginRuntime) {
|
| 6 |
+
runtime = next;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export function getDiscordRuntime(): PluginRuntime {
|
| 10 |
+
if (!runtime) {
|
| 11 |
+
throw new Error("Discord runtime not initialized");
|
| 12 |
+
}
|
| 13 |
+
return runtime;
|
| 14 |
+
}
|
extensions/google-antigravity-auth/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Google Antigravity Auth (OpenClaw plugin)
|
| 2 |
+
|
| 3 |
+
OAuth provider plugin for **Google Antigravity** (Cloud Code Assist).
|
| 4 |
+
|
| 5 |
+
## Enable
|
| 6 |
+
|
| 7 |
+
Bundled plugins are disabled by default. Enable this one:
|
| 8 |
+
|
| 9 |
+
```bash
|
| 10 |
+
openclaw plugins enable google-antigravity-auth
|
| 11 |
+
```
|
| 12 |
+
|
| 13 |
+
Restart the Gateway after enabling.
|
| 14 |
+
|
| 15 |
+
## Authenticate
|
| 16 |
+
|
| 17 |
+
```bash
|
| 18 |
+
openclaw models auth login --provider google-antigravity --set-default
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
## Notes
|
| 22 |
+
|
| 23 |
+
- Antigravity uses Google Cloud project quotas.
|
| 24 |
+
- If requests fail, ensure Gemini for Google Cloud is enabled.
|
extensions/google-antigravity-auth/index.ts
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createHash, randomBytes } from "node:crypto";
|
| 2 |
+
import { readFileSync } from "node:fs";
|
| 3 |
+
import { createServer } from "node:http";
|
| 4 |
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
| 5 |
+
|
| 6 |
+
// OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync
|
| 7 |
+
const decode = (s: string) => Buffer.from(s, "base64").toString();
|
| 8 |
+
const CLIENT_ID = decode(
|
| 9 |
+
"MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==",
|
| 10 |
+
);
|
| 11 |
+
const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=");
|
| 12 |
+
const REDIRECT_URI = "http://localhost:51121/oauth-callback";
|
| 13 |
+
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
| 14 |
+
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
| 15 |
+
const DEFAULT_PROJECT_ID = "rising-fact-p41fc";
|
| 16 |
+
const DEFAULT_MODEL = "google-antigravity/claude-opus-4-5-thinking";
|
| 17 |
+
|
| 18 |
+
const SCOPES = [
|
| 19 |
+
"https://www.googleapis.com/auth/cloud-platform",
|
| 20 |
+
"https://www.googleapis.com/auth/userinfo.email",
|
| 21 |
+
"https://www.googleapis.com/auth/userinfo.profile",
|
| 22 |
+
"https://www.googleapis.com/auth/cclog",
|
| 23 |
+
"https://www.googleapis.com/auth/experimentsandconfigs",
|
| 24 |
+
];
|
| 25 |
+
|
| 26 |
+
const CODE_ASSIST_ENDPOINTS = [
|
| 27 |
+
"https://cloudcode-pa.googleapis.com",
|
| 28 |
+
"https://daily-cloudcode-pa.sandbox.googleapis.com",
|
| 29 |
+
];
|
| 30 |
+
|
| 31 |
+
const RESPONSE_PAGE = `<!DOCTYPE html>
|
| 32 |
+
<html lang="en">
|
| 33 |
+
<head>
|
| 34 |
+
<meta charset="utf-8" />
|
| 35 |
+
<title>OpenClaw Antigravity OAuth</title>
|
| 36 |
+
</head>
|
| 37 |
+
<body>
|
| 38 |
+
<main>
|
| 39 |
+
<h1>Authentication complete</h1>
|
| 40 |
+
<p>You can return to the terminal.</p>
|
| 41 |
+
</main>
|
| 42 |
+
</body>
|
| 43 |
+
</html>`;
|
| 44 |
+
|
| 45 |
+
function generatePkce(): { verifier: string; challenge: string } {
|
| 46 |
+
const verifier = randomBytes(32).toString("hex");
|
| 47 |
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
| 48 |
+
return { verifier, challenge };
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function isWSL(): boolean {
|
| 52 |
+
if (process.platform !== "linux") {
|
| 53 |
+
return false;
|
| 54 |
+
}
|
| 55 |
+
try {
|
| 56 |
+
const release = readFileSync("/proc/version", "utf8").toLowerCase();
|
| 57 |
+
return release.includes("microsoft") || release.includes("wsl");
|
| 58 |
+
} catch {
|
| 59 |
+
return false;
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function isWSL2(): boolean {
|
| 64 |
+
if (!isWSL()) {
|
| 65 |
+
return false;
|
| 66 |
+
}
|
| 67 |
+
try {
|
| 68 |
+
const version = readFileSync("/proc/version", "utf8").toLowerCase();
|
| 69 |
+
return version.includes("wsl2") || version.includes("microsoft-standard");
|
| 70 |
+
} catch {
|
| 71 |
+
return false;
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
|
| 76 |
+
return isRemote || isWSL2();
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
function buildAuthUrl(params: { challenge: string; state: string }): string {
|
| 80 |
+
const url = new URL(AUTH_URL);
|
| 81 |
+
url.searchParams.set("client_id", CLIENT_ID);
|
| 82 |
+
url.searchParams.set("response_type", "code");
|
| 83 |
+
url.searchParams.set("redirect_uri", REDIRECT_URI);
|
| 84 |
+
url.searchParams.set("scope", SCOPES.join(" "));
|
| 85 |
+
url.searchParams.set("code_challenge", params.challenge);
|
| 86 |
+
url.searchParams.set("code_challenge_method", "S256");
|
| 87 |
+
url.searchParams.set("state", params.state);
|
| 88 |
+
url.searchParams.set("access_type", "offline");
|
| 89 |
+
url.searchParams.set("prompt", "consent");
|
| 90 |
+
return url.toString();
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
function parseCallbackInput(input: string): { code: string; state: string } | { error: string } {
|
| 94 |
+
const trimmed = input.trim();
|
| 95 |
+
if (!trimmed) {
|
| 96 |
+
return { error: "No input provided" };
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
try {
|
| 100 |
+
const url = new URL(trimmed);
|
| 101 |
+
const code = url.searchParams.get("code");
|
| 102 |
+
const state = url.searchParams.get("state");
|
| 103 |
+
if (!code) {
|
| 104 |
+
return { error: "Missing 'code' parameter in URL" };
|
| 105 |
+
}
|
| 106 |
+
if (!state) {
|
| 107 |
+
return { error: "Missing 'state' parameter in URL" };
|
| 108 |
+
}
|
| 109 |
+
return { code, state };
|
| 110 |
+
} catch {
|
| 111 |
+
return { error: "Paste the full redirect URL (not just the code)." };
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
async function startCallbackServer(params: { timeoutMs: number }) {
|
| 116 |
+
const redirect = new URL(REDIRECT_URI);
|
| 117 |
+
const port = redirect.port ? Number(redirect.port) : 51121;
|
| 118 |
+
|
| 119 |
+
let settled = false;
|
| 120 |
+
let resolveCallback: (url: URL) => void;
|
| 121 |
+
let rejectCallback: (err: Error) => void;
|
| 122 |
+
|
| 123 |
+
const callbackPromise = new Promise<URL>((resolve, reject) => {
|
| 124 |
+
resolveCallback = (url) => {
|
| 125 |
+
if (settled) {
|
| 126 |
+
return;
|
| 127 |
+
}
|
| 128 |
+
settled = true;
|
| 129 |
+
resolve(url);
|
| 130 |
+
};
|
| 131 |
+
rejectCallback = (err) => {
|
| 132 |
+
if (settled) {
|
| 133 |
+
return;
|
| 134 |
+
}
|
| 135 |
+
settled = true;
|
| 136 |
+
reject(err);
|
| 137 |
+
};
|
| 138 |
+
});
|
| 139 |
+
|
| 140 |
+
const timeout = setTimeout(() => {
|
| 141 |
+
rejectCallback(new Error("Timed out waiting for OAuth callback"));
|
| 142 |
+
}, params.timeoutMs);
|
| 143 |
+
timeout.unref?.();
|
| 144 |
+
|
| 145 |
+
const server = createServer((request, response) => {
|
| 146 |
+
if (!request.url) {
|
| 147 |
+
response.writeHead(400, { "Content-Type": "text/plain" });
|
| 148 |
+
response.end("Missing URL");
|
| 149 |
+
return;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
const url = new URL(request.url, `${redirect.protocol}//${redirect.host}`);
|
| 153 |
+
if (url.pathname !== redirect.pathname) {
|
| 154 |
+
response.writeHead(404, { "Content-Type": "text/plain" });
|
| 155 |
+
response.end("Not found");
|
| 156 |
+
return;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
| 160 |
+
response.end(RESPONSE_PAGE);
|
| 161 |
+
resolveCallback(url);
|
| 162 |
+
|
| 163 |
+
setImmediate(() => {
|
| 164 |
+
server.close();
|
| 165 |
+
});
|
| 166 |
+
});
|
| 167 |
+
|
| 168 |
+
await new Promise<void>((resolve, reject) => {
|
| 169 |
+
const onError = (err: Error) => {
|
| 170 |
+
server.off("error", onError);
|
| 171 |
+
reject(err);
|
| 172 |
+
};
|
| 173 |
+
server.once("error", onError);
|
| 174 |
+
server.listen(port, "127.0.0.1", () => {
|
| 175 |
+
server.off("error", onError);
|
| 176 |
+
resolve();
|
| 177 |
+
});
|
| 178 |
+
});
|
| 179 |
+
|
| 180 |
+
return {
|
| 181 |
+
waitForCallback: () => callbackPromise,
|
| 182 |
+
close: () =>
|
| 183 |
+
new Promise<void>((resolve) => {
|
| 184 |
+
server.close(() => resolve());
|
| 185 |
+
}),
|
| 186 |
+
};
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
async function exchangeCode(params: {
|
| 190 |
+
code: string;
|
| 191 |
+
verifier: string;
|
| 192 |
+
}): Promise<{ access: string; refresh: string; expires: number }> {
|
| 193 |
+
const response = await fetch(TOKEN_URL, {
|
| 194 |
+
method: "POST",
|
| 195 |
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
| 196 |
+
body: new URLSearchParams({
|
| 197 |
+
client_id: CLIENT_ID,
|
| 198 |
+
client_secret: CLIENT_SECRET,
|
| 199 |
+
code: params.code,
|
| 200 |
+
grant_type: "authorization_code",
|
| 201 |
+
redirect_uri: REDIRECT_URI,
|
| 202 |
+
code_verifier: params.verifier,
|
| 203 |
+
}),
|
| 204 |
+
});
|
| 205 |
+
|
| 206 |
+
if (!response.ok) {
|
| 207 |
+
const text = await response.text();
|
| 208 |
+
throw new Error(`Token exchange failed: ${text}`);
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
const data = (await response.json()) as {
|
| 212 |
+
access_token?: string;
|
| 213 |
+
refresh_token?: string;
|
| 214 |
+
expires_in?: number;
|
| 215 |
+
};
|
| 216 |
+
|
| 217 |
+
const access = data.access_token?.trim();
|
| 218 |
+
const refresh = data.refresh_token?.trim();
|
| 219 |
+
const expiresIn = data.expires_in ?? 0;
|
| 220 |
+
|
| 221 |
+
if (!access) {
|
| 222 |
+
throw new Error("Token exchange returned no access_token");
|
| 223 |
+
}
|
| 224 |
+
if (!refresh) {
|
| 225 |
+
throw new Error("Token exchange returned no refresh_token");
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
const expires = Date.now() + expiresIn * 1000 - 5 * 60 * 1000;
|
| 229 |
+
return { access, refresh, expires };
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
async function fetchUserEmail(accessToken: string): Promise<string | undefined> {
|
| 233 |
+
try {
|
| 234 |
+
const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", {
|
| 235 |
+
headers: { Authorization: `Bearer ${accessToken}` },
|
| 236 |
+
});
|
| 237 |
+
if (!response.ok) {
|
| 238 |
+
return undefined;
|
| 239 |
+
}
|
| 240 |
+
const data = (await response.json()) as { email?: string };
|
| 241 |
+
return data.email;
|
| 242 |
+
} catch {
|
| 243 |
+
return undefined;
|
| 244 |
+
}
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
async function fetchProjectId(accessToken: string): Promise<string> {
|
| 248 |
+
const headers = {
|
| 249 |
+
Authorization: `Bearer ${accessToken}`,
|
| 250 |
+
"Content-Type": "application/json",
|
| 251 |
+
"User-Agent": "google-api-nodejs-client/9.15.1",
|
| 252 |
+
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
| 253 |
+
"Client-Metadata": JSON.stringify({
|
| 254 |
+
ideType: "IDE_UNSPECIFIED",
|
| 255 |
+
platform: "PLATFORM_UNSPECIFIED",
|
| 256 |
+
pluginType: "GEMINI",
|
| 257 |
+
}),
|
| 258 |
+
};
|
| 259 |
+
|
| 260 |
+
for (const endpoint of CODE_ASSIST_ENDPOINTS) {
|
| 261 |
+
try {
|
| 262 |
+
const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
|
| 263 |
+
method: "POST",
|
| 264 |
+
headers,
|
| 265 |
+
body: JSON.stringify({
|
| 266 |
+
metadata: {
|
| 267 |
+
ideType: "IDE_UNSPECIFIED",
|
| 268 |
+
platform: "PLATFORM_UNSPECIFIED",
|
| 269 |
+
pluginType: "GEMINI",
|
| 270 |
+
},
|
| 271 |
+
}),
|
| 272 |
+
});
|
| 273 |
+
|
| 274 |
+
if (!response.ok) {
|
| 275 |
+
continue;
|
| 276 |
+
}
|
| 277 |
+
const data = (await response.json()) as {
|
| 278 |
+
cloudaicompanionProject?: string | { id?: string };
|
| 279 |
+
};
|
| 280 |
+
|
| 281 |
+
if (typeof data.cloudaicompanionProject === "string") {
|
| 282 |
+
return data.cloudaicompanionProject;
|
| 283 |
+
}
|
| 284 |
+
if (
|
| 285 |
+
data.cloudaicompanionProject &&
|
| 286 |
+
typeof data.cloudaicompanionProject === "object" &&
|
| 287 |
+
data.cloudaicompanionProject.id
|
| 288 |
+
) {
|
| 289 |
+
return data.cloudaicompanionProject.id;
|
| 290 |
+
}
|
| 291 |
+
} catch {
|
| 292 |
+
// ignore
|
| 293 |
+
}
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
return DEFAULT_PROJECT_ID;
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
async function loginAntigravity(params: {
|
| 300 |
+
isRemote: boolean;
|
| 301 |
+
openUrl: (url: string) => Promise<void>;
|
| 302 |
+
prompt: (message: string) => Promise<string>;
|
| 303 |
+
note: (message: string, title?: string) => Promise<void>;
|
| 304 |
+
log: (message: string) => void;
|
| 305 |
+
progress: { update: (msg: string) => void; stop: (msg?: string) => void };
|
| 306 |
+
}): Promise<{
|
| 307 |
+
access: string;
|
| 308 |
+
refresh: string;
|
| 309 |
+
expires: number;
|
| 310 |
+
email?: string;
|
| 311 |
+
projectId: string;
|
| 312 |
+
}> {
|
| 313 |
+
const { verifier, challenge } = generatePkce();
|
| 314 |
+
const state = randomBytes(16).toString("hex");
|
| 315 |
+
const authUrl = buildAuthUrl({ challenge, state });
|
| 316 |
+
|
| 317 |
+
let callbackServer: Awaited<ReturnType<typeof startCallbackServer>> | null = null;
|
| 318 |
+
const needsManual = shouldUseManualOAuthFlow(params.isRemote);
|
| 319 |
+
if (!needsManual) {
|
| 320 |
+
try {
|
| 321 |
+
callbackServer = await startCallbackServer({ timeoutMs: 5 * 60 * 1000 });
|
| 322 |
+
} catch {
|
| 323 |
+
callbackServer = null;
|
| 324 |
+
}
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
if (!callbackServer) {
|
| 328 |
+
await params.note(
|
| 329 |
+
[
|
| 330 |
+
"Open the URL in your local browser.",
|
| 331 |
+
"After signing in, copy the full redirect URL and paste it back here.",
|
| 332 |
+
"",
|
| 333 |
+
`Auth URL: ${authUrl}`,
|
| 334 |
+
`Redirect URI: ${REDIRECT_URI}`,
|
| 335 |
+
].join("\n"),
|
| 336 |
+
"Google Antigravity OAuth",
|
| 337 |
+
);
|
| 338 |
+
// Output raw URL below the box for easy copying (fixes #1772)
|
| 339 |
+
params.log("");
|
| 340 |
+
params.log("Copy this URL:");
|
| 341 |
+
params.log(authUrl);
|
| 342 |
+
params.log("");
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
if (!needsManual) {
|
| 346 |
+
params.progress.update("Opening Google sign-in…");
|
| 347 |
+
try {
|
| 348 |
+
await params.openUrl(authUrl);
|
| 349 |
+
} catch {
|
| 350 |
+
// ignore
|
| 351 |
+
}
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
let code = "";
|
| 355 |
+
let returnedState = "";
|
| 356 |
+
|
| 357 |
+
if (callbackServer) {
|
| 358 |
+
params.progress.update("Waiting for OAuth callback…");
|
| 359 |
+
const callback = await callbackServer.waitForCallback();
|
| 360 |
+
code = callback.searchParams.get("code") ?? "";
|
| 361 |
+
returnedState = callback.searchParams.get("state") ?? "";
|
| 362 |
+
await callbackServer.close();
|
| 363 |
+
} else {
|
| 364 |
+
params.progress.update("Waiting for redirect URL…");
|
| 365 |
+
const input = await params.prompt("Paste the redirect URL: ");
|
| 366 |
+
const parsed = parseCallbackInput(input);
|
| 367 |
+
if ("error" in parsed) {
|
| 368 |
+
throw new Error(parsed.error);
|
| 369 |
+
}
|
| 370 |
+
code = parsed.code;
|
| 371 |
+
returnedState = parsed.state;
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
if (!code) {
|
| 375 |
+
throw new Error("Missing OAuth code");
|
| 376 |
+
}
|
| 377 |
+
if (returnedState !== state) {
|
| 378 |
+
throw new Error("OAuth state mismatch. Please try again.");
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
params.progress.update("Exchanging code for tokens…");
|
| 382 |
+
const tokens = await exchangeCode({ code, verifier });
|
| 383 |
+
const email = await fetchUserEmail(tokens.access);
|
| 384 |
+
const projectId = await fetchProjectId(tokens.access);
|
| 385 |
+
|
| 386 |
+
params.progress.stop("Antigravity OAuth complete");
|
| 387 |
+
return { ...tokens, email, projectId };
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
const antigravityPlugin = {
|
| 391 |
+
id: "google-antigravity-auth",
|
| 392 |
+
name: "Google Antigravity Auth",
|
| 393 |
+
description: "OAuth flow for Google Antigravity (Cloud Code Assist)",
|
| 394 |
+
configSchema: emptyPluginConfigSchema(),
|
| 395 |
+
register(api) {
|
| 396 |
+
api.registerProvider({
|
| 397 |
+
id: "google-antigravity",
|
| 398 |
+
label: "Google Antigravity",
|
| 399 |
+
docsPath: "/providers/models",
|
| 400 |
+
aliases: ["antigravity"],
|
| 401 |
+
auth: [
|
| 402 |
+
{
|
| 403 |
+
id: "oauth",
|
| 404 |
+
label: "Google OAuth",
|
| 405 |
+
hint: "PKCE + localhost callback",
|
| 406 |
+
kind: "oauth",
|
| 407 |
+
run: async (ctx) => {
|
| 408 |
+
const spin = ctx.prompter.progress("Starting Antigravity OAuth…");
|
| 409 |
+
try {
|
| 410 |
+
const result = await loginAntigravity({
|
| 411 |
+
isRemote: ctx.isRemote,
|
| 412 |
+
openUrl: ctx.openUrl,
|
| 413 |
+
prompt: async (message) => String(await ctx.prompter.text({ message })),
|
| 414 |
+
note: ctx.prompter.note,
|
| 415 |
+
log: (message) => ctx.runtime.log(message),
|
| 416 |
+
progress: spin,
|
| 417 |
+
});
|
| 418 |
+
|
| 419 |
+
const profileId = `google-antigravity:${result.email ?? "default"}`;
|
| 420 |
+
return {
|
| 421 |
+
profiles: [
|
| 422 |
+
{
|
| 423 |
+
profileId,
|
| 424 |
+
credential: {
|
| 425 |
+
type: "oauth",
|
| 426 |
+
provider: "google-antigravity",
|
| 427 |
+
access: result.access,
|
| 428 |
+
refresh: result.refresh,
|
| 429 |
+
expires: result.expires,
|
| 430 |
+
email: result.email,
|
| 431 |
+
projectId: result.projectId,
|
| 432 |
+
},
|
| 433 |
+
},
|
| 434 |
+
],
|
| 435 |
+
configPatch: {
|
| 436 |
+
agents: {
|
| 437 |
+
defaults: {
|
| 438 |
+
models: {
|
| 439 |
+
[DEFAULT_MODEL]: {},
|
| 440 |
+
},
|
| 441 |
+
},
|
| 442 |
+
},
|
| 443 |
+
},
|
| 444 |
+
defaultModel: DEFAULT_MODEL,
|
| 445 |
+
notes: [
|
| 446 |
+
"Antigravity uses Google Cloud project quotas.",
|
| 447 |
+
"Enable Gemini for Google Cloud on your project if requests fail.",
|
| 448 |
+
],
|
| 449 |
+
};
|
| 450 |
+
} catch (err) {
|
| 451 |
+
spin.stop("Antigravity OAuth failed");
|
| 452 |
+
throw err;
|
| 453 |
+
}
|
| 454 |
+
},
|
| 455 |
+
},
|
| 456 |
+
],
|
| 457 |
+
});
|
| 458 |
+
},
|
| 459 |
+
};
|
| 460 |
+
|
| 461 |
+
export default antigravityPlugin;
|
extensions/google-antigravity-auth/openclaw.plugin.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "google-antigravity-auth",
|
| 3 |
+
"providers": ["google-antigravity"],
|
| 4 |
+
"configSchema": {
|
| 5 |
+
"type": "object",
|
| 6 |
+
"additionalProperties": false,
|
| 7 |
+
"properties": {}
|
| 8 |
+
}
|
| 9 |
+
}
|
extensions/google-antigravity-auth/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "@openclaw/google-antigravity-auth",
|
| 3 |
+
"version": "2026.1.30",
|
| 4 |
+
"description": "OpenClaw Google Antigravity OAuth provider plugin",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"devDependencies": {
|
| 7 |
+
"openclaw": "workspace:*"
|
| 8 |
+
},
|
| 9 |
+
"openclaw": {
|
| 10 |
+
"extensions": [
|
| 11 |
+
"./index.ts"
|
| 12 |
+
]
|
| 13 |
+
}
|
| 14 |
+
}
|
extensions/google-gemini-cli-auth/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Google Gemini CLI Auth (OpenClaw plugin)
|
| 2 |
+
|
| 3 |
+
OAuth provider plugin for **Gemini CLI** (Google Code Assist).
|
| 4 |
+
|
| 5 |
+
## Enable
|
| 6 |
+
|
| 7 |
+
Bundled plugins are disabled by default. Enable this one:
|
| 8 |
+
|
| 9 |
+
```bash
|
| 10 |
+
openclaw plugins enable google-gemini-cli-auth
|
| 11 |
+
```
|
| 12 |
+
|
| 13 |
+
Restart the Gateway after enabling.
|
| 14 |
+
|
| 15 |
+
## Authenticate
|
| 16 |
+
|
| 17 |
+
```bash
|
| 18 |
+
openclaw models auth login --provider google-gemini-cli --set-default
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
## Requirements
|
| 22 |
+
|
| 23 |
+
Requires the Gemini CLI to be installed (credentials are extracted automatically):
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
brew install gemini-cli
|
| 27 |
+
# or: npm install -g @google/gemini-cli
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
## Env vars (optional)
|
| 31 |
+
|
| 32 |
+
Override auto-detected credentials with:
|
| 33 |
+
|
| 34 |
+
- `OPENCLAW_GEMINI_OAUTH_CLIENT_ID` / `GEMINI_CLI_OAUTH_CLIENT_ID`
|
| 35 |
+
- `OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET` / `GEMINI_CLI_OAUTH_CLIENT_SECRET`
|
extensions/google-gemini-cli-auth/index.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
| 2 |
+
import { loginGeminiCliOAuth } from "./oauth.js";
|
| 3 |
+
|
| 4 |
+
const PROVIDER_ID = "google-gemini-cli";
|
| 5 |
+
const PROVIDER_LABEL = "Gemini CLI OAuth";
|
| 6 |
+
const DEFAULT_MODEL = "google-gemini-cli/gemini-3-pro-preview";
|
| 7 |
+
const ENV_VARS = [
|
| 8 |
+
"OPENCLAW_GEMINI_OAUTH_CLIENT_ID",
|
| 9 |
+
"OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET",
|
| 10 |
+
"GEMINI_CLI_OAUTH_CLIENT_ID",
|
| 11 |
+
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
|
| 12 |
+
];
|
| 13 |
+
|
| 14 |
+
const geminiCliPlugin = {
|
| 15 |
+
id: "google-gemini-cli-auth",
|
| 16 |
+
name: "Google Gemini CLI Auth",
|
| 17 |
+
description: "OAuth flow for Gemini CLI (Google Code Assist)",
|
| 18 |
+
configSchema: emptyPluginConfigSchema(),
|
| 19 |
+
register(api) {
|
| 20 |
+
api.registerProvider({
|
| 21 |
+
id: PROVIDER_ID,
|
| 22 |
+
label: PROVIDER_LABEL,
|
| 23 |
+
docsPath: "/providers/models",
|
| 24 |
+
aliases: ["gemini-cli"],
|
| 25 |
+
envVars: ENV_VARS,
|
| 26 |
+
auth: [
|
| 27 |
+
{
|
| 28 |
+
id: "oauth",
|
| 29 |
+
label: "Google OAuth",
|
| 30 |
+
hint: "PKCE + localhost callback",
|
| 31 |
+
kind: "oauth",
|
| 32 |
+
run: async (ctx) => {
|
| 33 |
+
const spin = ctx.prompter.progress("Starting Gemini CLI OAuth…");
|
| 34 |
+
try {
|
| 35 |
+
const result = await loginGeminiCliOAuth({
|
| 36 |
+
isRemote: ctx.isRemote,
|
| 37 |
+
openUrl: ctx.openUrl,
|
| 38 |
+
log: (msg) => ctx.runtime.log(msg),
|
| 39 |
+
note: ctx.prompter.note,
|
| 40 |
+
prompt: async (message) => String(await ctx.prompter.text({ message })),
|
| 41 |
+
progress: spin,
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
spin.stop("Gemini CLI OAuth complete");
|
| 45 |
+
const profileId = `google-gemini-cli:${result.email ?? "default"}`;
|
| 46 |
+
return {
|
| 47 |
+
profiles: [
|
| 48 |
+
{
|
| 49 |
+
profileId,
|
| 50 |
+
credential: {
|
| 51 |
+
type: "oauth",
|
| 52 |
+
provider: PROVIDER_ID,
|
| 53 |
+
access: result.access,
|
| 54 |
+
refresh: result.refresh,
|
| 55 |
+
expires: result.expires,
|
| 56 |
+
email: result.email,
|
| 57 |
+
projectId: result.projectId,
|
| 58 |
+
},
|
| 59 |
+
},
|
| 60 |
+
],
|
| 61 |
+
configPatch: {
|
| 62 |
+
agents: {
|
| 63 |
+
defaults: {
|
| 64 |
+
models: {
|
| 65 |
+
[DEFAULT_MODEL]: {},
|
| 66 |
+
},
|
| 67 |
+
},
|
| 68 |
+
},
|
| 69 |
+
},
|
| 70 |
+
defaultModel: DEFAULT_MODEL,
|
| 71 |
+
notes: ["If requests fail, set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID."],
|
| 72 |
+
};
|
| 73 |
+
} catch (err) {
|
| 74 |
+
spin.stop("Gemini CLI OAuth failed");
|
| 75 |
+
await ctx.prompter.note(
|
| 76 |
+
"Trouble with OAuth? Ensure your Google account has Gemini CLI access.",
|
| 77 |
+
"OAuth help",
|
| 78 |
+
);
|
| 79 |
+
throw err;
|
| 80 |
+
}
|
| 81 |
+
},
|
| 82 |
+
},
|
| 83 |
+
],
|
| 84 |
+
});
|
| 85 |
+
},
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
export default geminiCliPlugin;
|
extensions/google-gemini-cli-auth/oauth.test.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { join, parse } from "node:path";
|
| 2 |
+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
| 3 |
+
|
| 4 |
+
// Mock fs module before importing the module under test
|
| 5 |
+
const mockExistsSync = vi.fn();
|
| 6 |
+
const mockReadFileSync = vi.fn();
|
| 7 |
+
const mockRealpathSync = vi.fn();
|
| 8 |
+
const mockReaddirSync = vi.fn();
|
| 9 |
+
|
| 10 |
+
vi.mock("node:fs", async (importOriginal) => {
|
| 11 |
+
const actual = await importOriginal<typeof import("node:fs")>();
|
| 12 |
+
return {
|
| 13 |
+
...actual,
|
| 14 |
+
existsSync: (...args: Parameters<typeof actual.existsSync>) => mockExistsSync(...args),
|
| 15 |
+
readFileSync: (...args: Parameters<typeof actual.readFileSync>) => mockReadFileSync(...args),
|
| 16 |
+
realpathSync: (...args: Parameters<typeof actual.realpathSync>) => mockRealpathSync(...args),
|
| 17 |
+
readdirSync: (...args: Parameters<typeof actual.readdirSync>) => mockReaddirSync(...args),
|
| 18 |
+
};
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
describe("extractGeminiCliCredentials", () => {
|
| 22 |
+
const normalizePath = (value: string) =>
|
| 23 |
+
value.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
| 24 |
+
const rootDir = parse(process.cwd()).root || "/";
|
| 25 |
+
const FAKE_CLIENT_ID = "123456789-abcdef.apps.googleusercontent.com";
|
| 26 |
+
const FAKE_CLIENT_SECRET = "GOCSPX-FakeSecretValue123";
|
| 27 |
+
const FAKE_OAUTH2_CONTENT = `
|
| 28 |
+
const clientId = "${FAKE_CLIENT_ID}";
|
| 29 |
+
const clientSecret = "${FAKE_CLIENT_SECRET}";
|
| 30 |
+
`;
|
| 31 |
+
|
| 32 |
+
let originalPath: string | undefined;
|
| 33 |
+
|
| 34 |
+
beforeEach(async () => {
|
| 35 |
+
vi.resetModules();
|
| 36 |
+
vi.clearAllMocks();
|
| 37 |
+
originalPath = process.env.PATH;
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
afterEach(() => {
|
| 41 |
+
process.env.PATH = originalPath;
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
it("returns null when gemini binary is not in PATH", async () => {
|
| 45 |
+
process.env.PATH = "/nonexistent";
|
| 46 |
+
mockExistsSync.mockReturnValue(false);
|
| 47 |
+
|
| 48 |
+
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
|
| 49 |
+
clearCredentialsCache();
|
| 50 |
+
expect(extractGeminiCliCredentials()).toBeNull();
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
it("extracts credentials from oauth2.js in known path", async () => {
|
| 54 |
+
const fakeBinDir = join(rootDir, "fake", "bin");
|
| 55 |
+
const fakeGeminiPath = join(fakeBinDir, "gemini");
|
| 56 |
+
const fakeResolvedPath = join(
|
| 57 |
+
rootDir,
|
| 58 |
+
"fake",
|
| 59 |
+
"lib",
|
| 60 |
+
"node_modules",
|
| 61 |
+
"@google",
|
| 62 |
+
"gemini-cli",
|
| 63 |
+
"dist",
|
| 64 |
+
"index.js",
|
| 65 |
+
);
|
| 66 |
+
const fakeOauth2Path = join(
|
| 67 |
+
rootDir,
|
| 68 |
+
"fake",
|
| 69 |
+
"lib",
|
| 70 |
+
"node_modules",
|
| 71 |
+
"@google",
|
| 72 |
+
"gemini-cli",
|
| 73 |
+
"node_modules",
|
| 74 |
+
"@google",
|
| 75 |
+
"gemini-cli-core",
|
| 76 |
+
"dist",
|
| 77 |
+
"src",
|
| 78 |
+
"code_assist",
|
| 79 |
+
"oauth2.js",
|
| 80 |
+
);
|
| 81 |
+
|
| 82 |
+
process.env.PATH = fakeBinDir;
|
| 83 |
+
|
| 84 |
+
mockExistsSync.mockImplementation((p: string) => {
|
| 85 |
+
const normalized = normalizePath(p);
|
| 86 |
+
if (normalized === normalizePath(fakeGeminiPath)) {
|
| 87 |
+
return true;
|
| 88 |
+
}
|
| 89 |
+
if (normalized === normalizePath(fakeOauth2Path)) {
|
| 90 |
+
return true;
|
| 91 |
+
}
|
| 92 |
+
return false;
|
| 93 |
+
});
|
| 94 |
+
mockRealpathSync.mockReturnValue(fakeResolvedPath);
|
| 95 |
+
mockReadFileSync.mockReturnValue(FAKE_OAUTH2_CONTENT);
|
| 96 |
+
|
| 97 |
+
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
|
| 98 |
+
clearCredentialsCache();
|
| 99 |
+
const result = extractGeminiCliCredentials();
|
| 100 |
+
|
| 101 |
+
expect(result).toEqual({
|
| 102 |
+
clientId: FAKE_CLIENT_ID,
|
| 103 |
+
clientSecret: FAKE_CLIENT_SECRET,
|
| 104 |
+
});
|
| 105 |
+
});
|
| 106 |
+
|
| 107 |
+
it("returns null when oauth2.js cannot be found", async () => {
|
| 108 |
+
const fakeBinDir = join(rootDir, "fake", "bin");
|
| 109 |
+
const fakeGeminiPath = join(fakeBinDir, "gemini");
|
| 110 |
+
const fakeResolvedPath = join(
|
| 111 |
+
rootDir,
|
| 112 |
+
"fake",
|
| 113 |
+
"lib",
|
| 114 |
+
"node_modules",
|
| 115 |
+
"@google",
|
| 116 |
+
"gemini-cli",
|
| 117 |
+
"dist",
|
| 118 |
+
"index.js",
|
| 119 |
+
);
|
| 120 |
+
|
| 121 |
+
process.env.PATH = fakeBinDir;
|
| 122 |
+
|
| 123 |
+
mockExistsSync.mockImplementation(
|
| 124 |
+
(p: string) => normalizePath(p) === normalizePath(fakeGeminiPath),
|
| 125 |
+
);
|
| 126 |
+
mockRealpathSync.mockReturnValue(fakeResolvedPath);
|
| 127 |
+
mockReaddirSync.mockReturnValue([]); // Empty directory for recursive search
|
| 128 |
+
|
| 129 |
+
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
|
| 130 |
+
clearCredentialsCache();
|
| 131 |
+
expect(extractGeminiCliCredentials()).toBeNull();
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
it("returns null when oauth2.js lacks credentials", async () => {
|
| 135 |
+
const fakeBinDir = join(rootDir, "fake", "bin");
|
| 136 |
+
const fakeGeminiPath = join(fakeBinDir, "gemini");
|
| 137 |
+
const fakeResolvedPath = join(
|
| 138 |
+
rootDir,
|
| 139 |
+
"fake",
|
| 140 |
+
"lib",
|
| 141 |
+
"node_modules",
|
| 142 |
+
"@google",
|
| 143 |
+
"gemini-cli",
|
| 144 |
+
"dist",
|
| 145 |
+
"index.js",
|
| 146 |
+
);
|
| 147 |
+
const fakeOauth2Path = join(
|
| 148 |
+
rootDir,
|
| 149 |
+
"fake",
|
| 150 |
+
"lib",
|
| 151 |
+
"node_modules",
|
| 152 |
+
"@google",
|
| 153 |
+
"gemini-cli",
|
| 154 |
+
"node_modules",
|
| 155 |
+
"@google",
|
| 156 |
+
"gemini-cli-core",
|
| 157 |
+
"dist",
|
| 158 |
+
"src",
|
| 159 |
+
"code_assist",
|
| 160 |
+
"oauth2.js",
|
| 161 |
+
);
|
| 162 |
+
|
| 163 |
+
process.env.PATH = fakeBinDir;
|
| 164 |
+
|
| 165 |
+
mockExistsSync.mockImplementation((p: string) => {
|
| 166 |
+
const normalized = normalizePath(p);
|
| 167 |
+
if (normalized === normalizePath(fakeGeminiPath)) {
|
| 168 |
+
return true;
|
| 169 |
+
}
|
| 170 |
+
if (normalized === normalizePath(fakeOauth2Path)) {
|
| 171 |
+
return true;
|
| 172 |
+
}
|
| 173 |
+
return false;
|
| 174 |
+
});
|
| 175 |
+
mockRealpathSync.mockReturnValue(fakeResolvedPath);
|
| 176 |
+
mockReadFileSync.mockReturnValue("// no credentials here");
|
| 177 |
+
|
| 178 |
+
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
|
| 179 |
+
clearCredentialsCache();
|
| 180 |
+
expect(extractGeminiCliCredentials()).toBeNull();
|
| 181 |
+
});
|
| 182 |
+
|
| 183 |
+
it("caches credentials after first extraction", async () => {
|
| 184 |
+
const fakeBinDir = join(rootDir, "fake", "bin");
|
| 185 |
+
const fakeGeminiPath = join(fakeBinDir, "gemini");
|
| 186 |
+
const fakeResolvedPath = join(
|
| 187 |
+
rootDir,
|
| 188 |
+
"fake",
|
| 189 |
+
"lib",
|
| 190 |
+
"node_modules",
|
| 191 |
+
"@google",
|
| 192 |
+
"gemini-cli",
|
| 193 |
+
"dist",
|
| 194 |
+
"index.js",
|
| 195 |
+
);
|
| 196 |
+
const fakeOauth2Path = join(
|
| 197 |
+
rootDir,
|
| 198 |
+
"fake",
|
| 199 |
+
"lib",
|
| 200 |
+
"node_modules",
|
| 201 |
+
"@google",
|
| 202 |
+
"gemini-cli",
|
| 203 |
+
"node_modules",
|
| 204 |
+
"@google",
|
| 205 |
+
"gemini-cli-core",
|
| 206 |
+
"dist",
|
| 207 |
+
"src",
|
| 208 |
+
"code_assist",
|
| 209 |
+
"oauth2.js",
|
| 210 |
+
);
|
| 211 |
+
|
| 212 |
+
process.env.PATH = fakeBinDir;
|
| 213 |
+
|
| 214 |
+
mockExistsSync.mockImplementation((p: string) => {
|
| 215 |
+
const normalized = normalizePath(p);
|
| 216 |
+
if (normalized === normalizePath(fakeGeminiPath)) {
|
| 217 |
+
return true;
|
| 218 |
+
}
|
| 219 |
+
if (normalized === normalizePath(fakeOauth2Path)) {
|
| 220 |
+
return true;
|
| 221 |
+
}
|
| 222 |
+
return false;
|
| 223 |
+
});
|
| 224 |
+
mockRealpathSync.mockReturnValue(fakeResolvedPath);
|
| 225 |
+
mockReadFileSync.mockReturnValue(FAKE_OAUTH2_CONTENT);
|
| 226 |
+
|
| 227 |
+
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
|
| 228 |
+
clearCredentialsCache();
|
| 229 |
+
|
| 230 |
+
// First call
|
| 231 |
+
const result1 = extractGeminiCliCredentials();
|
| 232 |
+
expect(result1).not.toBeNull();
|
| 233 |
+
|
| 234 |
+
// Second call should use cache (readFileSync not called again)
|
| 235 |
+
const readCount = mockReadFileSync.mock.calls.length;
|
| 236 |
+
const result2 = extractGeminiCliCredentials();
|
| 237 |
+
expect(result2).toEqual(result1);
|
| 238 |
+
expect(mockReadFileSync.mock.calls.length).toBe(readCount);
|
| 239 |
+
});
|
| 240 |
+
});
|
extensions/google-gemini-cli-auth/oauth.ts
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createHash, randomBytes } from "node:crypto";
|
| 2 |
+
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
| 3 |
+
import { createServer } from "node:http";
|
| 4 |
+
import { delimiter, dirname, join } from "node:path";
|
| 5 |
+
|
| 6 |
+
const CLIENT_ID_KEYS = ["OPENCLAW_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"];
|
| 7 |
+
const CLIENT_SECRET_KEYS = [
|
| 8 |
+
"OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET",
|
| 9 |
+
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
|
| 10 |
+
];
|
| 11 |
+
const REDIRECT_URI = "http://localhost:8085/oauth2callback";
|
| 12 |
+
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
| 13 |
+
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
| 14 |
+
const USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json";
|
| 15 |
+
const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
|
| 16 |
+
const SCOPES = [
|
| 17 |
+
"https://www.googleapis.com/auth/cloud-platform",
|
| 18 |
+
"https://www.googleapis.com/auth/userinfo.email",
|
| 19 |
+
"https://www.googleapis.com/auth/userinfo.profile",
|
| 20 |
+
];
|
| 21 |
+
|
| 22 |
+
const TIER_FREE = "free-tier";
|
| 23 |
+
const TIER_LEGACY = "legacy-tier";
|
| 24 |
+
const TIER_STANDARD = "standard-tier";
|
| 25 |
+
|
| 26 |
+
export type GeminiCliOAuthCredentials = {
|
| 27 |
+
access: string;
|
| 28 |
+
refresh: string;
|
| 29 |
+
expires: number;
|
| 30 |
+
email?: string;
|
| 31 |
+
projectId: string;
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
export type GeminiCliOAuthContext = {
|
| 35 |
+
isRemote: boolean;
|
| 36 |
+
openUrl: (url: string) => Promise<void>;
|
| 37 |
+
log: (msg: string) => void;
|
| 38 |
+
note: (message: string, title?: string) => Promise<void>;
|
| 39 |
+
prompt: (message: string) => Promise<string>;
|
| 40 |
+
progress: { update: (msg: string) => void; stop: (msg?: string) => void };
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
function resolveEnv(keys: string[]): string | undefined {
|
| 44 |
+
for (const key of keys) {
|
| 45 |
+
const value = process.env[key]?.trim();
|
| 46 |
+
if (value) {
|
| 47 |
+
return value;
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
return undefined;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null;
|
| 54 |
+
|
| 55 |
+
/** @internal */
|
| 56 |
+
export function clearCredentialsCache(): void {
|
| 57 |
+
cachedGeminiCliCredentials = null;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/** Extracts OAuth credentials from the installed Gemini CLI's bundled oauth2.js. */
|
| 61 |
+
export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null {
|
| 62 |
+
if (cachedGeminiCliCredentials) {
|
| 63 |
+
return cachedGeminiCliCredentials;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
try {
|
| 67 |
+
const geminiPath = findInPath("gemini");
|
| 68 |
+
if (!geminiPath) {
|
| 69 |
+
return null;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
const resolvedPath = realpathSync(geminiPath);
|
| 73 |
+
const geminiCliDir = dirname(dirname(resolvedPath));
|
| 74 |
+
|
| 75 |
+
const searchPaths = [
|
| 76 |
+
join(
|
| 77 |
+
geminiCliDir,
|
| 78 |
+
"node_modules",
|
| 79 |
+
"@google",
|
| 80 |
+
"gemini-cli-core",
|
| 81 |
+
"dist",
|
| 82 |
+
"src",
|
| 83 |
+
"code_assist",
|
| 84 |
+
"oauth2.js",
|
| 85 |
+
),
|
| 86 |
+
join(
|
| 87 |
+
geminiCliDir,
|
| 88 |
+
"node_modules",
|
| 89 |
+
"@google",
|
| 90 |
+
"gemini-cli-core",
|
| 91 |
+
"dist",
|
| 92 |
+
"code_assist",
|
| 93 |
+
"oauth2.js",
|
| 94 |
+
),
|
| 95 |
+
];
|
| 96 |
+
|
| 97 |
+
let content: string | null = null;
|
| 98 |
+
for (const p of searchPaths) {
|
| 99 |
+
if (existsSync(p)) {
|
| 100 |
+
content = readFileSync(p, "utf8");
|
| 101 |
+
break;
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
if (!content) {
|
| 105 |
+
const found = findFile(geminiCliDir, "oauth2.js", 10);
|
| 106 |
+
if (found) {
|
| 107 |
+
content = readFileSync(found, "utf8");
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
if (!content) {
|
| 111 |
+
return null;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/);
|
| 115 |
+
const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/);
|
| 116 |
+
if (idMatch && secretMatch) {
|
| 117 |
+
cachedGeminiCliCredentials = { clientId: idMatch[1], clientSecret: secretMatch[1] };
|
| 118 |
+
return cachedGeminiCliCredentials;
|
| 119 |
+
}
|
| 120 |
+
} catch {
|
| 121 |
+
// Gemini CLI not installed or extraction failed
|
| 122 |
+
}
|
| 123 |
+
return null;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
function findInPath(name: string): string | null {
|
| 127 |
+
const exts = process.platform === "win32" ? [".cmd", ".bat", ".exe", ""] : [""];
|
| 128 |
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
| 129 |
+
for (const ext of exts) {
|
| 130 |
+
const p = join(dir, name + ext);
|
| 131 |
+
if (existsSync(p)) {
|
| 132 |
+
return p;
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
return null;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
function findFile(dir: string, name: string, depth: number): string | null {
|
| 140 |
+
if (depth <= 0) {
|
| 141 |
+
return null;
|
| 142 |
+
}
|
| 143 |
+
try {
|
| 144 |
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
| 145 |
+
const p = join(dir, e.name);
|
| 146 |
+
if (e.isFile() && e.name === name) {
|
| 147 |
+
return p;
|
| 148 |
+
}
|
| 149 |
+
if (e.isDirectory() && !e.name.startsWith(".")) {
|
| 150 |
+
const found = findFile(p, name, depth - 1);
|
| 151 |
+
if (found) {
|
| 152 |
+
return found;
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
} catch {}
|
| 157 |
+
return null;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } {
|
| 161 |
+
// 1. Check env vars first (user override)
|
| 162 |
+
const envClientId = resolveEnv(CLIENT_ID_KEYS);
|
| 163 |
+
const envClientSecret = resolveEnv(CLIENT_SECRET_KEYS);
|
| 164 |
+
if (envClientId) {
|
| 165 |
+
return { clientId: envClientId, clientSecret: envClientSecret };
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
// 2. Try to extract from installed Gemini CLI
|
| 169 |
+
const extracted = extractGeminiCliCredentials();
|
| 170 |
+
if (extracted) {
|
| 171 |
+
return extracted;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
// 3. No credentials available
|
| 175 |
+
throw new Error(
|
| 176 |
+
"Gemini CLI not found. Install it first: brew install gemini-cli (or npm install -g @google/gemini-cli), or set GEMINI_CLI_OAUTH_CLIENT_ID.",
|
| 177 |
+
);
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
function isWSL(): boolean {
|
| 181 |
+
if (process.platform !== "linux") {
|
| 182 |
+
return false;
|
| 183 |
+
}
|
| 184 |
+
try {
|
| 185 |
+
const release = readFileSync("/proc/version", "utf8").toLowerCase();
|
| 186 |
+
return release.includes("microsoft") || release.includes("wsl");
|
| 187 |
+
} catch {
|
| 188 |
+
return false;
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
function isWSL2(): boolean {
|
| 193 |
+
if (!isWSL()) {
|
| 194 |
+
return false;
|
| 195 |
+
}
|
| 196 |
+
try {
|
| 197 |
+
const version = readFileSync("/proc/version", "utf8").toLowerCase();
|
| 198 |
+
return version.includes("wsl2") || version.includes("microsoft-standard");
|
| 199 |
+
} catch {
|
| 200 |
+
return false;
|
| 201 |
+
}
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
|
| 205 |
+
return isRemote || isWSL2();
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
function generatePkce(): { verifier: string; challenge: string } {
|
| 209 |
+
const verifier = randomBytes(32).toString("hex");
|
| 210 |
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
| 211 |
+
return { verifier, challenge };
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
function buildAuthUrl(challenge: string, verifier: string): string {
|
| 215 |
+
const { clientId } = resolveOAuthClientConfig();
|
| 216 |
+
const params = new URLSearchParams({
|
| 217 |
+
client_id: clientId,
|
| 218 |
+
response_type: "code",
|
| 219 |
+
redirect_uri: REDIRECT_URI,
|
| 220 |
+
scope: SCOPES.join(" "),
|
| 221 |
+
code_challenge: challenge,
|
| 222 |
+
code_challenge_method: "S256",
|
| 223 |
+
state: verifier,
|
| 224 |
+
access_type: "offline",
|
| 225 |
+
prompt: "consent",
|
| 226 |
+
});
|
| 227 |
+
return `${AUTH_URL}?${params.toString()}`;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
function parseCallbackInput(
|
| 231 |
+
input: string,
|
| 232 |
+
expectedState: string,
|
| 233 |
+
): { code: string; state: string } | { error: string } {
|
| 234 |
+
const trimmed = input.trim();
|
| 235 |
+
if (!trimmed) {
|
| 236 |
+
return { error: "No input provided" };
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
try {
|
| 240 |
+
const url = new URL(trimmed);
|
| 241 |
+
const code = url.searchParams.get("code");
|
| 242 |
+
const state = url.searchParams.get("state") ?? expectedState;
|
| 243 |
+
if (!code) {
|
| 244 |
+
return { error: "Missing 'code' parameter in URL" };
|
| 245 |
+
}
|
| 246 |
+
if (!state) {
|
| 247 |
+
return { error: "Missing 'state' parameter. Paste the full URL." };
|
| 248 |
+
}
|
| 249 |
+
return { code, state };
|
| 250 |
+
} catch {
|
| 251 |
+
if (!expectedState) {
|
| 252 |
+
return { error: "Paste the full redirect URL, not just the code." };
|
| 253 |
+
}
|
| 254 |
+
return { code: trimmed, state: expectedState };
|
| 255 |
+
}
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
async function waitForLocalCallback(params: {
|
| 259 |
+
expectedState: string;
|
| 260 |
+
timeoutMs: number;
|
| 261 |
+
onProgress?: (message: string) => void;
|
| 262 |
+
}): Promise<{ code: string; state: string }> {
|
| 263 |
+
const port = 8085;
|
| 264 |
+
const hostname = "localhost";
|
| 265 |
+
const expectedPath = "/oauth2callback";
|
| 266 |
+
|
| 267 |
+
return new Promise<{ code: string; state: string }>((resolve, reject) => {
|
| 268 |
+
let timeout: NodeJS.Timeout | null = null;
|
| 269 |
+
const server = createServer((req, res) => {
|
| 270 |
+
try {
|
| 271 |
+
const requestUrl = new URL(req.url ?? "/", `http://${hostname}:${port}`);
|
| 272 |
+
if (requestUrl.pathname !== expectedPath) {
|
| 273 |
+
res.statusCode = 404;
|
| 274 |
+
res.setHeader("Content-Type", "text/plain");
|
| 275 |
+
res.end("Not found");
|
| 276 |
+
return;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
const error = requestUrl.searchParams.get("error");
|
| 280 |
+
const code = requestUrl.searchParams.get("code")?.trim();
|
| 281 |
+
const state = requestUrl.searchParams.get("state")?.trim();
|
| 282 |
+
|
| 283 |
+
if (error) {
|
| 284 |
+
res.statusCode = 400;
|
| 285 |
+
res.setHeader("Content-Type", "text/plain");
|
| 286 |
+
res.end(`Authentication failed: ${error}`);
|
| 287 |
+
finish(new Error(`OAuth error: ${error}`));
|
| 288 |
+
return;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
if (!code || !state) {
|
| 292 |
+
res.statusCode = 400;
|
| 293 |
+
res.setHeader("Content-Type", "text/plain");
|
| 294 |
+
res.end("Missing code or state");
|
| 295 |
+
finish(new Error("Missing OAuth code or state"));
|
| 296 |
+
return;
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
if (state !== params.expectedState) {
|
| 300 |
+
res.statusCode = 400;
|
| 301 |
+
res.setHeader("Content-Type", "text/plain");
|
| 302 |
+
res.end("Invalid state");
|
| 303 |
+
finish(new Error("OAuth state mismatch"));
|
| 304 |
+
return;
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
res.statusCode = 200;
|
| 308 |
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
| 309 |
+
res.end(
|
| 310 |
+
"<!doctype html><html><head><meta charset='utf-8'/></head>" +
|
| 311 |
+
"<body><h2>Gemini CLI OAuth complete</h2>" +
|
| 312 |
+
"<p>You can close this window and return to OpenClaw.</p></body></html>",
|
| 313 |
+
);
|
| 314 |
+
|
| 315 |
+
finish(undefined, { code, state });
|
| 316 |
+
} catch (err) {
|
| 317 |
+
finish(err instanceof Error ? err : new Error("OAuth callback failed"));
|
| 318 |
+
}
|
| 319 |
+
});
|
| 320 |
+
|
| 321 |
+
const finish = (err?: Error, result?: { code: string; state: string }) => {
|
| 322 |
+
if (timeout) {
|
| 323 |
+
clearTimeout(timeout);
|
| 324 |
+
}
|
| 325 |
+
try {
|
| 326 |
+
server.close();
|
| 327 |
+
} catch {
|
| 328 |
+
// ignore close errors
|
| 329 |
+
}
|
| 330 |
+
if (err) {
|
| 331 |
+
reject(err);
|
| 332 |
+
} else if (result) {
|
| 333 |
+
resolve(result);
|
| 334 |
+
}
|
| 335 |
+
};
|
| 336 |
+
|
| 337 |
+
server.once("error", (err) => {
|
| 338 |
+
finish(err instanceof Error ? err : new Error("OAuth callback server error"));
|
| 339 |
+
});
|
| 340 |
+
|
| 341 |
+
server.listen(port, hostname, () => {
|
| 342 |
+
params.onProgress?.(`Waiting for OAuth callback on ${REDIRECT_URI}…`);
|
| 343 |
+
});
|
| 344 |
+
|
| 345 |
+
timeout = setTimeout(() => {
|
| 346 |
+
finish(new Error("OAuth callback timeout"));
|
| 347 |
+
}, params.timeoutMs);
|
| 348 |
+
});
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
async function exchangeCodeForTokens(
|
| 352 |
+
code: string,
|
| 353 |
+
verifier: string,
|
| 354 |
+
): Promise<GeminiCliOAuthCredentials> {
|
| 355 |
+
const { clientId, clientSecret } = resolveOAuthClientConfig();
|
| 356 |
+
const body = new URLSearchParams({
|
| 357 |
+
client_id: clientId,
|
| 358 |
+
code,
|
| 359 |
+
grant_type: "authorization_code",
|
| 360 |
+
redirect_uri: REDIRECT_URI,
|
| 361 |
+
code_verifier: verifier,
|
| 362 |
+
});
|
| 363 |
+
if (clientSecret) {
|
| 364 |
+
body.set("client_secret", clientSecret);
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
const response = await fetch(TOKEN_URL, {
|
| 368 |
+
method: "POST",
|
| 369 |
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
| 370 |
+
body,
|
| 371 |
+
});
|
| 372 |
+
|
| 373 |
+
if (!response.ok) {
|
| 374 |
+
const errorText = await response.text();
|
| 375 |
+
throw new Error(`Token exchange failed: ${errorText}`);
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
const data = (await response.json()) as {
|
| 379 |
+
access_token: string;
|
| 380 |
+
refresh_token: string;
|
| 381 |
+
expires_in: number;
|
| 382 |
+
};
|
| 383 |
+
|
| 384 |
+
if (!data.refresh_token) {
|
| 385 |
+
throw new Error("No refresh token received. Please try again.");
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
const email = await getUserEmail(data.access_token);
|
| 389 |
+
const projectId = await discoverProject(data.access_token);
|
| 390 |
+
const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000;
|
| 391 |
+
|
| 392 |
+
return {
|
| 393 |
+
refresh: data.refresh_token,
|
| 394 |
+
access: data.access_token,
|
| 395 |
+
expires: expiresAt,
|
| 396 |
+
projectId,
|
| 397 |
+
email,
|
| 398 |
+
};
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
async function getUserEmail(accessToken: string): Promise<string | undefined> {
|
| 402 |
+
try {
|
| 403 |
+
const response = await fetch(USERINFO_URL, {
|
| 404 |
+
headers: { Authorization: `Bearer ${accessToken}` },
|
| 405 |
+
});
|
| 406 |
+
if (response.ok) {
|
| 407 |
+
const data = (await response.json()) as { email?: string };
|
| 408 |
+
return data.email;
|
| 409 |
+
}
|
| 410 |
+
} catch {
|
| 411 |
+
// ignore
|
| 412 |
+
}
|
| 413 |
+
return undefined;
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
async function discoverProject(accessToken: string): Promise<string> {
|
| 417 |
+
const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID;
|
| 418 |
+
const headers = {
|
| 419 |
+
Authorization: `Bearer ${accessToken}`,
|
| 420 |
+
"Content-Type": "application/json",
|
| 421 |
+
"User-Agent": "google-api-nodejs-client/9.15.1",
|
| 422 |
+
"X-Goog-Api-Client": "gl-node/openclaw",
|
| 423 |
+
};
|
| 424 |
+
|
| 425 |
+
const loadBody = {
|
| 426 |
+
cloudaicompanionProject: envProject,
|
| 427 |
+
metadata: {
|
| 428 |
+
ideType: "IDE_UNSPECIFIED",
|
| 429 |
+
platform: "PLATFORM_UNSPECIFIED",
|
| 430 |
+
pluginType: "GEMINI",
|
| 431 |
+
duetProject: envProject,
|
| 432 |
+
},
|
| 433 |
+
};
|
| 434 |
+
|
| 435 |
+
let data: {
|
| 436 |
+
currentTier?: { id?: string };
|
| 437 |
+
cloudaicompanionProject?: string | { id?: string };
|
| 438 |
+
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>;
|
| 439 |
+
} = {};
|
| 440 |
+
|
| 441 |
+
try {
|
| 442 |
+
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, {
|
| 443 |
+
method: "POST",
|
| 444 |
+
headers,
|
| 445 |
+
body: JSON.stringify(loadBody),
|
| 446 |
+
});
|
| 447 |
+
|
| 448 |
+
if (!response.ok) {
|
| 449 |
+
const errorPayload = await response.json().catch(() => null);
|
| 450 |
+
if (isVpcScAffected(errorPayload)) {
|
| 451 |
+
data = { currentTier: { id: TIER_STANDARD } };
|
| 452 |
+
} else {
|
| 453 |
+
throw new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`);
|
| 454 |
+
}
|
| 455 |
+
} else {
|
| 456 |
+
data = (await response.json()) as typeof data;
|
| 457 |
+
}
|
| 458 |
+
} catch (err) {
|
| 459 |
+
if (err instanceof Error) {
|
| 460 |
+
throw err;
|
| 461 |
+
}
|
| 462 |
+
throw new Error("loadCodeAssist failed", { cause: err });
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
if (data.currentTier) {
|
| 466 |
+
const project = data.cloudaicompanionProject;
|
| 467 |
+
if (typeof project === "string" && project) {
|
| 468 |
+
return project;
|
| 469 |
+
}
|
| 470 |
+
if (typeof project === "object" && project?.id) {
|
| 471 |
+
return project.id;
|
| 472 |
+
}
|
| 473 |
+
if (envProject) {
|
| 474 |
+
return envProject;
|
| 475 |
+
}
|
| 476 |
+
throw new Error(
|
| 477 |
+
"This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.",
|
| 478 |
+
);
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
const tier = getDefaultTier(data.allowedTiers);
|
| 482 |
+
const tierId = tier?.id || TIER_FREE;
|
| 483 |
+
if (tierId !== TIER_FREE && !envProject) {
|
| 484 |
+
throw new Error(
|
| 485 |
+
"This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.",
|
| 486 |
+
);
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
const onboardBody: Record<string, unknown> = {
|
| 490 |
+
tierId,
|
| 491 |
+
metadata: {
|
| 492 |
+
ideType: "IDE_UNSPECIFIED",
|
| 493 |
+
platform: "PLATFORM_UNSPECIFIED",
|
| 494 |
+
pluginType: "GEMINI",
|
| 495 |
+
},
|
| 496 |
+
};
|
| 497 |
+
if (tierId !== TIER_FREE && envProject) {
|
| 498 |
+
onboardBody.cloudaicompanionProject = envProject;
|
| 499 |
+
(onboardBody.metadata as Record<string, unknown>).duetProject = envProject;
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, {
|
| 503 |
+
method: "POST",
|
| 504 |
+
headers,
|
| 505 |
+
body: JSON.stringify(onboardBody),
|
| 506 |
+
});
|
| 507 |
+
|
| 508 |
+
if (!onboardResponse.ok) {
|
| 509 |
+
throw new Error(`onboardUser failed: ${onboardResponse.status} ${onboardResponse.statusText}`);
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
let lro = (await onboardResponse.json()) as {
|
| 513 |
+
done?: boolean;
|
| 514 |
+
name?: string;
|
| 515 |
+
response?: { cloudaicompanionProject?: { id?: string } };
|
| 516 |
+
};
|
| 517 |
+
|
| 518 |
+
if (!lro.done && lro.name) {
|
| 519 |
+
lro = await pollOperation(lro.name, headers);
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
const projectId = lro.response?.cloudaicompanionProject?.id;
|
| 523 |
+
if (projectId) {
|
| 524 |
+
return projectId;
|
| 525 |
+
}
|
| 526 |
+
if (envProject) {
|
| 527 |
+
return envProject;
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
throw new Error(
|
| 531 |
+
"Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.",
|
| 532 |
+
);
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
function isVpcScAffected(payload: unknown): boolean {
|
| 536 |
+
if (!payload || typeof payload !== "object") {
|
| 537 |
+
return false;
|
| 538 |
+
}
|
| 539 |
+
const error = (payload as { error?: unknown }).error;
|
| 540 |
+
if (!error || typeof error !== "object") {
|
| 541 |
+
return false;
|
| 542 |
+
}
|
| 543 |
+
const details = (error as { details?: unknown[] }).details;
|
| 544 |
+
if (!Array.isArray(details)) {
|
| 545 |
+
return false;
|
| 546 |
+
}
|
| 547 |
+
return details.some(
|
| 548 |
+
(item) =>
|
| 549 |
+
typeof item === "object" &&
|
| 550 |
+
item &&
|
| 551 |
+
(item as { reason?: string }).reason === "SECURITY_POLICY_VIOLATED",
|
| 552 |
+
);
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
function getDefaultTier(
|
| 556 |
+
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>,
|
| 557 |
+
): { id?: string } | undefined {
|
| 558 |
+
if (!allowedTiers?.length) {
|
| 559 |
+
return { id: TIER_LEGACY };
|
| 560 |
+
}
|
| 561 |
+
return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY };
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
async function pollOperation(
|
| 565 |
+
operationName: string,
|
| 566 |
+
headers: Record<string, string>,
|
| 567 |
+
): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> {
|
| 568 |
+
for (let attempt = 0; attempt < 24; attempt += 1) {
|
| 569 |
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
| 570 |
+
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, {
|
| 571 |
+
headers,
|
| 572 |
+
});
|
| 573 |
+
if (!response.ok) {
|
| 574 |
+
continue;
|
| 575 |
+
}
|
| 576 |
+
const data = (await response.json()) as {
|
| 577 |
+
done?: boolean;
|
| 578 |
+
response?: { cloudaicompanionProject?: { id?: string } };
|
| 579 |
+
};
|
| 580 |
+
if (data.done) {
|
| 581 |
+
return data;
|
| 582 |
+
}
|
| 583 |
+
}
|
| 584 |
+
throw new Error("Operation polling timeout");
|
| 585 |
+
}
|
| 586 |
+
|
| 587 |
+
export async function loginGeminiCliOAuth(
|
| 588 |
+
ctx: GeminiCliOAuthContext,
|
| 589 |
+
): Promise<GeminiCliOAuthCredentials> {
|
| 590 |
+
const needsManual = shouldUseManualOAuthFlow(ctx.isRemote);
|
| 591 |
+
await ctx.note(
|
| 592 |
+
needsManual
|
| 593 |
+
? [
|
| 594 |
+
"You are running in a remote/VPS environment.",
|
| 595 |
+
"A URL will be shown for you to open in your LOCAL browser.",
|
| 596 |
+
"After signing in, copy the redirect URL and paste it back here.",
|
| 597 |
+
].join("\n")
|
| 598 |
+
: [
|
| 599 |
+
"Browser will open for Google authentication.",
|
| 600 |
+
"Sign in with your Google account for Gemini CLI access.",
|
| 601 |
+
"The callback will be captured automatically on localhost:8085.",
|
| 602 |
+
].join("\n"),
|
| 603 |
+
"Gemini CLI OAuth",
|
| 604 |
+
);
|
| 605 |
+
|
| 606 |
+
const { verifier, challenge } = generatePkce();
|
| 607 |
+
const authUrl = buildAuthUrl(challenge, verifier);
|
| 608 |
+
|
| 609 |
+
if (needsManual) {
|
| 610 |
+
ctx.progress.update("OAuth URL ready");
|
| 611 |
+
ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`);
|
| 612 |
+
ctx.progress.update("Waiting for you to paste the callback URL...");
|
| 613 |
+
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
|
| 614 |
+
const parsed = parseCallbackInput(callbackInput, verifier);
|
| 615 |
+
if ("error" in parsed) {
|
| 616 |
+
throw new Error(parsed.error);
|
| 617 |
+
}
|
| 618 |
+
if (parsed.state !== verifier) {
|
| 619 |
+
throw new Error("OAuth state mismatch - please try again");
|
| 620 |
+
}
|
| 621 |
+
ctx.progress.update("Exchanging authorization code for tokens...");
|
| 622 |
+
return exchangeCodeForTokens(parsed.code, verifier);
|
| 623 |
+
}
|
| 624 |
+
|
| 625 |
+
ctx.progress.update("Complete sign-in in browser...");
|
| 626 |
+
try {
|
| 627 |
+
await ctx.openUrl(authUrl);
|
| 628 |
+
} catch {
|
| 629 |
+
ctx.log(`\nOpen this URL in your browser:\n\n${authUrl}\n`);
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
try {
|
| 633 |
+
const { code } = await waitForLocalCallback({
|
| 634 |
+
expectedState: verifier,
|
| 635 |
+
timeoutMs: 5 * 60 * 1000,
|
| 636 |
+
onProgress: (msg) => ctx.progress.update(msg),
|
| 637 |
+
});
|
| 638 |
+
ctx.progress.update("Exchanging authorization code for tokens...");
|
| 639 |
+
return await exchangeCodeForTokens(code, verifier);
|
| 640 |
+
} catch (err) {
|
| 641 |
+
if (
|
| 642 |
+
err instanceof Error &&
|
| 643 |
+
(err.message.includes("EADDRINUSE") ||
|
| 644 |
+
err.message.includes("port") ||
|
| 645 |
+
err.message.includes("listen"))
|
| 646 |
+
) {
|
| 647 |
+
ctx.progress.update("Local callback server failed. Switching to manual mode...");
|
| 648 |
+
ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`);
|
| 649 |
+
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
|
| 650 |
+
const parsed = parseCallbackInput(callbackInput, verifier);
|
| 651 |
+
if ("error" in parsed) {
|
| 652 |
+
throw new Error(parsed.error, { cause: err });
|
| 653 |
+
}
|
| 654 |
+
if (parsed.state !== verifier) {
|
| 655 |
+
throw new Error("OAuth state mismatch - please try again", { cause: err });
|
| 656 |
+
}
|
| 657 |
+
ctx.progress.update("Exchanging authorization code for tokens...");
|
| 658 |
+
return exchangeCodeForTokens(parsed.code, verifier);
|
| 659 |
+
}
|
| 660 |
+
throw err;
|
| 661 |
+
}
|
| 662 |
+
}
|
extensions/google-gemini-cli-auth/openclaw.plugin.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "google-gemini-cli-auth",
|
| 3 |
+
"providers": ["google-gemini-cli"],
|
| 4 |
+
"configSchema": {
|
| 5 |
+
"type": "object",
|
| 6 |
+
"additionalProperties": false,
|
| 7 |
+
"properties": {}
|
| 8 |
+
}
|
| 9 |
+
}
|
extensions/google-gemini-cli-auth/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "@openclaw/google-gemini-cli-auth",
|
| 3 |
+
"version": "2026.1.30",
|
| 4 |
+
"description": "OpenClaw Gemini CLI OAuth provider plugin",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"devDependencies": {
|
| 7 |
+
"openclaw": "workspace:*"
|
| 8 |
+
},
|
| 9 |
+
"openclaw": {
|
| 10 |
+
"extensions": [
|
| 11 |
+
"./index.ts"
|
| 12 |
+
]
|
| 13 |
+
}
|
| 14 |
+
}
|
extensions/googlechat/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
| 2 |
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
| 3 |
+
import { googlechatDock, googlechatPlugin } from "./src/channel.js";
|
| 4 |
+
import { handleGoogleChatWebhookRequest } from "./src/monitor.js";
|
| 5 |
+
import { setGoogleChatRuntime } from "./src/runtime.js";
|
| 6 |
+
|
| 7 |
+
const plugin = {
|
| 8 |
+
id: "googlechat",
|
| 9 |
+
name: "Google Chat",
|
| 10 |
+
description: "OpenClaw Google Chat channel plugin",
|
| 11 |
+
configSchema: emptyPluginConfigSchema(),
|
| 12 |
+
register(api: OpenClawPluginApi) {
|
| 13 |
+
setGoogleChatRuntime(api.runtime);
|
| 14 |
+
api.registerChannel({ plugin: googlechatPlugin, dock: googlechatDock });
|
| 15 |
+
api.registerHttpHandler(handleGoogleChatWebhookRequest);
|
| 16 |
+
},
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
export default plugin;
|