Spaces:
Running
Running
| export type BlueBubblesService = "imessage" | "sms" | "auto"; | |
| export type BlueBubblesTarget = | |
| | { kind: "chat_id"; chatId: number } | |
| | { kind: "chat_guid"; chatGuid: string } | |
| | { kind: "chat_identifier"; chatIdentifier: string } | |
| | { kind: "handle"; to: string; service: BlueBubblesService }; | |
| export type BlueBubblesAllowTarget = | |
| | { kind: "chat_id"; chatId: number } | |
| | { kind: "chat_guid"; chatGuid: string } | |
| | { kind: "chat_identifier"; chatIdentifier: string } | |
| | { kind: "handle"; handle: string }; | |
| 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 (!trimmed.toLowerCase().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); | |
| } | |
| export function normalizeBlueBubblesHandle(raw: string): string { | |
| const trimmed = raw.trim(); | |
| if (!trimmed) { | |
| return ""; | |
| } | |
| const lowered = trimmed.toLowerCase(); | |
| 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 trimmed.toLowerCase(); | |
| } | |
| return trimmed.replace(/\s+/g, ""); | |
| } | |
| /** | |
| * Extracts the handle from a chat_guid if it's a DM (1:1 chat). | |
| * BlueBubbles chat_guid format for DM: "service;-;handle" (e.g., "iMessage;-;+19257864429") | |
| * Group chat format: "service;+;groupId" (has "+" instead of "-") | |
| */ | |
| export function extractHandleFromChatGuid(chatGuid: string): string | null { | |
| const parts = chatGuid.split(";"); | |
| // DM format: service;-;handle (3 parts, middle is "-") | |
| if (parts.length === 3 && parts[1] === "-") { | |
| const handle = parts[2]?.trim(); | |
| if (handle) { | |
| return normalizeBlueBubblesHandle(handle); | |
| } | |
| } | |
| return null; | |
| } | |
| export function normalizeBlueBubblesMessagingTarget(raw: string): string | undefined { | |
| let trimmed = raw.trim(); | |
| if (!trimmed) { | |
| return undefined; | |
| } | |
| trimmed = stripBlueBubblesPrefix(trimmed); | |
| if (!trimmed) { | |
| return undefined; | |
| } | |
| try { | |
| const parsed = parseBlueBubblesTarget(trimmed); | |
| if (parsed.kind === "chat_id") { | |
| return `chat_id:${parsed.chatId}`; | |
| } | |
| if (parsed.kind === "chat_guid") { | |
| // For DM chat_guids, normalize to just the handle for easier comparison. | |
| // This allows "chat_guid:iMessage;-;+1234567890" to match "+1234567890". | |
| const handle = extractHandleFromChatGuid(parsed.chatGuid); | |
| if (handle) { | |
| return handle; | |
| } | |
| // For group chats or unrecognized formats, keep the full chat_guid | |
| return `chat_guid:${parsed.chatGuid}`; | |
| } | |
| if (parsed.kind === "chat_identifier") { | |
| return `chat_identifier:${parsed.chatIdentifier}`; | |
| } | |
| const handle = normalizeBlueBubblesHandle(parsed.to); | |
| if (!handle) { | |
| return undefined; | |
| } | |
| return parsed.service === "auto" ? handle : `${parsed.service}:${handle}`; | |
| } catch { | |
| return trimmed; | |
| } | |
| } | |
| export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string): boolean { | |
| const trimmed = raw.trim(); | |
| if (!trimmed) { | |
| return false; | |
| } | |
| const candidate = stripBlueBubblesPrefix(trimmed); | |
| if (!candidate) { | |
| return false; | |
| } | |
| if (parseRawChatGuid(candidate)) { | |
| return true; | |
| } | |
| const lowered = candidate.toLowerCase(); | |
| if (/^(imessage|sms|auto):/.test(lowered)) { | |
| return true; | |
| } | |
| if ( | |
| /^(chat_id|chatid|chat|chat_guid|chatguid|guid|chat_identifier|chatidentifier|chatident|group):/.test( | |
| lowered, | |
| ) | |
| ) { | |
| return true; | |
| } | |
| // Recognize chat<digits> patterns (e.g., "chat660250192681427962") as chat IDs | |
| if (/^chat\d+$/i.test(candidate)) { | |
| return true; | |
| } | |
| if (looksLikeRawChatIdentifier(candidate)) { | |
| return true; | |
| } | |
| if (candidate.includes("@")) { | |
| return true; | |
| } | |
| const digitsOnly = candidate.replace(/[\s().-]/g, ""); | |
| if (/^\+?\d{3,}$/.test(digitsOnly)) { | |
| return true; | |
| } | |
| if (normalized) { | |
| const normalizedTrimmed = normalized.trim(); | |
| if (!normalizedTrimmed) { | |
| return false; | |
| } | |
| const normalizedLower = normalizedTrimmed.toLowerCase(); | |
| if ( | |
| /^(imessage|sms|auto):/.test(normalizedLower) || | |
| /^(chat_id|chat_guid|chat_identifier):/.test(normalizedLower) | |
| ) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget { | |
| const trimmed = stripBlueBubblesPrefix(raw); | |
| if (!trimmed) { | |
| throw new Error("BlueBubbles target is required"); | |
| } | |
| const lower = trimmed.toLowerCase(); | |
| for (const { prefix, service } of SERVICE_PREFIXES) { | |
| if (lower.startsWith(prefix)) { | |
| const remainder = stripPrefix(trimmed, prefix); | |
| if (!remainder) { | |
| throw new Error(`${prefix} target is required`); | |
| } | |
| const remainderLower = remainder.toLowerCase(); | |
| const isChatTarget = | |
| 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:"); | |
| if (isChatTarget) { | |
| return parseBlueBubblesTarget(remainder); | |
| } | |
| return { kind: "handle", to: remainder, service }; | |
| } | |
| } | |
| for (const prefix of CHAT_ID_PREFIXES) { | |
| if (lower.startsWith(prefix)) { | |
| const value = stripPrefix(trimmed, prefix); | |
| const chatId = Number.parseInt(value, 10); | |
| if (!Number.isFinite(chatId)) { | |
| throw new Error(`Invalid chat_id: ${value}`); | |
| } | |
| return { kind: "chat_id", chatId }; | |
| } | |
| } | |
| for (const prefix of CHAT_GUID_PREFIXES) { | |
| if (lower.startsWith(prefix)) { | |
| const value = stripPrefix(trimmed, prefix); | |
| if (!value) { | |
| throw new Error("chat_guid is required"); | |
| } | |
| return { kind: "chat_guid", chatGuid: value }; | |
| } | |
| } | |
| for (const prefix of CHAT_IDENTIFIER_PREFIXES) { | |
| if (lower.startsWith(prefix)) { | |
| const value = stripPrefix(trimmed, prefix); | |
| if (!value) { | |
| throw new Error("chat_identifier is required"); | |
| } | |
| return { kind: "chat_identifier", chatIdentifier: value }; | |
| } | |
| } | |
| if (lower.startsWith("group:")) { | |
| const value = stripPrefix(trimmed, "group:"); | |
| const chatId = Number.parseInt(value, 10); | |
| if (Number.isFinite(chatId)) { | |
| return { kind: "chat_id", chatId }; | |
| } | |
| if (!value) { | |
| throw new Error("group target is required"); | |
| } | |
| return { kind: "chat_guid", chatGuid: value }; | |
| } | |
| const rawChatGuid = parseRawChatGuid(trimmed); | |
| if (rawChatGuid) { | |
| return { kind: "chat_guid", chatGuid: rawChatGuid }; | |
| } | |
| // Handle chat<digits> pattern (e.g., "chat660250192681427962") as chat_identifier | |
| // These are BlueBubbles chat identifiers (the third part of a chat GUID), not numeric IDs | |
| if (/^chat\d+$/i.test(trimmed)) { | |
| return { kind: "chat_identifier", chatIdentifier: trimmed }; | |
| } | |
| // Handle UUID/hex chat identifiers (e.g., "8b9c1a10536d4d86a336ea03ab7151cc") | |
| if (looksLikeRawChatIdentifier(trimmed)) { | |
| return { kind: "chat_identifier", chatIdentifier: trimmed }; | |
| } | |
| return { kind: "handle", to: trimmed, service: "auto" }; | |
| } | |
| export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget { | |
| const trimmed = raw.trim(); | |
| if (!trimmed) { | |
| return { kind: "handle", handle: "" }; | |
| } | |
| const lower = trimmed.toLowerCase(); | |
| for (const { prefix } of SERVICE_PREFIXES) { | |
| if (lower.startsWith(prefix)) { | |
| const remainder = stripPrefix(trimmed, prefix); | |
| if (!remainder) { | |
| return { kind: "handle", handle: "" }; | |
| } | |
| return parseBlueBubblesAllowTarget(remainder); | |
| } | |
| } | |
| for (const prefix of CHAT_ID_PREFIXES) { | |
| if (lower.startsWith(prefix)) { | |
| const value = stripPrefix(trimmed, prefix); | |
| const chatId = Number.parseInt(value, 10); | |
| if (Number.isFinite(chatId)) { | |
| return { kind: "chat_id", chatId }; | |
| } | |
| } | |
| } | |
| for (const prefix of CHAT_GUID_PREFIXES) { | |
| if (lower.startsWith(prefix)) { | |
| const value = stripPrefix(trimmed, prefix); | |
| if (value) { | |
| return { kind: "chat_guid", chatGuid: value }; | |
| } | |
| } | |
| } | |
| for (const prefix of CHAT_IDENTIFIER_PREFIXES) { | |
| if (lower.startsWith(prefix)) { | |
| const value = stripPrefix(trimmed, prefix); | |
| if (value) { | |
| return { kind: "chat_identifier", chatIdentifier: value }; | |
| } | |
| } | |
| } | |
| if (lower.startsWith("group:")) { | |
| const value = stripPrefix(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 }; | |
| } | |
| } | |
| // Handle chat<digits> pattern (e.g., "chat660250192681427962") as chat_identifier | |
| // These are BlueBubbles chat identifiers (the third part of a chat GUID), not numeric IDs | |
| if (/^chat\d+$/i.test(trimmed)) { | |
| return { kind: "chat_identifier", chatIdentifier: trimmed }; | |
| } | |
| // Handle UUID/hex chat identifiers (e.g., "8b9c1a10536d4d86a336ea03ab7151cc") | |
| if (looksLikeRawChatIdentifier(trimmed)) { | |
| return { kind: "chat_identifier", chatIdentifier: trimmed }; | |
| } | |
| return { kind: "handle", handle: normalizeBlueBubblesHandle(trimmed) }; | |
| } | |
| export function isAllowedBlueBubblesSender(params: { | |
| allowFrom: Array<string | number>; | |
| sender: string; | |
| chatId?: number | null; | |
| chatGuid?: string | null; | |
| chatIdentifier?: string | null; | |
| }): boolean { | |
| const allowFrom = params.allowFrom.map((entry) => String(entry).trim()); | |
| if (allowFrom.length === 0) { | |
| return true; | |
| } | |
| if (allowFrom.includes("*")) { | |
| return true; | |
| } | |
| const senderNormalized = normalizeBlueBubblesHandle(params.sender); | |
| const chatId = params.chatId ?? undefined; | |
| const chatGuid = params.chatGuid?.trim(); | |
| const chatIdentifier = params.chatIdentifier?.trim(); | |
| for (const entry of allowFrom) { | |
| if (!entry) { | |
| continue; | |
| } | |
| const parsed = parseBlueBubblesAllowTarget(entry); | |
| if (parsed.kind === "chat_id" && chatId !== undefined) { | |
| if (parsed.chatId === chatId) { | |
| return true; | |
| } | |
| } else if (parsed.kind === "chat_guid" && chatGuid) { | |
| if (parsed.chatGuid === chatGuid) { | |
| return true; | |
| } | |
| } else if (parsed.kind === "chat_identifier" && chatIdentifier) { | |
| if (parsed.chatIdentifier === chatIdentifier) { | |
| return true; | |
| } | |
| } else if (parsed.kind === "handle" && senderNormalized) { | |
| if (parsed.handle === senderNormalized) { | |
| return true; | |
| } | |
| } | |
| } | |
| return false; | |
| } | |
| export function formatBlueBubblesChatTarget(params: { | |
| chatId?: number | null; | |
| chatGuid?: string | null; | |
| chatIdentifier?: string | null; | |
| }): string { | |
| if (params.chatId && Number.isFinite(params.chatId)) { | |
| return `chat_id:${params.chatId}`; | |
| } | |
| const guid = params.chatGuid?.trim(); | |
| if (guid) { | |
| return `chat_guid:${guid}`; | |
| } | |
| const identifier = params.chatIdentifier?.trim(); | |
| if (identifier) { | |
| return `chat_identifier:${identifier}`; | |
| } | |
| return ""; | |
| } | |