darkfire514 commited on
Commit
87fc763
·
verified ·
1 Parent(s): caea1dc

Upload 553 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. extensions/bluebubbles/index.ts +19 -0
  2. extensions/bluebubbles/openclaw.plugin.json +9 -0
  3. extensions/bluebubbles/package.json +36 -0
  4. extensions/bluebubbles/src/accounts.ts +88 -0
  5. extensions/bluebubbles/src/actions.test.ts +650 -0
  6. extensions/bluebubbles/src/actions.ts +438 -0
  7. extensions/bluebubbles/src/attachments.test.ts +345 -0
  8. extensions/bluebubbles/src/attachments.ts +300 -0
  9. extensions/bluebubbles/src/channel.ts +414 -0
  10. extensions/bluebubbles/src/chat.test.ts +461 -0
  11. extensions/bluebubbles/src/chat.ts +378 -0
  12. extensions/bluebubbles/src/config-schema.ts +51 -0
  13. extensions/bluebubbles/src/media-send.ts +174 -0
  14. extensions/bluebubbles/src/monitor.test.ts +2340 -0
  15. extensions/bluebubbles/src/monitor.ts +2469 -0
  16. extensions/bluebubbles/src/onboarding.ts +352 -0
  17. extensions/bluebubbles/src/probe.ts +135 -0
  18. extensions/bluebubbles/src/reactions.test.ts +392 -0
  19. extensions/bluebubbles/src/reactions.ts +188 -0
  20. extensions/bluebubbles/src/runtime.ts +14 -0
  21. extensions/bluebubbles/src/send.test.ts +808 -0
  22. extensions/bluebubbles/src/send.ts +467 -0
  23. extensions/bluebubbles/src/targets.test.ts +183 -0
  24. extensions/bluebubbles/src/targets.ts +422 -0
  25. extensions/bluebubbles/src/types.ts +127 -0
  26. extensions/copilot-proxy/README.md +24 -0
  27. extensions/copilot-proxy/index.ts +148 -0
  28. extensions/copilot-proxy/openclaw.plugin.json +9 -0
  29. extensions/copilot-proxy/package.json +14 -0
  30. extensions/diagnostics-otel/index.ts +15 -0
  31. extensions/diagnostics-otel/openclaw.plugin.json +8 -0
  32. extensions/diagnostics-otel/package.json +27 -0
  33. extensions/diagnostics-otel/src/service.test.ts +226 -0
  34. extensions/diagnostics-otel/src/service.ts +635 -0
  35. extensions/discord/index.ts +17 -0
  36. extensions/discord/openclaw.plugin.json +9 -0
  37. extensions/discord/package.json +14 -0
  38. extensions/discord/src/channel.ts +422 -0
  39. extensions/discord/src/runtime.ts +14 -0
  40. extensions/google-antigravity-auth/README.md +24 -0
  41. extensions/google-antigravity-auth/index.ts +461 -0
  42. extensions/google-antigravity-auth/openclaw.plugin.json +9 -0
  43. extensions/google-antigravity-auth/package.json +14 -0
  44. extensions/google-gemini-cli-auth/README.md +35 -0
  45. extensions/google-gemini-cli-auth/index.ts +88 -0
  46. extensions/google-gemini-cli-auth/oauth.test.ts +240 -0
  47. extensions/google-gemini-cli-auth/oauth.ts +662 -0
  48. extensions/google-gemini-cli-auth/openclaw.plugin.json +9 -0
  49. extensions/google-gemini-cli-auth/package.json +14 -0
  50. 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;