diff --git a/src/plugin-sdk/bluebubbles.test.ts b/src/plugin-sdk/bluebubbles.test.ts new file mode 100644 index 00000000000..dbb0b595f26 --- /dev/null +++ b/src/plugin-sdk/bluebubbles.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const loadBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn()); + +vi.mock("./facade-loader.js", () => ({ + loadBundledPluginPublicSurfaceModuleSync, +})); + +describe("plugin-sdk bluebubbles facade", () => { + beforeEach(() => { + vi.resetModules(); + loadBundledPluginPublicSurfaceModuleSync.mockReset(); + }); + + it("delegates conversation matching helpers to the plugin public facade", async () => { + const normalized = { conversationId: "+15551234567" }; + const match = { conversationId: "+15551234567", matchPriority: 2 }; + const normalizeBlueBubblesAcpConversationId = vi.fn().mockReturnValue(normalized); + const matchBlueBubblesAcpConversation = vi.fn().mockReturnValue(match); + const resolveBlueBubblesConversationIdFromTarget = vi.fn().mockReturnValue("+15551234567"); + loadBundledPluginPublicSurfaceModuleSync.mockReturnValue({ + normalizeBlueBubblesAcpConversationId, + matchBlueBubblesAcpConversation, + resolveBlueBubblesConversationIdFromTarget, + }); + + const bluebubbles = await import("./bluebubbles.js"); + + expect(bluebubbles.normalizeBlueBubblesAcpConversationId("sms:+15551234567")).toBe(normalized); + expect( + bluebubbles.matchBlueBubblesAcpConversation({ + bindingConversationId: "+15551234567", + conversationId: "sms:+15551234567", + }), + ).toBe(match); + expect(bluebubbles.resolveBlueBubblesConversationIdFromTarget("sms:+15551234567")).toBe( + "+15551234567", + ); + expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({ + dirName: "bluebubbles", + artifactBasename: "api.js", + }); + expect(normalizeBlueBubblesAcpConversationId).toHaveBeenCalledWith("sms:+15551234567"); + expect(matchBlueBubblesAcpConversation).toHaveBeenCalledWith({ + bindingConversationId: "+15551234567", + conversationId: "sms:+15551234567", + }); + expect(resolveBlueBubblesConversationIdFromTarget).toHaveBeenCalledWith("sms:+15551234567"); + }); +}); diff --git a/src/plugin-sdk/bluebubbles.ts b/src/plugin-sdk/bluebubbles.ts index 978c42e439e..3719918c4e6 100644 --- a/src/plugin-sdk/bluebubbles.ts +++ b/src/plugin-sdk/bluebubbles.ts @@ -1,22 +1,10 @@ import type { ChannelStatusIssue } from "../channels/plugins/types.public.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; -import { - parseChatTargetPrefixesOrThrow, - resolveServicePrefixedTarget, - type ParsedChatTarget, -} from "./channel-targets.js"; import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-loader.js"; // Narrow plugin-sdk surface for the bundled BlueBubbles plugin. // Keep this list additive and scoped to the conversation-binding seam only. -type BlueBubblesService = "imessage" | "sms" | "auto"; - -type BlueBubblesTarget = - | ParsedChatTarget - | { kind: "handle"; to: string; service: BlueBubblesService }; - export type BlueBubblesConversationBindingManager = { stop: () => void; }; @@ -26,6 +14,14 @@ type BlueBubblesFacadeModule = { accountId?: string; cfg: OpenClawConfig; }) => BlueBubblesConversationBindingManager; + normalizeBlueBubblesAcpConversationId: ( + conversationId: string, + ) => { conversationId: string } | null; + matchBlueBubblesAcpConversation: (params: { + bindingConversationId: string; + conversationId: string; + }) => { conversationId: string; matchPriority: number } | null; + resolveBlueBubblesConversationIdFromTarget: (target: string) => string | undefined; collectBlueBubblesStatusIssues: (accounts: unknown[]) => ChannelStatusIssue[]; }; @@ -43,227 +39,21 @@ export function createBlueBubblesConversationBindingManager(params: { return loadBlueBubblesFacadeModule().createBlueBubblesConversationBindingManager(params); } -const CHAT_ID_PREFIXES = ["chat_id:", "chatid:", "chat:"]; -const CHAT_GUID_PREFIXES = ["chat_guid:", "chatguid:", "guid:"]; -const CHAT_IDENTIFIER_PREFIXES = ["chat_identifier:", "chatidentifier:", "chatident:"]; -const SERVICE_PREFIXES: Array<{ prefix: string; service: BlueBubblesService }> = [ - { prefix: "imessage:", service: "imessage" }, - { prefix: "sms:", service: "sms" }, - { prefix: "auto:", service: "auto" }, -]; -const CHAT_IDENTIFIER_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -const CHAT_IDENTIFIER_HEX_RE = /^[0-9a-f]{24,64}$/i; - -function parseRawChatGuid(value: string): string | null { - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - const parts = trimmed.split(";"); - if (parts.length !== 3) { - return null; - } - const service = parts[0]?.trim(); - const separator = parts[1]?.trim(); - const identifier = parts[2]?.trim(); - if (!service || !identifier) { - return null; - } - if (separator !== "+" && separator !== "-") { - return null; - } - return `${service};${separator};${identifier}`; -} - -function stripPrefix(value: string, prefix: string): string { - return value.slice(prefix.length).trim(); -} - -function stripBlueBubblesPrefix(value: string): string { - const trimmed = value.trim(); - if (!trimmed) { - return ""; - } - if (!normalizeLowercaseStringOrEmpty(trimmed).startsWith("bluebubbles:")) { - return trimmed; - } - return trimmed.slice("bluebubbles:".length).trim(); -} - -function looksLikeRawChatIdentifier(value: string): boolean { - const trimmed = value.trim(); - if (!trimmed) { - return false; - } - if (/^chat\d+$/i.test(trimmed)) { - return true; - } - return CHAT_IDENTIFIER_UUID_RE.test(trimmed) || CHAT_IDENTIFIER_HEX_RE.test(trimmed); -} - -function parseGroupTarget(params: { - trimmed: string; - lower: string; -}): { kind: "chat_id"; chatId: number } | { kind: "chat_guid"; chatGuid: string } | null { - if (!params.lower.startsWith("group:")) { - return null; - } - const value = stripPrefix(params.trimmed, "group:"); - const chatId = Number.parseInt(value, 10); - if (Number.isFinite(chatId)) { - return { kind: "chat_id", chatId }; - } - if (value) { - return { kind: "chat_guid", chatGuid: value }; - } - throw new Error("group target is required"); -} - -function parseRawChatIdentifierTarget( - trimmed: string, -): { kind: "chat_identifier"; chatIdentifier: string } | null { - if (/^chat\d+$/i.test(trimmed)) { - return { kind: "chat_identifier", chatIdentifier: trimmed }; - } - if (looksLikeRawChatIdentifier(trimmed)) { - return { kind: "chat_identifier", chatIdentifier: trimmed }; - } - return null; -} - -function normalizeBlueBubblesHandle(raw: string): string { - const trimmed = raw.trim(); - if (!trimmed) { - return ""; - } - const lowered = normalizeLowercaseStringOrEmpty(trimmed); - if (lowered.startsWith("imessage:")) { - return normalizeBlueBubblesHandle(trimmed.slice(9)); - } - if (lowered.startsWith("sms:")) { - return normalizeBlueBubblesHandle(trimmed.slice(4)); - } - if (lowered.startsWith("auto:")) { - return normalizeBlueBubblesHandle(trimmed.slice(5)); - } - if (trimmed.includes("@")) { - return normalizeLowercaseStringOrEmpty(trimmed); - } - return trimmed.replace(/\s+/g, ""); -} - -function extractHandleFromChatGuid(chatGuid: string): string | null { - const parts = chatGuid.split(";"); - if (parts.length === 3 && parts[1] === "-") { - const handle = parts[2]?.trim(); - if (handle) { - return normalizeBlueBubblesHandle(handle); - } - } - return null; -} - -function parseBlueBubblesTarget(raw: string): BlueBubblesTarget { - const trimmed = stripBlueBubblesPrefix(raw); - if (!trimmed) { - throw new Error("BlueBubbles target is required"); - } - const lower = normalizeLowercaseStringOrEmpty(trimmed); - - const servicePrefixed = resolveServicePrefixedTarget({ - trimmed, - lower, - servicePrefixes: SERVICE_PREFIXES, - isChatTarget: (remainderLower) => - CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) || - CHAT_GUID_PREFIXES.some((p) => remainderLower.startsWith(p)) || - CHAT_IDENTIFIER_PREFIXES.some((p) => remainderLower.startsWith(p)) || - remainderLower.startsWith("group:"), - parseTarget: parseBlueBubblesTarget, - }); - if (servicePrefixed) { - return servicePrefixed; - } - - const chatTarget = parseChatTargetPrefixesOrThrow({ - trimmed, - lower, - chatIdPrefixes: CHAT_ID_PREFIXES, - chatGuidPrefixes: CHAT_GUID_PREFIXES, - chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES, - }); - if (chatTarget) { - return chatTarget; - } - - const groupTarget = parseGroupTarget({ trimmed, lower }); - if (groupTarget) { - return groupTarget; - } - - const rawChatGuid = parseRawChatGuid(trimmed); - if (rawChatGuid) { - return { kind: "chat_guid", chatGuid: rawChatGuid }; - } - - const rawChatIdentifierTarget = parseRawChatIdentifierTarget(trimmed); - if (rawChatIdentifierTarget) { - return rawChatIdentifierTarget; - } - - return { kind: "handle", to: trimmed, service: "auto" }; -} - export function normalizeBlueBubblesAcpConversationId( conversationId: string, ): { conversationId: string } | null { - const trimmed = conversationId.trim(); - if (!trimmed) { - return null; - } - - try { - const parsed = parseBlueBubblesTarget(trimmed); - if (parsed.kind === "handle") { - const handle = normalizeBlueBubblesHandle(parsed.to); - return handle ? { conversationId: handle } : null; - } - if (parsed.kind === "chat_id") { - return { conversationId: String(parsed.chatId) }; - } - if (parsed.kind === "chat_guid") { - const handle = extractHandleFromChatGuid(parsed.chatGuid); - return { - conversationId: handle || parsed.chatGuid, - }; - } - return { conversationId: parsed.chatIdentifier }; - } catch { - const handle = normalizeBlueBubblesHandle(trimmed); - return handle ? { conversationId: handle } : null; - } + return loadBlueBubblesFacadeModule().normalizeBlueBubblesAcpConversationId(conversationId); } export function matchBlueBubblesAcpConversation(params: { bindingConversationId: string; conversationId: string; }): { conversationId: string; matchPriority: number } | null { - const binding = normalizeBlueBubblesAcpConversationId(params.bindingConversationId); - const conversation = normalizeBlueBubblesAcpConversationId(params.conversationId); - if (!binding || !conversation) { - return null; - } - if (binding.conversationId !== conversation.conversationId) { - return null; - } - return { - conversationId: conversation.conversationId, - matchPriority: 2, - }; + return loadBlueBubblesFacadeModule().matchBlueBubblesAcpConversation(params); } export function resolveBlueBubblesConversationIdFromTarget(target: string): string | undefined { - return normalizeBlueBubblesAcpConversationId(target)?.conversationId; + return loadBlueBubblesFacadeModule().resolveBlueBubblesConversationIdFromTarget(target); } export function collectBlueBubblesStatusIssues(accounts: unknown[]): ChannelStatusIssue[] {