@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/src/send.ts ADDED
@@ -0,0 +1,418 @@
1
+ import crypto from "node:crypto";
2
+
3
+ import { resolveBlueBubblesAccount } from "./accounts.js";
4
+ import {
5
+ extractHandleFromChatGuid,
6
+ normalizeBlueBubblesHandle,
7
+ parseBlueBubblesTarget,
8
+ } from "./targets.js";
9
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
10
+ import {
11
+ blueBubblesFetchWithTimeout,
12
+ buildBlueBubblesApiUrl,
13
+ type BlueBubblesSendTarget,
14
+ } from "./types.js";
15
+
16
+ export type BlueBubblesSendOpts = {
17
+ serverUrl?: string;
18
+ password?: string;
19
+ accountId?: string;
20
+ timeoutMs?: number;
21
+ cfg?: OpenClawConfig;
22
+ /** Message GUID to reply to (reply threading) */
23
+ replyToMessageGuid?: string;
24
+ /** Part index for reply (default: 0) */
25
+ replyToPartIndex?: number;
26
+ /** Effect ID or short name for message effects (e.g., "slam", "balloons") */
27
+ effectId?: string;
28
+ };
29
+
30
+ export type BlueBubblesSendResult = {
31
+ messageId: string;
32
+ };
33
+
34
+ /** Maps short effect names to full Apple effect IDs */
35
+ const EFFECT_MAP: Record<string, string> = {
36
+ // Bubble effects
37
+ slam: "com.apple.MobileSMS.expressivesend.impact",
38
+ loud: "com.apple.MobileSMS.expressivesend.loud",
39
+ gentle: "com.apple.MobileSMS.expressivesend.gentle",
40
+ invisible: "com.apple.MobileSMS.expressivesend.invisibleink",
41
+ "invisible-ink": "com.apple.MobileSMS.expressivesend.invisibleink",
42
+ "invisible ink": "com.apple.MobileSMS.expressivesend.invisibleink",
43
+ invisibleink: "com.apple.MobileSMS.expressivesend.invisibleink",
44
+ // Screen effects
45
+ echo: "com.apple.messages.effect.CKEchoEffect",
46
+ spotlight: "com.apple.messages.effect.CKSpotlightEffect",
47
+ balloons: "com.apple.messages.effect.CKHappyBirthdayEffect",
48
+ confetti: "com.apple.messages.effect.CKConfettiEffect",
49
+ love: "com.apple.messages.effect.CKHeartEffect",
50
+ heart: "com.apple.messages.effect.CKHeartEffect",
51
+ hearts: "com.apple.messages.effect.CKHeartEffect",
52
+ lasers: "com.apple.messages.effect.CKLasersEffect",
53
+ fireworks: "com.apple.messages.effect.CKFireworksEffect",
54
+ celebration: "com.apple.messages.effect.CKSparklesEffect",
55
+ };
56
+
57
+ function resolveEffectId(raw?: string): string | undefined {
58
+ if (!raw) return undefined;
59
+ const trimmed = raw.trim().toLowerCase();
60
+ if (EFFECT_MAP[trimmed]) return EFFECT_MAP[trimmed];
61
+ const normalized = trimmed.replace(/[\s_]+/g, "-");
62
+ if (EFFECT_MAP[normalized]) return EFFECT_MAP[normalized];
63
+ const compact = trimmed.replace(/[\s_-]+/g, "");
64
+ if (EFFECT_MAP[compact]) return EFFECT_MAP[compact];
65
+ return raw;
66
+ }
67
+
68
+ function resolveSendTarget(raw: string): BlueBubblesSendTarget {
69
+ const parsed = parseBlueBubblesTarget(raw);
70
+ if (parsed.kind === "handle") {
71
+ return {
72
+ kind: "handle",
73
+ address: normalizeBlueBubblesHandle(parsed.to),
74
+ service: parsed.service,
75
+ };
76
+ }
77
+ if (parsed.kind === "chat_id") {
78
+ return { kind: "chat_id", chatId: parsed.chatId };
79
+ }
80
+ if (parsed.kind === "chat_guid") {
81
+ return { kind: "chat_guid", chatGuid: parsed.chatGuid };
82
+ }
83
+ return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
84
+ }
85
+
86
+ function extractMessageId(payload: unknown): string {
87
+ if (!payload || typeof payload !== "object") return "unknown";
88
+ const record = payload as Record<string, unknown>;
89
+ const data =
90
+ record.data && typeof record.data === "object" ? (record.data as Record<string, unknown>) : null;
91
+ const candidates = [
92
+ record.messageId,
93
+ record.messageGuid,
94
+ record.message_guid,
95
+ record.guid,
96
+ record.id,
97
+ data?.messageId,
98
+ data?.messageGuid,
99
+ data?.message_guid,
100
+ data?.message_id,
101
+ data?.guid,
102
+ data?.id,
103
+ ];
104
+ for (const candidate of candidates) {
105
+ if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
106
+ if (typeof candidate === "number" && Number.isFinite(candidate)) return String(candidate);
107
+ }
108
+ return "unknown";
109
+ }
110
+
111
+ type BlueBubblesChatRecord = Record<string, unknown>;
112
+
113
+ function extractChatGuid(chat: BlueBubblesChatRecord): string | null {
114
+ const candidates = [
115
+ chat.chatGuid,
116
+ chat.guid,
117
+ chat.chat_guid,
118
+ chat.identifier,
119
+ chat.chatIdentifier,
120
+ chat.chat_identifier,
121
+ ];
122
+ for (const candidate of candidates) {
123
+ if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
124
+ }
125
+ return null;
126
+ }
127
+
128
+ function extractChatId(chat: BlueBubblesChatRecord): number | null {
129
+ const candidates = [chat.chatId, chat.id, chat.chat_id];
130
+ for (const candidate of candidates) {
131
+ if (typeof candidate === "number" && Number.isFinite(candidate)) return candidate;
132
+ }
133
+ return null;
134
+ }
135
+
136
+ function extractChatIdentifierFromChatGuid(chatGuid: string): string | null {
137
+ const parts = chatGuid.split(";");
138
+ if (parts.length < 3) return null;
139
+ const identifier = parts[2]?.trim();
140
+ return identifier ? identifier : null;
141
+ }
142
+
143
+ function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] {
144
+ const raw =
145
+ (Array.isArray(chat.participants) ? chat.participants : null) ??
146
+ (Array.isArray(chat.handles) ? chat.handles : null) ??
147
+ (Array.isArray(chat.participantHandles) ? chat.participantHandles : null);
148
+ if (!raw) return [];
149
+ const out: string[] = [];
150
+ for (const entry of raw) {
151
+ if (typeof entry === "string") {
152
+ out.push(entry);
153
+ continue;
154
+ }
155
+ if (entry && typeof entry === "object") {
156
+ const record = entry as Record<string, unknown>;
157
+ const candidate =
158
+ (typeof record.address === "string" && record.address) ||
159
+ (typeof record.handle === "string" && record.handle) ||
160
+ (typeof record.id === "string" && record.id) ||
161
+ (typeof record.identifier === "string" && record.identifier);
162
+ if (candidate) out.push(candidate);
163
+ }
164
+ }
165
+ return out;
166
+ }
167
+
168
+ async function queryChats(params: {
169
+ baseUrl: string;
170
+ password: string;
171
+ timeoutMs?: number;
172
+ offset: number;
173
+ limit: number;
174
+ }): Promise<BlueBubblesChatRecord[]> {
175
+ const url = buildBlueBubblesApiUrl({
176
+ baseUrl: params.baseUrl,
177
+ path: "/api/v1/chat/query",
178
+ password: params.password,
179
+ });
180
+ const res = await blueBubblesFetchWithTimeout(
181
+ url,
182
+ {
183
+ method: "POST",
184
+ headers: { "Content-Type": "application/json" },
185
+ body: JSON.stringify({
186
+ limit: params.limit,
187
+ offset: params.offset,
188
+ with: ["participants"],
189
+ }),
190
+ },
191
+ params.timeoutMs,
192
+ );
193
+ if (!res.ok) return [];
194
+ const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null;
195
+ const data = payload && typeof payload.data !== "undefined" ? (payload.data as unknown) : null;
196
+ return Array.isArray(data) ? (data as BlueBubblesChatRecord[]) : [];
197
+ }
198
+
199
+ export async function resolveChatGuidForTarget(params: {
200
+ baseUrl: string;
201
+ password: string;
202
+ timeoutMs?: number;
203
+ target: BlueBubblesSendTarget;
204
+ }): Promise<string | null> {
205
+ if (params.target.kind === "chat_guid") return params.target.chatGuid;
206
+
207
+ const normalizedHandle =
208
+ params.target.kind === "handle" ? normalizeBlueBubblesHandle(params.target.address) : "";
209
+ const targetChatId = params.target.kind === "chat_id" ? params.target.chatId : null;
210
+ const targetChatIdentifier =
211
+ params.target.kind === "chat_identifier" ? params.target.chatIdentifier : null;
212
+
213
+ const limit = 500;
214
+ let participantMatch: string | null = null;
215
+ for (let offset = 0; offset < 5000; offset += limit) {
216
+ const chats = await queryChats({
217
+ baseUrl: params.baseUrl,
218
+ password: params.password,
219
+ timeoutMs: params.timeoutMs,
220
+ offset,
221
+ limit,
222
+ });
223
+ if (chats.length === 0) break;
224
+ for (const chat of chats) {
225
+ if (targetChatId != null) {
226
+ const chatId = extractChatId(chat);
227
+ if (chatId != null && chatId === targetChatId) {
228
+ return extractChatGuid(chat);
229
+ }
230
+ }
231
+ if (targetChatIdentifier) {
232
+ const guid = extractChatGuid(chat);
233
+ if (guid) {
234
+ // Back-compat: some callers might pass a full chat GUID.
235
+ if (guid === targetChatIdentifier) return guid;
236
+
237
+ // Primary match: BlueBubbles `chat_identifier:*` targets correspond to the
238
+ // third component of the chat GUID: `service;(+|-) ;identifier`.
239
+ const guidIdentifier = extractChatIdentifierFromChatGuid(guid);
240
+ if (guidIdentifier && guidIdentifier === targetChatIdentifier) return guid;
241
+ }
242
+
243
+ const identifier =
244
+ typeof chat.identifier === "string"
245
+ ? chat.identifier
246
+ : typeof chat.chatIdentifier === "string"
247
+ ? chat.chatIdentifier
248
+ : typeof chat.chat_identifier === "string"
249
+ ? chat.chat_identifier
250
+ : "";
251
+ if (identifier && identifier === targetChatIdentifier) return guid ?? extractChatGuid(chat);
252
+ }
253
+ if (normalizedHandle) {
254
+ const guid = extractChatGuid(chat);
255
+ const directHandle = guid ? extractHandleFromChatGuid(guid) : null;
256
+ if (directHandle && directHandle === normalizedHandle) {
257
+ return guid;
258
+ }
259
+ if (!participantMatch && guid) {
260
+ // Only consider DM chats (`;-;` separator) as participant matches.
261
+ // Group chats (`;+;` separator) should never match when searching by handle/phone.
262
+ // This prevents routing "send to +1234567890" to a group chat that contains that number.
263
+ const isDmChat = guid.includes(";-;");
264
+ if (isDmChat) {
265
+ const participants = extractParticipantAddresses(chat).map((entry) =>
266
+ normalizeBlueBubblesHandle(entry),
267
+ );
268
+ if (participants.includes(normalizedHandle)) {
269
+ participantMatch = guid;
270
+ }
271
+ }
272
+ }
273
+ }
274
+ }
275
+ }
276
+ return participantMatch;
277
+ }
278
+
279
+ /**
280
+ * Creates a new chat (DM) and optionally sends an initial message.
281
+ * Requires Private API to be enabled in BlueBubbles.
282
+ */
283
+ async function createNewChatWithMessage(params: {
284
+ baseUrl: string;
285
+ password: string;
286
+ address: string;
287
+ message: string;
288
+ timeoutMs?: number;
289
+ }): Promise<BlueBubblesSendResult> {
290
+ const url = buildBlueBubblesApiUrl({
291
+ baseUrl: params.baseUrl,
292
+ path: "/api/v1/chat/new",
293
+ password: params.password,
294
+ });
295
+ const payload = {
296
+ addresses: [params.address],
297
+ message: params.message,
298
+ };
299
+ const res = await blueBubblesFetchWithTimeout(
300
+ url,
301
+ {
302
+ method: "POST",
303
+ headers: { "Content-Type": "application/json" },
304
+ body: JSON.stringify(payload),
305
+ },
306
+ params.timeoutMs,
307
+ );
308
+ if (!res.ok) {
309
+ const errorText = await res.text();
310
+ // Check for Private API not enabled error
311
+ if (res.status === 400 || res.status === 403 || errorText.toLowerCase().includes("private api")) {
312
+ throw new Error(
313
+ `BlueBubbles send failed: Cannot create new chat - Private API must be enabled. Original error: ${errorText || res.status}`,
314
+ );
315
+ }
316
+ throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`);
317
+ }
318
+ const body = await res.text();
319
+ if (!body) return { messageId: "ok" };
320
+ try {
321
+ const parsed = JSON.parse(body) as unknown;
322
+ return { messageId: extractMessageId(parsed) };
323
+ } catch {
324
+ return { messageId: "ok" };
325
+ }
326
+ }
327
+
328
+ export async function sendMessageBlueBubbles(
329
+ to: string,
330
+ text: string,
331
+ opts: BlueBubblesSendOpts = {},
332
+ ): Promise<BlueBubblesSendResult> {
333
+ const trimmedText = text ?? "";
334
+ if (!trimmedText.trim()) {
335
+ throw new Error("BlueBubbles send requires text");
336
+ }
337
+
338
+ const account = resolveBlueBubblesAccount({
339
+ cfg: opts.cfg ?? {},
340
+ accountId: opts.accountId,
341
+ });
342
+ const baseUrl = opts.serverUrl?.trim() || account.config.serverUrl?.trim();
343
+ const password = opts.password?.trim() || account.config.password?.trim();
344
+ if (!baseUrl) throw new Error("BlueBubbles serverUrl is required");
345
+ if (!password) throw new Error("BlueBubbles password is required");
346
+
347
+ const target = resolveSendTarget(to);
348
+ const chatGuid = await resolveChatGuidForTarget({
349
+ baseUrl,
350
+ password,
351
+ timeoutMs: opts.timeoutMs,
352
+ target,
353
+ });
354
+ if (!chatGuid) {
355
+ // If target is a phone number/handle and no existing chat found,
356
+ // auto-create a new DM chat using the /api/v1/chat/new endpoint
357
+ if (target.kind === "handle") {
358
+ return createNewChatWithMessage({
359
+ baseUrl,
360
+ password,
361
+ address: target.address,
362
+ message: trimmedText,
363
+ timeoutMs: opts.timeoutMs,
364
+ });
365
+ }
366
+ throw new Error(
367
+ "BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
368
+ );
369
+ }
370
+ const effectId = resolveEffectId(opts.effectId);
371
+ const needsPrivateApi = Boolean(opts.replyToMessageGuid || effectId);
372
+ const payload: Record<string, unknown> = {
373
+ chatGuid,
374
+ tempGuid: crypto.randomUUID(),
375
+ message: trimmedText,
376
+ };
377
+ if (needsPrivateApi) {
378
+ payload.method = "private-api";
379
+ }
380
+
381
+ // Add reply threading support
382
+ if (opts.replyToMessageGuid) {
383
+ payload.selectedMessageGuid = opts.replyToMessageGuid;
384
+ payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0;
385
+ }
386
+
387
+ // Add message effects support
388
+ if (effectId) {
389
+ payload.effectId = effectId;
390
+ }
391
+
392
+ const url = buildBlueBubblesApiUrl({
393
+ baseUrl,
394
+ path: "/api/v1/message/text",
395
+ password,
396
+ });
397
+ const res = await blueBubblesFetchWithTimeout(
398
+ url,
399
+ {
400
+ method: "POST",
401
+ headers: { "Content-Type": "application/json" },
402
+ body: JSON.stringify(payload),
403
+ },
404
+ opts.timeoutMs,
405
+ );
406
+ if (!res.ok) {
407
+ const errorText = await res.text();
408
+ throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`);
409
+ }
410
+ const body = await res.text();
411
+ if (!body) return { messageId: "ok" };
412
+ try {
413
+ const parsed = JSON.parse(body) as unknown;
414
+ return { messageId: extractMessageId(parsed) };
415
+ } catch {
416
+ return { messageId: "ok" };
417
+ }
418
+ }
@@ -0,0 +1,184 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ looksLikeBlueBubblesTargetId,
5
+ normalizeBlueBubblesMessagingTarget,
6
+ parseBlueBubblesTarget,
7
+ parseBlueBubblesAllowTarget,
8
+ } from "./targets.js";
9
+
10
+ describe("normalizeBlueBubblesMessagingTarget", () => {
11
+ it("normalizes chat_guid targets", () => {
12
+ expect(normalizeBlueBubblesMessagingTarget("chat_guid:ABC-123")).toBe("chat_guid:ABC-123");
13
+ });
14
+
15
+ it("normalizes group numeric targets to chat_id", () => {
16
+ expect(normalizeBlueBubblesMessagingTarget("group:123")).toBe("chat_id:123");
17
+ });
18
+
19
+ it("strips provider prefix and normalizes handles", () => {
20
+ expect(normalizeBlueBubblesMessagingTarget("bluebubbles:imessage:User@Example.com")).toBe(
21
+ "imessage:user@example.com",
22
+ );
23
+ });
24
+
25
+ it("extracts handle from DM chat_guid for cross-context matching", () => {
26
+ // DM format: service;-;handle
27
+ expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;+19257864429")).toBe(
28
+ "+19257864429",
29
+ );
30
+ expect(normalizeBlueBubblesMessagingTarget("chat_guid:SMS;-;+15551234567")).toBe(
31
+ "+15551234567",
32
+ );
33
+ // Email handles
34
+ expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;user@example.com")).toBe(
35
+ "user@example.com",
36
+ );
37
+ });
38
+
39
+ it("preserves group chat_guid format", () => {
40
+ // Group format: service;+;groupId
41
+ expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;+;chat123456789")).toBe(
42
+ "chat_guid:iMessage;+;chat123456789",
43
+ );
44
+ });
45
+
46
+ it("normalizes raw chat_guid values", () => {
47
+ expect(normalizeBlueBubblesMessagingTarget("iMessage;+;chat660250192681427962")).toBe(
48
+ "chat_guid:iMessage;+;chat660250192681427962",
49
+ );
50
+ expect(normalizeBlueBubblesMessagingTarget("iMessage;-;+19257864429")).toBe("+19257864429");
51
+ });
52
+
53
+ it("normalizes chat<digits> pattern to chat_identifier format", () => {
54
+ expect(normalizeBlueBubblesMessagingTarget("chat660250192681427962")).toBe(
55
+ "chat_identifier:chat660250192681427962",
56
+ );
57
+ expect(normalizeBlueBubblesMessagingTarget("chat123")).toBe("chat_identifier:chat123");
58
+ expect(normalizeBlueBubblesMessagingTarget("Chat456789")).toBe("chat_identifier:Chat456789");
59
+ });
60
+
61
+ it("normalizes UUID/hex chat identifiers", () => {
62
+ expect(normalizeBlueBubblesMessagingTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(
63
+ "chat_identifier:8b9c1a10536d4d86a336ea03ab7151cc",
64
+ );
65
+ expect(normalizeBlueBubblesMessagingTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(
66
+ "chat_identifier:1C2D3E4F-1234-5678-9ABC-DEF012345678",
67
+ );
68
+ });
69
+ });
70
+
71
+ describe("looksLikeBlueBubblesTargetId", () => {
72
+ it("accepts chat targets", () => {
73
+ expect(looksLikeBlueBubblesTargetId("chat_guid:ABC-123")).toBe(true);
74
+ });
75
+
76
+ it("accepts email handles", () => {
77
+ expect(looksLikeBlueBubblesTargetId("user@example.com")).toBe(true);
78
+ });
79
+
80
+ it("accepts phone numbers with punctuation", () => {
81
+ expect(looksLikeBlueBubblesTargetId("+1 (555) 123-4567")).toBe(true);
82
+ });
83
+
84
+ it("accepts raw chat_guid values", () => {
85
+ expect(looksLikeBlueBubblesTargetId("iMessage;+;chat660250192681427962")).toBe(true);
86
+ });
87
+
88
+ it("accepts chat<digits> pattern as chat_id", () => {
89
+ expect(looksLikeBlueBubblesTargetId("chat660250192681427962")).toBe(true);
90
+ expect(looksLikeBlueBubblesTargetId("chat123")).toBe(true);
91
+ expect(looksLikeBlueBubblesTargetId("Chat456789")).toBe(true);
92
+ });
93
+
94
+ it("accepts UUID/hex chat identifiers", () => {
95
+ expect(looksLikeBlueBubblesTargetId("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(true);
96
+ expect(looksLikeBlueBubblesTargetId("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(true);
97
+ });
98
+
99
+ it("rejects display names", () => {
100
+ expect(looksLikeBlueBubblesTargetId("Jane Doe")).toBe(false);
101
+ });
102
+ });
103
+
104
+ describe("parseBlueBubblesTarget", () => {
105
+ it("parses chat<digits> pattern as chat_identifier", () => {
106
+ expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({
107
+ kind: "chat_identifier",
108
+ chatIdentifier: "chat660250192681427962",
109
+ });
110
+ expect(parseBlueBubblesTarget("chat123")).toEqual({
111
+ kind: "chat_identifier",
112
+ chatIdentifier: "chat123",
113
+ });
114
+ expect(parseBlueBubblesTarget("Chat456789")).toEqual({
115
+ kind: "chat_identifier",
116
+ chatIdentifier: "Chat456789",
117
+ });
118
+ });
119
+
120
+ it("parses UUID/hex chat identifiers as chat_identifier", () => {
121
+ expect(parseBlueBubblesTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
122
+ kind: "chat_identifier",
123
+ chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
124
+ });
125
+ expect(parseBlueBubblesTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
126
+ kind: "chat_identifier",
127
+ chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
128
+ });
129
+ });
130
+
131
+ it("parses explicit chat_id: prefix", () => {
132
+ expect(parseBlueBubblesTarget("chat_id:123")).toEqual({ kind: "chat_id", chatId: 123 });
133
+ });
134
+
135
+ it("parses phone numbers as handles", () => {
136
+ expect(parseBlueBubblesTarget("+19257864429")).toEqual({
137
+ kind: "handle",
138
+ to: "+19257864429",
139
+ service: "auto",
140
+ });
141
+ });
142
+
143
+ it("parses raw chat_guid format", () => {
144
+ expect(parseBlueBubblesTarget("iMessage;+;chat660250192681427962")).toEqual({
145
+ kind: "chat_guid",
146
+ chatGuid: "iMessage;+;chat660250192681427962",
147
+ });
148
+ });
149
+ });
150
+
151
+ describe("parseBlueBubblesAllowTarget", () => {
152
+ it("parses chat<digits> pattern as chat_identifier", () => {
153
+ expect(parseBlueBubblesAllowTarget("chat660250192681427962")).toEqual({
154
+ kind: "chat_identifier",
155
+ chatIdentifier: "chat660250192681427962",
156
+ });
157
+ expect(parseBlueBubblesAllowTarget("chat123")).toEqual({
158
+ kind: "chat_identifier",
159
+ chatIdentifier: "chat123",
160
+ });
161
+ });
162
+
163
+ it("parses UUID/hex chat identifiers as chat_identifier", () => {
164
+ expect(parseBlueBubblesAllowTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
165
+ kind: "chat_identifier",
166
+ chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
167
+ });
168
+ expect(parseBlueBubblesAllowTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
169
+ kind: "chat_identifier",
170
+ chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
171
+ });
172
+ });
173
+
174
+ it("parses explicit chat_id: prefix", () => {
175
+ expect(parseBlueBubblesAllowTarget("chat_id:456")).toEqual({ kind: "chat_id", chatId: 456 });
176
+ });
177
+
178
+ it("parses phone numbers as handles", () => {
179
+ expect(parseBlueBubblesAllowTarget("+19257864429")).toEqual({
180
+ kind: "handle",
181
+ handle: "+19257864429",
182
+ });
183
+ });
184
+ });