@openclaw/bluebubbles 2026.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.ts +20 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +33 -0
- package/src/accounts.ts +80 -0
- package/src/actions.test.ts +651 -0
- package/src/actions.ts +403 -0
- package/src/attachments.test.ts +346 -0
- package/src/attachments.ts +282 -0
- package/src/channel.ts +399 -0
- package/src/chat.test.ts +462 -0
- package/src/chat.ts +354 -0
- package/src/config-schema.ts +51 -0
- package/src/media-send.ts +168 -0
- package/src/monitor.test.ts +2146 -0
- package/src/monitor.ts +2276 -0
- package/src/onboarding.ts +340 -0
- package/src/probe.ts +127 -0
- package/src/reactions.test.ts +393 -0
- package/src/reactions.ts +183 -0
- package/src/runtime.ts +14 -0
- package/src/send.test.ts +809 -0
- package/src/send.ts +418 -0
- package/src/targets.test.ts +184 -0
- package/src/targets.ts +323 -0
- package/src/types.ts +127 -0
package/src/channel.ts
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
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
|
+
|
|
17
|
+
import {
|
|
18
|
+
listBlueBubblesAccountIds,
|
|
19
|
+
type ResolvedBlueBubblesAccount,
|
|
20
|
+
resolveBlueBubblesAccount,
|
|
21
|
+
resolveDefaultBlueBubblesAccountId,
|
|
22
|
+
} from "./accounts.js";
|
|
23
|
+
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
|
24
|
+
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
|
25
|
+
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
|
|
26
|
+
import { sendMessageBlueBubbles } from "./send.js";
|
|
27
|
+
import {
|
|
28
|
+
extractHandleFromChatGuid,
|
|
29
|
+
looksLikeBlueBubblesTargetId,
|
|
30
|
+
normalizeBlueBubblesHandle,
|
|
31
|
+
normalizeBlueBubblesMessagingTarget,
|
|
32
|
+
parseBlueBubblesTarget,
|
|
33
|
+
} from "./targets.js";
|
|
34
|
+
import { bluebubblesMessageActions } from "./actions.js";
|
|
35
|
+
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
|
|
36
|
+
import { blueBubblesOnboardingAdapter } from "./onboarding.js";
|
|
37
|
+
import { sendBlueBubblesMedia } from "./media-send.js";
|
|
38
|
+
|
|
39
|
+
const meta = {
|
|
40
|
+
id: "bluebubbles",
|
|
41
|
+
label: "BlueBubbles",
|
|
42
|
+
selectionLabel: "BlueBubbles (macOS app)",
|
|
43
|
+
detailLabel: "BlueBubbles",
|
|
44
|
+
docsPath: "/channels/bluebubbles",
|
|
45
|
+
docsLabel: "bluebubbles",
|
|
46
|
+
blurb: "iMessage via the BlueBubbles mac app + REST API.",
|
|
47
|
+
systemImage: "bubble.left.and.text.bubble.right",
|
|
48
|
+
aliases: ["bb"],
|
|
49
|
+
order: 75,
|
|
50
|
+
preferOver: ["imessage"],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|
54
|
+
id: "bluebubbles",
|
|
55
|
+
meta,
|
|
56
|
+
capabilities: {
|
|
57
|
+
chatTypes: ["direct", "group"],
|
|
58
|
+
media: true,
|
|
59
|
+
reactions: true,
|
|
60
|
+
edit: true,
|
|
61
|
+
unsend: true,
|
|
62
|
+
reply: true,
|
|
63
|
+
effects: true,
|
|
64
|
+
groupManagement: true,
|
|
65
|
+
},
|
|
66
|
+
groups: {
|
|
67
|
+
resolveRequireMention: resolveBlueBubblesGroupRequireMention,
|
|
68
|
+
resolveToolPolicy: resolveBlueBubblesGroupToolPolicy,
|
|
69
|
+
},
|
|
70
|
+
threading: {
|
|
71
|
+
buildToolContext: ({ context, hasRepliedRef }) => ({
|
|
72
|
+
currentChannelId: context.To?.trim() || undefined,
|
|
73
|
+
currentThreadTs: context.ReplyToIdFull ?? context.ReplyToId,
|
|
74
|
+
hasRepliedRef,
|
|
75
|
+
}),
|
|
76
|
+
},
|
|
77
|
+
reload: { configPrefixes: ["channels.bluebubbles"] },
|
|
78
|
+
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
|
|
79
|
+
onboarding: blueBubblesOnboardingAdapter,
|
|
80
|
+
config: {
|
|
81
|
+
listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg as OpenClawConfig),
|
|
82
|
+
resolveAccount: (cfg, accountId) =>
|
|
83
|
+
resolveBlueBubblesAccount({ cfg: cfg as OpenClawConfig, accountId }),
|
|
84
|
+
defaultAccountId: (cfg) => resolveDefaultBlueBubblesAccountId(cfg as OpenClawConfig),
|
|
85
|
+
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
|
86
|
+
setAccountEnabledInConfigSection({
|
|
87
|
+
cfg: cfg as OpenClawConfig,
|
|
88
|
+
sectionKey: "bluebubbles",
|
|
89
|
+
accountId,
|
|
90
|
+
enabled,
|
|
91
|
+
allowTopLevel: true,
|
|
92
|
+
}),
|
|
93
|
+
deleteAccount: ({ cfg, accountId }) =>
|
|
94
|
+
deleteAccountFromConfigSection({
|
|
95
|
+
cfg: cfg as OpenClawConfig,
|
|
96
|
+
sectionKey: "bluebubbles",
|
|
97
|
+
accountId,
|
|
98
|
+
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
|
|
99
|
+
}),
|
|
100
|
+
isConfigured: (account) => account.configured,
|
|
101
|
+
describeAccount: (account): ChannelAccountSnapshot => ({
|
|
102
|
+
accountId: account.accountId,
|
|
103
|
+
name: account.name,
|
|
104
|
+
enabled: account.enabled,
|
|
105
|
+
configured: account.configured,
|
|
106
|
+
baseUrl: account.baseUrl,
|
|
107
|
+
}),
|
|
108
|
+
resolveAllowFrom: ({ cfg, accountId }) =>
|
|
109
|
+
(resolveBlueBubblesAccount({ cfg: cfg as OpenClawConfig, accountId }).config.allowFrom ??
|
|
110
|
+
[]).map(
|
|
111
|
+
(entry) => String(entry),
|
|
112
|
+
),
|
|
113
|
+
formatAllowFrom: ({ allowFrom }) =>
|
|
114
|
+
allowFrom
|
|
115
|
+
.map((entry) => String(entry).trim())
|
|
116
|
+
.filter(Boolean)
|
|
117
|
+
.map((entry) => entry.replace(/^bluebubbles:/i, ""))
|
|
118
|
+
.map((entry) => normalizeBlueBubblesHandle(entry)),
|
|
119
|
+
},
|
|
120
|
+
actions: bluebubblesMessageActions,
|
|
121
|
+
security: {
|
|
122
|
+
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
|
123
|
+
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
124
|
+
const useAccountPath = Boolean(
|
|
125
|
+
(cfg as OpenClawConfig).channels?.bluebubbles?.accounts?.[resolvedAccountId],
|
|
126
|
+
);
|
|
127
|
+
const basePath = useAccountPath
|
|
128
|
+
? `channels.bluebubbles.accounts.${resolvedAccountId}.`
|
|
129
|
+
: "channels.bluebubbles.";
|
|
130
|
+
return {
|
|
131
|
+
policy: account.config.dmPolicy ?? "pairing",
|
|
132
|
+
allowFrom: account.config.allowFrom ?? [],
|
|
133
|
+
policyPath: `${basePath}dmPolicy`,
|
|
134
|
+
allowFromPath: basePath,
|
|
135
|
+
approveHint: formatPairingApproveHint("bluebubbles"),
|
|
136
|
+
normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")),
|
|
137
|
+
};
|
|
138
|
+
},
|
|
139
|
+
collectWarnings: ({ account }) => {
|
|
140
|
+
const groupPolicy = account.config.groupPolicy ?? "allowlist";
|
|
141
|
+
if (groupPolicy !== "open") return [];
|
|
142
|
+
return [
|
|
143
|
+
`- BlueBubbles groups: groupPolicy="open" allows any member to trigger the bot. Set channels.bluebubbles.groupPolicy="allowlist" + channels.bluebubbles.groupAllowFrom to restrict senders.`,
|
|
144
|
+
];
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
messaging: {
|
|
148
|
+
normalizeTarget: normalizeBlueBubblesMessagingTarget,
|
|
149
|
+
targetResolver: {
|
|
150
|
+
looksLikeId: looksLikeBlueBubblesTargetId,
|
|
151
|
+
hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>",
|
|
152
|
+
},
|
|
153
|
+
formatTargetDisplay: ({ target, display }) => {
|
|
154
|
+
const shouldParseDisplay = (value: string): boolean => {
|
|
155
|
+
if (looksLikeBlueBubblesTargetId(value)) return true;
|
|
156
|
+
return /^(bluebubbles:|chat_guid:|chat_id:|chat_identifier:)/i.test(value);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Helper to extract a clean handle from any BlueBubbles target format
|
|
160
|
+
const extractCleanDisplay = (value: string | undefined): string | null => {
|
|
161
|
+
const trimmed = value?.trim();
|
|
162
|
+
if (!trimmed) return null;
|
|
163
|
+
try {
|
|
164
|
+
const parsed = parseBlueBubblesTarget(trimmed);
|
|
165
|
+
if (parsed.kind === "chat_guid") {
|
|
166
|
+
const handle = extractHandleFromChatGuid(parsed.chatGuid);
|
|
167
|
+
if (handle) return handle;
|
|
168
|
+
}
|
|
169
|
+
if (parsed.kind === "handle") {
|
|
170
|
+
return normalizeBlueBubblesHandle(parsed.to);
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
// Fall through
|
|
174
|
+
}
|
|
175
|
+
// Strip common prefixes and try raw extraction
|
|
176
|
+
const stripped = trimmed
|
|
177
|
+
.replace(/^bluebubbles:/i, "")
|
|
178
|
+
.replace(/^chat_guid:/i, "")
|
|
179
|
+
.replace(/^chat_id:/i, "")
|
|
180
|
+
.replace(/^chat_identifier:/i, "");
|
|
181
|
+
const handle = extractHandleFromChatGuid(stripped);
|
|
182
|
+
if (handle) return handle;
|
|
183
|
+
// Don't return raw chat_guid formats - they contain internal routing info
|
|
184
|
+
if (stripped.includes(";-;") || stripped.includes(";+;")) return null;
|
|
185
|
+
return stripped;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// Try to get a clean display from the display parameter first
|
|
189
|
+
const trimmedDisplay = display?.trim();
|
|
190
|
+
if (trimmedDisplay) {
|
|
191
|
+
if (!shouldParseDisplay(trimmedDisplay)) {
|
|
192
|
+
return trimmedDisplay;
|
|
193
|
+
}
|
|
194
|
+
const cleanDisplay = extractCleanDisplay(trimmedDisplay);
|
|
195
|
+
if (cleanDisplay) return cleanDisplay;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Fall back to extracting from target
|
|
199
|
+
const cleanTarget = extractCleanDisplay(target);
|
|
200
|
+
if (cleanTarget) return cleanTarget;
|
|
201
|
+
|
|
202
|
+
// Last resort: return display or target as-is
|
|
203
|
+
return display?.trim() || target?.trim() || "";
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
setup: {
|
|
207
|
+
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
|
208
|
+
applyAccountName: ({ cfg, accountId, name }) =>
|
|
209
|
+
applyAccountNameToChannelSection({
|
|
210
|
+
cfg: cfg as OpenClawConfig,
|
|
211
|
+
channelKey: "bluebubbles",
|
|
212
|
+
accountId,
|
|
213
|
+
name,
|
|
214
|
+
}),
|
|
215
|
+
validateInput: ({ input }) => {
|
|
216
|
+
if (!input.httpUrl && !input.password) {
|
|
217
|
+
return "BlueBubbles requires --http-url and --password.";
|
|
218
|
+
}
|
|
219
|
+
if (!input.httpUrl) return "BlueBubbles requires --http-url.";
|
|
220
|
+
if (!input.password) return "BlueBubbles requires --password.";
|
|
221
|
+
return null;
|
|
222
|
+
},
|
|
223
|
+
applyAccountConfig: ({ cfg, accountId, input }) => {
|
|
224
|
+
const namedConfig = applyAccountNameToChannelSection({
|
|
225
|
+
cfg: cfg as OpenClawConfig,
|
|
226
|
+
channelKey: "bluebubbles",
|
|
227
|
+
accountId,
|
|
228
|
+
name: input.name,
|
|
229
|
+
});
|
|
230
|
+
const next =
|
|
231
|
+
accountId !== DEFAULT_ACCOUNT_ID
|
|
232
|
+
? migrateBaseNameToDefaultAccount({
|
|
233
|
+
cfg: namedConfig,
|
|
234
|
+
channelKey: "bluebubbles",
|
|
235
|
+
})
|
|
236
|
+
: namedConfig;
|
|
237
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
238
|
+
return {
|
|
239
|
+
...next,
|
|
240
|
+
channels: {
|
|
241
|
+
...next.channels,
|
|
242
|
+
bluebubbles: {
|
|
243
|
+
...next.channels?.bluebubbles,
|
|
244
|
+
enabled: true,
|
|
245
|
+
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
|
|
246
|
+
...(input.password ? { password: input.password } : {}),
|
|
247
|
+
...(input.webhookPath ? { webhookPath: input.webhookPath } : {}),
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
} as OpenClawConfig;
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
...next,
|
|
254
|
+
channels: {
|
|
255
|
+
...next.channels,
|
|
256
|
+
bluebubbles: {
|
|
257
|
+
...next.channels?.bluebubbles,
|
|
258
|
+
enabled: true,
|
|
259
|
+
accounts: {
|
|
260
|
+
...(next.channels?.bluebubbles?.accounts ?? {}),
|
|
261
|
+
[accountId]: {
|
|
262
|
+
...(next.channels?.bluebubbles?.accounts?.[accountId] ?? {}),
|
|
263
|
+
enabled: true,
|
|
264
|
+
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
|
|
265
|
+
...(input.password ? { password: input.password } : {}),
|
|
266
|
+
...(input.webhookPath ? { webhookPath: input.webhookPath } : {}),
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
} as OpenClawConfig;
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
pairing: {
|
|
275
|
+
idLabel: "bluebubblesSenderId",
|
|
276
|
+
normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
|
|
277
|
+
notifyApproval: async ({ cfg, id }) => {
|
|
278
|
+
await sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, {
|
|
279
|
+
cfg: cfg as OpenClawConfig,
|
|
280
|
+
});
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
outbound: {
|
|
284
|
+
deliveryMode: "direct",
|
|
285
|
+
textChunkLimit: 4000,
|
|
286
|
+
resolveTarget: ({ to }) => {
|
|
287
|
+
const trimmed = to?.trim();
|
|
288
|
+
if (!trimmed) {
|
|
289
|
+
return {
|
|
290
|
+
ok: false,
|
|
291
|
+
error: new Error("Delivering to BlueBubbles requires --to <handle|chat_guid:GUID>"),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
return { ok: true, to: trimmed };
|
|
295
|
+
},
|
|
296
|
+
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
|
|
297
|
+
const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : "";
|
|
298
|
+
// Resolve short ID (e.g., "5") to full UUID
|
|
299
|
+
const replyToMessageGuid = rawReplyToId
|
|
300
|
+
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
|
|
301
|
+
: "";
|
|
302
|
+
const result = await sendMessageBlueBubbles(to, text, {
|
|
303
|
+
cfg: cfg as OpenClawConfig,
|
|
304
|
+
accountId: accountId ?? undefined,
|
|
305
|
+
replyToMessageGuid: replyToMessageGuid || undefined,
|
|
306
|
+
});
|
|
307
|
+
return { channel: "bluebubbles", ...result };
|
|
308
|
+
},
|
|
309
|
+
sendMedia: async (ctx) => {
|
|
310
|
+
const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx;
|
|
311
|
+
const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as {
|
|
312
|
+
mediaPath?: string;
|
|
313
|
+
mediaBuffer?: Uint8Array;
|
|
314
|
+
contentType?: string;
|
|
315
|
+
filename?: string;
|
|
316
|
+
caption?: string;
|
|
317
|
+
};
|
|
318
|
+
const resolvedCaption = caption ?? text;
|
|
319
|
+
const result = await sendBlueBubblesMedia({
|
|
320
|
+
cfg: cfg as OpenClawConfig,
|
|
321
|
+
to,
|
|
322
|
+
mediaUrl,
|
|
323
|
+
mediaPath,
|
|
324
|
+
mediaBuffer,
|
|
325
|
+
contentType,
|
|
326
|
+
filename,
|
|
327
|
+
caption: resolvedCaption ?? undefined,
|
|
328
|
+
replyToId: replyToId ?? null,
|
|
329
|
+
accountId: accountId ?? undefined,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
return { channel: "bluebubbles", ...result };
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
status: {
|
|
336
|
+
defaultRuntime: {
|
|
337
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
338
|
+
running: false,
|
|
339
|
+
lastStartAt: null,
|
|
340
|
+
lastStopAt: null,
|
|
341
|
+
lastError: null,
|
|
342
|
+
},
|
|
343
|
+
collectStatusIssues: collectBlueBubblesStatusIssues,
|
|
344
|
+
buildChannelSummary: ({ snapshot }) => ({
|
|
345
|
+
configured: snapshot.configured ?? false,
|
|
346
|
+
baseUrl: snapshot.baseUrl ?? null,
|
|
347
|
+
running: snapshot.running ?? false,
|
|
348
|
+
lastStartAt: snapshot.lastStartAt ?? null,
|
|
349
|
+
lastStopAt: snapshot.lastStopAt ?? null,
|
|
350
|
+
lastError: snapshot.lastError ?? null,
|
|
351
|
+
probe: snapshot.probe,
|
|
352
|
+
lastProbeAt: snapshot.lastProbeAt ?? null,
|
|
353
|
+
}),
|
|
354
|
+
probeAccount: async ({ account, timeoutMs }) =>
|
|
355
|
+
probeBlueBubbles({
|
|
356
|
+
baseUrl: account.baseUrl,
|
|
357
|
+
password: account.config.password ?? null,
|
|
358
|
+
timeoutMs,
|
|
359
|
+
}),
|
|
360
|
+
buildAccountSnapshot: ({ account, runtime, probe }) => {
|
|
361
|
+
const running = runtime?.running ?? false;
|
|
362
|
+
const probeOk = (probe as BlueBubblesProbe | undefined)?.ok;
|
|
363
|
+
return {
|
|
364
|
+
accountId: account.accountId,
|
|
365
|
+
name: account.name,
|
|
366
|
+
enabled: account.enabled,
|
|
367
|
+
configured: account.configured,
|
|
368
|
+
baseUrl: account.baseUrl,
|
|
369
|
+
running,
|
|
370
|
+
connected: probeOk ?? running,
|
|
371
|
+
lastStartAt: runtime?.lastStartAt ?? null,
|
|
372
|
+
lastStopAt: runtime?.lastStopAt ?? null,
|
|
373
|
+
lastError: runtime?.lastError ?? null,
|
|
374
|
+
probe,
|
|
375
|
+
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
376
|
+
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
|
377
|
+
};
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
gateway: {
|
|
381
|
+
startAccount: async (ctx) => {
|
|
382
|
+
const account = ctx.account;
|
|
383
|
+
const webhookPath = resolveWebhookPathFromConfig(account.config);
|
|
384
|
+
ctx.setStatus({
|
|
385
|
+
accountId: account.accountId,
|
|
386
|
+
baseUrl: account.baseUrl,
|
|
387
|
+
});
|
|
388
|
+
ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`);
|
|
389
|
+
return monitorBlueBubblesProvider({
|
|
390
|
+
account,
|
|
391
|
+
config: ctx.cfg as OpenClawConfig,
|
|
392
|
+
runtime: ctx.runtime,
|
|
393
|
+
abortSignal: ctx.abortSignal,
|
|
394
|
+
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
|
395
|
+
webhookPath,
|
|
396
|
+
});
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
};
|