diff --git a/client.ts b/client.ts index 5869247..2095198 100644 --- a/client.ts +++ b/client.ts @@ -3,6 +3,7 @@ import axios, { type AxiosInstance } from "axios"; import io from "socket.io-client"; import { getLogger, setGlobalLogLevel, setGlobalLogToFile } from "./lib/Loggable"; import type { LogLevel } from "./lib/Logger"; +import { parse } from "./lib/parseaple"; import { AttachmentModule, ChatModule, @@ -256,7 +257,21 @@ export class AdvancedIMessageKit extends EventEmitter implements TypedEventEmitt } if (args.length > 0) { - super.emit(eventName, args[0]); + const data = args[0]; + if ( + (eventName === "new-message" || + eventName === "updated-message" || + eventName === "message-updated") && + data && + typeof data === "object" + ) { + try { + (data as any).parsed = parse(data as any); + } catch { + // Parsing should never block message delivery + } + } + super.emit(eventName, data); } else { super.emit(eventName); } @@ -351,6 +366,11 @@ export class AdvancedIMessageKit extends EventEmitter implements TypedEventEmitt if (msg.dateCreated && msg.dateCreated > this.lastMessageTime) { this.lastMessageTime = msg.dateCreated; } + try { + (msg as any).parsed = parse(msg); + } catch { + // Parsing should never block message delivery + } super.emit("new-message", msg); } } diff --git a/index.ts b/index.ts index 9d8e7b3..91838a6 100644 --- a/index.ts +++ b/index.ts @@ -3,6 +3,8 @@ import "reflect-metadata"; export { AdvancedIMessageKit, SDK } from "./client"; export * from "./events"; export { getLogger, setGlobalLogLevel, setGlobalLogToFile } from "./lib/Loggable"; +export type * from "./lib/parseaple"; +export { classify, describe, parse, parseVCard } from "./lib/parseaple"; export { getOptionTextById, getPollOneLiner, diff --git a/lib/parseaple/describe.ts b/lib/parseaple/describe.ts new file mode 100644 index 0000000..4701709 --- /dev/null +++ b/lib/parseaple/describe.ts @@ -0,0 +1,122 @@ +import type { ParsedBase, ParsedDescription, ParsedMessage } from "./types"; + +const BASE_KEYS = new Set([ + "type", + "guid", + "chatGuid", + "from", + "isFromMe", + "timestamp", + "replyToGuid", + "threadGuid", + "effect", +]); + +function extractData(parsed: ParsedMessage): Record { + const data: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + if (!BASE_KEYS.has(key) && value !== undefined) data[key] = value; + } + return data; +} + +function bytes(n: number): string { + if (n < 1024) return `${n}B`; + if (n < 1048576) return `${(n / 1024).toFixed(0)}KB`; + return `${(n / 1048576).toFixed(1)}MB`; +} + +function truncate(text: string, max = 80): string { + return text.length > max ? `${text.slice(0, max)}...` : text; +} + +function dims(d?: { width: number; height: number }): string { + return d ? ` (${d.width}x${d.height})` : ""; +} + +function replyPrefix(parsed: ParsedMessage): string { + if (!("replyToGuid" in parsed) || !parsed.replyToGuid) return ""; + return `[reply to ${parsed.replyToGuid}] `; +} + +function summarize(parsed: ParsedMessage): string { + const rp = replyPrefix(parsed); + switch (parsed.type) { + case "text": { + const fx = parsed.effect ? ` with ${parsed.effect} effect` : ""; + const styled = parsed.styles?.length ? " (styled)" : ""; + return `${rp}Sent a text${fx}${styled}: "${truncate(parsed.text)}"`; + } + case "rich-link": + return parsed.title ? `Shared a link: "${parsed.title}" (${parsed.url})` : `Shared a link: ${parsed.url}`; + + case "collaboration": + return `Shared ${parsed.app.name} "${parsed.title}" for collaboration`; + + case "location-share": { + if (parsed.kind === "request") return "Requested location"; + const loc = `${parsed.coordinates.lat.toFixed(4)}, ${parsed.coordinates.lng.toFixed(4)}`; + const dur = parsed.duration + ? ` for ${parsed.duration === "oneHour" ? "1 hour" : parsed.duration === "untilEndOfDay" ? "end of day" : parsed.duration}` + : ""; + return parsed.address?.short + ? `${parsed.action}${dur} in ${parsed.address.short} (${loc})` + : `${parsed.action}${dur} (${loc})`; + } + case "contact": + return parsed.fullName ? `Shared a contact: ${parsed.fullName}` : "Shared a contact card"; + + case "sticker": + return "Sent a sticker"; + case "audio": + return `Sent a voice message (${bytes(parsed.attachment.sizeBytes)})`; + case "image": + return `Sent a photo${dims(parsed.dimensions)}`; + case "gif": + return `Sent a GIF${dims(parsed.dimensions)}`; + case "video": + return `Sent a video (${bytes(parsed.attachment.sizeBytes)})`; + case "file": + return `Sent a file: ${parsed.attachment.fileName} (${bytes(parsed.attachment.sizeBytes)})`; + case "digital-touch": + return `Sent a Digital Touch${parsed.expired ? " (expired)" : ""}`; + + case "reaction": { + const e = parsed.emoji ? ` ${parsed.emoji}` : ""; + return `${parsed.isRemoval ? "Removed" : "Reacted with"} ${parsed.reaction}${e} on ${parsed.targetMessageGuid}`; + } + case "edit": { + const from = parsed.originalText ? `from "${truncate(parsed.originalText)}" ` : ""; + return `Edited a message ${from}to: "${truncate(parsed.newText)}"`; + } + case "unsend": + return "Unsent a message"; + + case "poll": + return parsed.isPollVote + ? "Voted on a poll" + : parsed.title + ? `Created a poll: "${parsed.title}"` + : "Created a poll"; + + case "checkin": { + const mode = parsed.mode !== "unknown" ? ` (${parsed.mode})` : ""; + const dest = parsed.destinationName ? ` → ${parsed.destinationName}` : ""; + return `Check In${mode}: ${parsed.status}${dest}`; + } + case "system": + return parsed.groupTitle + ? `System: ${parsed.systemType} — "${parsed.groupTitle}"` + : `System: ${parsed.systemType}`; + case "unknown": + return "Sent an unrecognized message type"; + } +} + +export function describe(parsed: ParsedMessage): ParsedDescription { + return { + summary: summarize(parsed), + type: parsed.type, + data: extractData(parsed), + }; +} diff --git a/lib/parseaple/index.ts b/lib/parseaple/index.ts new file mode 100644 index 0000000..d611d18 --- /dev/null +++ b/lib/parseaple/index.ts @@ -0,0 +1,4 @@ +export { describe } from "./describe"; +export { classify, type MessageType, parse } from "./parse"; +export type * from "./types"; +export { parseVCard, type VCardData } from "./vcard"; diff --git a/lib/parseaple/parse.ts b/lib/parseaple/parse.ts new file mode 100644 index 0000000..68760c2 --- /dev/null +++ b/lib/parseaple/parse.ts @@ -0,0 +1,800 @@ +import { inflateRawSync } from "node:zlib"; +import type { AttachmentResponse } from "../../types/attachment"; +import type { MessageResponse } from "../../types/message"; +import { isPollVote, POLL_BALLOON_BUNDLE_ID, parsePollDefinition } from "../poll-utils"; +import type { + AttachmentMeta, + AudioMessage, + CheckInMessage, + CollaborationMessage, + ContactMessage, + DigitalTouchMessage, + EditMessage, + FileMessage, + GifMessage, + ImageMessage, + LocationShareMessage, + ParsedBase, + ParsedMessage, + PollMessage, + ReactionMessage, + RichLinkMessage, + StickerMessage, + SystemMessage, + TextMessage, + TextStyleRange, + UnknownMessage, + UnsendMessage, + VideoMessage, +} from "./types"; +import { parseVCard } from "./vcard"; + +// --- Effects --- + +const EFFECT_ID_TO_NAME: Record = { + "com.apple.messages.effect.CKConfettiEffect": "confetti", + "com.apple.messages.effect.CKFireworksEffect": "fireworks", + "com.apple.messages.effect.CKBalloonEffect": "balloons", + "com.apple.messages.effect.CKHeartEffect": "hearts", + "com.apple.messages.effect.CKHappyBirthdayEffect": "happyBirthday", + "com.apple.messages.effect.CKLasersEffect": "lasers", + "com.apple.messages.effect.CKShootingStarEffect": "shootingStar", + "com.apple.messages.effect.CKSparklesEffect": "sparkles", + "com.apple.messages.effect.CKCelebrationEffect": "celebration", + "com.apple.messages.effect.CKEchoEffect": "echo", + "com.apple.messages.effect.CKSpotlightEffect": "spotlight", + "com.apple.MobileSMS.expressivesend.gentle": "gentle", + "com.apple.MobileSMS.expressivesend.loud": "loud", + "com.apple.MobileSMS.expressivesend.impact": "slam", + "com.apple.MobileSMS.expressivesend.invisibleink": "invisibleInk", +}; + +function resolveEffect(id: string | null | undefined): string | undefined { + if (!id) return undefined; + return EFFECT_ID_TO_NAME[id] ?? id; +} + +// --- Classification --- + +export type MessageType = + | "unsend" + | "edit" + | "reaction" + | "location-share" + | "collaboration" + | "rich-link" + | "digital-touch" + | "checkin" + | "poll" + | "audio" + | "sticker" + | "contact" + | "gif" + | "image" + | "video" + | "file" + | "system" + | "text" + | "unknown"; + +export function classify(msg: MessageResponse): MessageType { + if (msg.dateRetracted) return "unsend"; + if (msg.dateEdited) return "edit"; + + const bid = msg.balloonBundleId; + if (bid) { + if (bid.includes("findmy.FindMyMessagesApp")) return "location-share"; + if (bid === "com.apple.messages.URLBalloonProvider") return classifyUrlBalloon(msg); + if (bid === "com.apple.DigitalTouchBalloonProvider") return "digital-touch"; + + if (bid.includes("CheckIn") || bid.includes("GSCheckInMessageExtension") || bid.includes("SafetyMonitorApp")) + return "checkin"; + if (bid === POLL_BALLOON_BUNDLE_ID) return "poll"; + } + + if (msg.associatedMessageGuid) return "reaction"; + + if (msg.isPoll) return "poll"; + if (msg.isAudioMessage) return "audio"; + + const att = msg.attachments?.[0]; + if (att) { + if (att.isSticker) return "sticker"; + const mime = att.mimeType?.toLowerCase() ?? ""; + const uti = att.uti?.toLowerCase() ?? ""; + if (mime === "text/vcard" || mime === "text/x-vcard" || uti === "public.vcard") return "contact"; + if (mime === "image/gif" || uti === "com.compuserve.gif") return "gif"; + if (mime.startsWith("image/")) return "image"; + if (mime.startsWith("video/") || uti.includes("movie") || uti.includes("video")) return "video"; + return "file"; + } + + if (msg.itemType > 0) return "system"; + if (msg.text) return "text"; + return "unknown"; +} + +function classifyUrlBalloon(msg: MessageResponse): "collaboration" | "rich-link" { + const objects = msg.payloadData?.[0]?.$objects; + if (Array.isArray(objects)) { + for (const obj of objects) { + if (typeof obj === "object" && obj !== null) { + if ("collaborationMetadata" in obj) return "collaboration"; + if ("isCollaboration" in obj) return "collaboration"; + } + } + } + const text = msg.text ?? ""; + if ( + text.includes("icloud.com/notes/") || + text.includes("icloud.com/reminders/") || + text.includes("icloud.com/pages/") || + text.includes("icloud.com/numbers/") || + text.includes("icloud.com/keynote/") + ) { + return "collaboration"; + } + return "rich-link"; +} + +// --- Reactions --- + +const CLASSIC_TAPBACKS: Record = { + love: "love", + like: "like", + dislike: "dislike", + laugh: "laugh", + emphasize: "emphasize", + question: "question", +}; + +// 2000-2005 = add classic tapback, 3000-3005 = remove classic tapback +// 2006+ = add emoji reaction, 3006+ = remove emoji reaction +const NUMERIC_CLASSIC: Record = { + 2000: "love", + 2001: "like", + 2002: "dislike", + 2003: "laugh", + 2004: "emphasize", + 2005: "question", +}; + +/** Server often sets replyToGuid to the previous bubble for ordering; only threadOriginator* marks a real inline reply. */ +function resolveInlineReplyTarget(msg: MessageResponse): string | undefined { + const hasInlineReplyThread = + (msg.threadOriginatorGuid != null && msg.threadOriginatorGuid !== "") || + (msg.threadOriginatorPart != null && msg.threadOriginatorPart !== ""); + if (!hasInlineReplyThread) return undefined; + const g = msg.replyToGuid; + return g != null && g !== "" ? g : undefined; +} + +const MAX_CHAT_GUID_CACHE = 4096; +const messageGuidToChatGuid = new Map(); + +/** chats[] is sometimes omitted on updated-message; cache by message guid when we have seen it. */ +function resolveChatGuid(msg: MessageResponse): string { + const fromChat = msg.chats?.[0]?.guid; + if (fromChat) { + if (messageGuidToChatGuid.size >= MAX_CHAT_GUID_CACHE) { + const first = messageGuidToChatGuid.keys().next().value; + if (first !== undefined) messageGuidToChatGuid.delete(first); + } + messageGuidToChatGuid.set(msg.guid, fromChat); + return fromChat; + } + return messageGuidToChatGuid.get(msg.guid) ?? ""; +} + +function buildBase(msg: MessageResponse): ParsedBase { + return { + type: "unknown", + guid: msg.guid, + chatGuid: resolveChatGuid(msg), + from: msg.handle?.address ?? (msg.isFromMe ? "me" : "unknown"), + isFromMe: msg.isFromMe, + timestamp: new Date(msg.dateCreated), + replyToGuid: resolveInlineReplyTarget(msg), + threadGuid: msg.threadOriginatorGuid ?? undefined, + effect: resolveEffect(msg.expressiveSendStyleId), + }; +} + +function toAttachment(att: AttachmentResponse): AttachmentMeta { + return { + guid: att.guid, + mimeType: att.mimeType ?? "application/octet-stream", + fileName: att.transferName ?? "unknown", + sizeBytes: att.totalBytes ?? 0, + uti: att.uti, + }; +} + +function getDimensions(att: AttachmentResponse): { width: number; height: number } | undefined { + if (att.width && att.height) return { width: att.width, height: att.height }; + if (att.metadata?.width && att.metadata?.height) { + return { width: Number(att.metadata.width), height: Number(att.metadata.height) }; + } + return undefined; +} + +function resolveUid(objects: any[], uid: any): any { + if (typeof uid === "object" && uid !== null && "UID" in uid) return objects[uid.UID]; + return uid; +} + +function resolveString(objects: any[], uid: any): string | undefined { + const val = resolveUid(objects, uid); + return typeof val === "string" ? val : undefined; +} + +function parseReactionTarget(raw: string): { + targetMessageGuid: string; + targetPart: number; + targetType: "part" | "balloon"; +} { + if (raw.startsWith("p:")) { + const match = raw.match(/^p:(\d+)\/(.*)/); + if (match) + return { targetMessageGuid: match[2]!, targetPart: Number.parseInt(match[1]!, 10), targetType: "part" }; + } + if (raw.startsWith("bp:")) return { targetMessageGuid: raw.slice(3), targetPart: 0, targetType: "balloon" }; + return { targetMessageGuid: raw, targetPart: 0, targetType: "part" }; +} + +function parseReactionType( + type: string, + text: string | null, +): { reaction: string; emoji?: string; isRemoval: boolean } { + if (CLASSIC_TAPBACKS[type]) return { reaction: type, isRemoval: false }; + if (type.startsWith("-") && CLASSIC_TAPBACKS[type.slice(1)]) return { reaction: type.slice(1), isRemoval: true }; + + const code = Number.parseInt(type, 10); + if (Number.isNaN(code)) return { reaction: type, isRemoval: false }; + if (NUMERIC_CLASSIC[code]) return { reaction: NUMERIC_CLASSIC[code]!, isRemoval: false }; + if (code >= 3000 && code < 3006) return { reaction: NUMERIC_CLASSIC[code - 1000] ?? "unknown", isRemoval: true }; + + if (code === 2007) { + return { reaction: "sticker", isRemoval: false }; + } + if (code === 3007) { + return { reaction: "sticker", isRemoval: true }; + } + + if (code >= 2006 && code < 3000) { + const emoji = text?.match(/^Reacted\s+(.+?)\s+to\s+["\u201c]/)?.[1]; + return { reaction: "emoji", emoji, isRemoval: false }; + } + if (code >= 3006) { + const emoji = text?.match(/^Removed\s+(.+?)\s+from\s+["\u201c]/)?.[1]; + return { reaction: "emoji", emoji, isRemoval: true }; + } + + return { reaction: type, isRemoval: false }; +} + +const SYSTEM_TYPES: Record = { + 1: "participant-change", + 2: "group-name-change", + 3: "group-icon-change", + 4: "location-sharing-update", + 6: "group-photo-change", +}; + +const ICLOUD_SERVICES: Record = { + notes: "notes", + Notes: "notes", + reminders: "reminders", + pages: "pages", + numbers: "numbers", + keynote: "keynote", + mobilenotes: "notes", +}; + +const TEXT_EFFECT_NAMES: Record = { + 1: "big", + 2: "small", + 3: "shake", + 4: "nod", + 5: "explode", + 6: "ripple", + 7: "bloom", + 8: "jitter", + 9: "pen", + 10: "crystallize", + 11: "haze", + 12: "emerge", +}; + +function extractTextStyles(msg: MessageResponse): TextStyleRange[] | undefined { + const body = msg.attributedBody?.[0]; + if (!body?.runs) return undefined; + + const styles: TextStyleRange[] = []; + for (const run of body.runs) { + const attrs = run.attributes; + if (!attrs) continue; + const [start, length] = run.range ?? [0, 0]; + const bold = !!(attrs.__kIMBoldAttributeName || attrs.__kIMTextBoldAttributeName); + const italic = !!(attrs.__kIMItalicAttributeName || attrs.__kIMTextItalicAttributeName); + const underline = !!(attrs.__kIMUnderlineAttributeName || attrs.__kIMTextUnderlineAttributeName); + const strikethrough = !!(attrs.__kIMStrikethroughAttributeName || attrs.__kIMTextStrikethroughAttributeName); + const effectId = attrs.__kIMTextEffectAttributeName; + const animation = typeof effectId === "number" ? TEXT_EFFECT_NAMES[effectId] : undefined; + + if (bold || italic || underline || strikethrough || animation) { + styles.push({ + start, + end: start + length, + ...(bold && { bold }), + ...(italic && { italic }), + ...(underline && { underline }), + ...(strikethrough && { strikethrough }), + ...(animation && { animation }), + }); + } + } + return styles.length > 0 ? styles : undefined; +} + +function handleText(msg: MessageResponse, base: ParsedBase): TextMessage { + const styles = extractTextStyles(msg); + return { ...base, type: "text", text: msg.text ?? "", ...(styles && { styles }) }; +} + +function handleImage(msg: MessageResponse, base: ParsedBase): ImageMessage { + const att = msg.attachments![0]!; + return { + ...base, + type: "image", + subtype: (att.mimeType ?? "").replace("image/", "") || "unknown", + attachment: toAttachment(att), + dimensions: getDimensions(att), + }; +} + +function handleGif(msg: MessageResponse, base: ParsedBase): GifMessage { + const att = msg.attachments![0]!; + return { ...base, type: "gif", attachment: toAttachment(att), dimensions: getDimensions(att) }; +} + +function handleVideo(msg: MessageResponse, base: ParsedBase): VideoMessage { + const att = msg.attachments![0]!; + return { + ...base, + type: "video", + attachment: toAttachment(att), + dimensions: att.width && att.height ? { width: att.width, height: att.height } : undefined, + }; +} + +function handleFile(msg: MessageResponse, base: ParsedBase): FileMessage { + return { ...base, type: "file", attachment: toAttachment(msg.attachments![0]!) }; +} + +function handleSticker(msg: MessageResponse, base: ParsedBase): StickerMessage { + return { + ...base, + type: "sticker", + attachment: toAttachment(msg.attachments![0]!), + isReplySticker: !!base.replyToGuid, + }; +} + +function handleAudio(msg: MessageResponse, base: ParsedBase): AudioMessage { + const att = msg.attachments?.[0]; + return { + ...base, + type: "audio", + attachment: att + ? toAttachment(att) + : { guid: "", mimeType: "audio/unknown", fileName: "unknown", sizeBytes: 0 }, + expirable: msg.isExpired ?? true, + }; +} + +function handleDigitalTouch(msg: MessageResponse, base: ParsedBase): DigitalTouchMessage { + return { ...base, type: "digital-touch", expired: msg.isExpired ?? false }; +} + +function handleReaction(msg: MessageResponse, base: ParsedBase): ReactionMessage { + const target = parseReactionTarget(msg.associatedMessageGuid!); + const { reaction, emoji, isRemoval } = parseReactionType(msg.associatedMessageType!, msg.text); + return { ...base, type: "reaction", reaction, emoji, isRemoval, ...target }; +} + +function extractOriginalText(msg: MessageResponse): string | undefined { + const msi = msg.messageSummaryInfo?.[0] as Record | undefined; + const editedContent = msi?.editedContent as Array<{ text?: { values?: Array<{ string?: string }> } }> | undefined; + if (!editedContent?.length) return undefined; + return editedContent[0]?.text?.values?.[0]?.string ?? undefined; +} + +function handleEdit(msg: MessageResponse, base: ParsedBase): EditMessage { + return { + ...base, + type: "edit", + originalText: extractOriginalText(msg), + newText: msg.text ?? "", + editedAt: new Date(msg.dateEdited!), + }; +} + +function handleUnsend(msg: MessageResponse, base: ParsedBase): UnsendMessage { + return { ...base, type: "unsend", retractedAt: new Date(msg.dateRetracted!) }; +} + +function handlePoll(msg: MessageResponse, base: ParsedBase): PollMessage { + const isVote = isPollVote(msg); + const pollData = !isVote ? parsePollDefinition(msg) : null; + return { + ...base, + type: "poll", + title: pollData?.title, + options: pollData?.options.map((o) => o.text), + isPollVote: isVote, + }; +} + +function handleSystem(msg: MessageResponse, base: ParsedBase): SystemMessage { + return { + ...base, + type: "system", + systemType: SYSTEM_TYPES[msg.itemType] ?? "unknown", + groupTitle: msg.groupTitle ?? undefined, + direction: msg.shareDirection === 1 ? "incoming" : msg.shareDirection === 0 ? "outgoing" : undefined, + }; +} + +function handleRichLink(msg: MessageResponse, base: ParsedBase): RichLinkMessage { + const url = msg.text ?? ""; + const result: RichLinkMessage = { ...base, type: "rich-link", url }; + + const objects = msg.payloadData?.[0]?.$objects; + if (!Array.isArray(objects)) return result; + + for (const obj of objects) { + if (typeof obj !== "object" || obj === null || Array.isArray(obj)) continue; + + if ("title" in obj && "originalURL" in obj) { + result.title = resolveString(objects, obj.title); + result.summary = resolveString(objects, obj.summary); + result.siteName = resolveString(objects, obj.itemType); + result.twitterCard = resolveString(objects, obj.twitterCard); + } + + if ("NS.relative" in obj && "$class" in obj) { + const resolved = resolveString(objects, obj["NS.relative"]); + if (resolved?.startsWith("http") && !result.canonicalUrl) result.canonicalUrl = resolved; + } + + if ("version" in obj && "URL" in obj && "$class" in obj) { + const nsurl = resolveUid(objects, obj.URL); + if (typeof nsurl === "object" && nsurl !== null && "NS.relative" in nsurl) { + const imgUrl = resolveString(objects, nsurl["NS.relative"]); + if (imgUrl?.startsWith("http")) { + const className = resolveUid(objects, obj.$class)?.$classname; + if (className === "LPIconMetadata" && !result.icon) result.icon = { url: imgUrl }; + else if (className === "LPImageMetadata" && !result.image) result.image = { url: imgUrl }; + } + } + } + } + + return result; +} + +function handleCollaboration(msg: MessageResponse, base: ParsedBase): CollaborationMessage { + const url = msg.text ?? ""; + const result: CollaborationMessage = { + ...base, + type: "collaboration", + service: "unknown", + title: "", + url, + isCollaboration: true, + app: { name: "Unknown", bundleIds: [] }, + }; + + const objects = msg.payloadData?.[0]?.$objects; + if (!Array.isArray(objects)) return result; + + for (const obj of objects) { + if (typeof obj !== "object" || obj === null || Array.isArray(obj)) continue; + + if ("collaborationMetadata" in obj && "originalURL" in obj) { + const collab = resolveUid(objects, obj.collaborationMetadata); + if (typeof collab === "object" && collab !== null) { + result.title = resolveString(objects, collab.title) ?? result.title; + result.collaborationUrl = resolveString(objects, collab.collaborationIdentifier); + + const bundleArr = resolveUid(objects, collab.ckAppBundleIDs); + if (bundleArr?.["NS.objects"]) { + result.app.bundleIds = (bundleArr["NS.objects"] as any[]) + .map((ref: any) => resolveString(objects, ref)) + .filter((s): s is string => !!s); + } + } + } + + if ("isCollaboration" in obj && "specialization" in obj) { + const spec = resolveUid(objects, obj.specialization); + if (typeof spec === "object" && spec !== null) { + result.app.name = resolveString(objects, spec.application) ?? result.app.name; + result.title = result.title || resolveString(objects, spec.title) || ""; + } + } + } + + for (const id of result.app.bundleIds) { + for (const [key, service] of Object.entries(ICLOUD_SERVICES)) { + if (id.includes(key)) { + result.service = service; + break; + } + } + if (result.service !== "unknown") break; + } + if (result.service === "unknown" && url.includes("icloud.com/notes")) result.service = "notes"; + + return result; +} + +function handleLocationShare(msg: MessageResponse, base: ParsedBase): LocationShareMessage { + const result: LocationShareMessage = { + ...base, + type: "location-share", + action: "Shared Location", + kind: "unknown", + coordinates: { lat: 0, lng: 0 }, + mapsUrl: "", + }; + + const runs = msg.attributedBody?.[0]?.runs; + if (Array.isArray(runs)) { + for (const run of runs) { + const breadcrumb = run?.attributes?.__kIMBreadcrumbTextMarkerAttributeName; + if (typeof breadcrumb === "string") { + result.action = breadcrumb; + break; + } + } + } + + const objects = msg.payloadData?.[0]?.$objects; + if (Array.isArray(objects)) { + for (const obj of objects) { + if (typeof obj !== "string" || !obj.includes("FindMyMessagePayloadZippedDataKey")) continue; + try { + const query = obj.startsWith("?") ? obj.slice(1) : obj; + let zipped = ""; + for (const part of query.split("&")) { + const eq = part.indexOf("="); + if (eq !== -1 && part.slice(0, eq) === "FindMyMessagePayloadZippedDataKey") { + zipped = decodeURIComponent(part.slice(eq + 1)); + break; + } + } + if (!zipped) continue; + + const decoded = Buffer.from(zipped, "base64"); + const json = JSON.parse(inflateRawSync(decoded).toString("utf-8")); + if (json.kind?.share) { + result.kind = "share"; + const dur = json.kind.share.duration; + if (dur) result.duration = Object.keys(dur)[0]; + } else if (json.kind?.request !== undefined) { + result.kind = "request"; + } + + if (json.kind?.share?.isFromRequest !== undefined) { + result.isFromRequest = json.kind.share.isFromRequest; + } + + const loc = json.initialLocation ?? json.location; + if (loc?.latitude && loc?.longitude) { + result.coordinates = { lat: loc.latitude, lng: loc.longitude }; + result.mapsUrl = `https://maps.apple.com/?ll=${loc.latitude},${loc.longitude}`; + if (typeof loc.altitude === "number") result.altitude = loc.altitude; + if (typeof loc.horizontalAccuracy === "number") result.horizontalAccuracy = loc.horizontalAccuracy; + if (typeof loc.speed === "number" && loc.speed >= 0) result.speed = loc.speed; + } + } catch { + // Decompression or parsing failed + } + break; + } + } + + return result; +} + +function tryParseVCardFromAttachment(att: AttachmentResponse): { + fullName?: string; + firstName?: string; + lastName?: string; + nickname?: string; + org?: string; + title?: string; + phones: string[]; + emails: string[]; + urls: string[]; + addresses: string[]; + birthday?: string; + note?: string; +} | null { + const raw = att.data; + if (!raw || typeof raw !== "string") return null; + + let text = raw.trim(); + if (!text.includes("BEGIN:VCARD")) { + try { + const decoded = Buffer.from(text, "base64").toString("utf-8"); + if (decoded.includes("BEGIN:VCARD")) text = decoded; + } catch { + return null; + } + } + if (!text.includes("BEGIN:VCARD")) return null; + + const v = parseVCard(text); + return { + fullName: v.fullName, + firstName: v.firstName, + lastName: v.lastName, + nickname: v.nickname, + org: v.org, + title: v.title, + phones: v.phones, + emails: v.emails, + urls: v.urls, + addresses: v.addresses, + birthday: v.birthday, + note: v.note, + }; +} + +/** + * Socket events typically don't include attachment.data (only metadata), + * so phones/emails will be empty. To get full contact info, download the + * attachment via REST using attachmentGuid and run parseVCard() on the file. + */ +function handleContact(msg: MessageResponse, base: ParsedBase): ContactMessage { + const att = msg.attachments![0]!; + const nameFromFile = att.transferName?.replace(/\.vcf$/i, "").trim() || undefined; + const fromVcf = tryParseVCardFromAttachment(att); + + return { + ...base, + type: "contact", + fullName: fromVcf?.fullName ?? nameFromFile, + firstName: fromVcf?.firstName, + lastName: fromVcf?.lastName, + nickname: fromVcf?.nickname, + org: fromVcf?.org, + title: fromVcf?.title, + phones: fromVcf?.phones ?? [], + emails: fromVcf?.emails ?? [], + urls: fromVcf?.urls ?? [], + addresses: fromVcf?.addresses ?? [], + birthday: fromVcf?.birthday, + note: fromVcf?.note, + attachmentGuid: att.guid, + }; +} + +const CHECKIN_SESSION_TYPES: Record = { + "0": "destination", + "1": "timer", + "2": "workout", +}; + +const CHECKIN_DEST_TYPES: Record = { "0": "none", "1": "destination" }; + +const CHECKIN_END_REASONS: Record = { + "1": "ended", + "2": "cancelled", +}; + +function parseUnixSeconds(val: string | undefined): Date | undefined { + if (!val) return undefined; + const ts = Number.parseFloat(val); + return Number.isNaN(ts) ? undefined : new Date(ts * 1000); +} + +function handleCheckIn(msg: MessageResponse, base: ParsedBase): CheckInMessage { + const result: CheckInMessage = { + ...base, + type: "checkin", + mode: "unknown", + status: "started", + lowPowerMode: false, + }; + + const objects = msg.payloadData?.[0]?.$objects; + if (!Array.isArray(objects)) return result; + + for (const obj of objects) { + if (typeof obj !== "string" || !obj.includes("messageType=")) continue; + const query = obj.startsWith("?") ? obj.slice(1) : obj; + const params: Record = {}; + for (const part of query.split("&")) { + const eq = part.indexOf("="); + if (eq !== -1) params[part.slice(0, eq)] = decodeURIComponent(part.slice(eq + 1)); + } + + result.mode = CHECKIN_SESSION_TYPES[params.sessionType ?? ""] ?? "unknown"; + result.sessionId = params.sessionID; + result.shareUrl = params.shareURL; + result.startedAt = parseUnixSeconds(params.sendDate); + result.estimatedEndTime = parseUnixSeconds(params.estimatedEndTime); + result.destinationType = + CHECKIN_DEST_TYPES[params.sessionDestinationType ?? ""] ?? params.sessionDestinationType; + result.lowPowerMode = params.lowPowerModeWarningState === "1"; + + if (params.messageType === "2" && params.sessionEndReason) { + result.status = CHECKIN_END_REASONS[params.sessionEndReason] ?? "ended"; + } + break; + } + + for (const obj of objects) { + if (typeof obj !== "string" || !obj.startsWith("Check In: ")) continue; + const caption = obj.slice("Check In: ".length).trim(); + if (caption.toLowerCase() === "ended" || caption.toLowerCase() === "cancelled") continue; + if (caption) result.destinationName = caption; + break; + } + + return result; +} + +function handleUnknown(msg: MessageResponse, base: ParsedBase): UnknownMessage { + return { ...base, type: "unknown", balloonBundleId: msg.balloonBundleId ?? undefined, raw: msg.payloadData }; +} + +export function parse(msg: MessageResponse): ParsedMessage { + const base = buildBase(msg); + const type = classify(msg); + + switch (type) { + case "text": + return handleText(msg, base); + case "image": + return handleImage(msg, base); + case "gif": + return handleGif(msg, base); + case "video": + return handleVideo(msg, base); + case "file": + return handleFile(msg, base); + case "sticker": + return handleSticker(msg, base); + case "audio": + return handleAudio(msg, base); + case "digital-touch": + return handleDigitalTouch(msg, base); + case "reaction": + return handleReaction(msg, base); + case "edit": + return handleEdit(msg, base); + case "unsend": + return handleUnsend(msg, base); + case "poll": + return handlePoll(msg, base); + case "system": + return handleSystem(msg, base); + case "rich-link": + return handleRichLink(msg, base); + case "collaboration": + return handleCollaboration(msg, base); + case "location-share": + return handleLocationShare(msg, base); + case "contact": + return handleContact(msg, base); + case "checkin": + return handleCheckIn(msg, base); + case "unknown": + return handleUnknown(msg, base); + } +} diff --git a/lib/parseaple/types.ts b/lib/parseaple/types.ts new file mode 100644 index 0000000..02a0adf --- /dev/null +++ b/lib/parseaple/types.ts @@ -0,0 +1,213 @@ +export interface ParsedBase { + type: string; + guid: string; + chatGuid: string; + from: string; + isFromMe: boolean; + timestamp: Date; + /** Set only when the user sent an inline reply (wire has threadOriginatorGuid or threadOriginatorPart). Not the same as Apple’s replyToGuid on every bubble. */ + replyToGuid?: string; + /** Reply-thread fork id from threadOriginatorGuid when present. */ + threadGuid?: string; + effect?: string; +} + +export interface AttachmentMeta { + guid: string; + mimeType: string; + fileName: string; + sizeBytes: number; + uti?: string; +} + +export interface TextStyleRange { + start: number; + end: number; + bold?: boolean; + italic?: boolean; + underline?: boolean; + strikethrough?: boolean; + animation?: string; +} + +export interface TextMessage extends ParsedBase { + type: "text"; + text: string; + styles?: TextStyleRange[]; +} + +export interface ImageMessage extends ParsedBase { + type: "image"; + subtype: string; + attachment: AttachmentMeta; + dimensions?: { width: number; height: number }; +} + +export interface GifMessage extends ParsedBase { + type: "gif"; + attachment: AttachmentMeta; + dimensions?: { width: number; height: number }; +} + +export interface VideoMessage extends ParsedBase { + type: "video"; + attachment: AttachmentMeta; + dimensions?: { width: number; height: number }; +} + +export interface FileMessage extends ParsedBase { + type: "file"; + attachment: AttachmentMeta; +} + +export interface StickerMessage extends ParsedBase { + type: "sticker"; + attachment: AttachmentMeta; + isReplySticker: boolean; +} + +export interface AudioMessage extends ParsedBase { + type: "audio"; + attachment: AttachmentMeta; + expirable: boolean; +} + +export interface DigitalTouchMessage extends ParsedBase { + type: "digital-touch"; + expired: boolean; +} + +export interface ReactionMessage extends ParsedBase { + type: "reaction"; + reaction: string; + emoji?: string; + targetMessageGuid: string; + targetPart: number; + targetType: "part" | "balloon"; + isRemoval: boolean; +} + +export interface EditMessage extends ParsedBase { + type: "edit"; + originalText?: string; + newText: string; + editedAt: Date; +} + +export interface UnsendMessage extends ParsedBase { + type: "unsend"; + retractedAt: Date; +} + +export interface PollMessage extends ParsedBase { + type: "poll"; + title?: string; + options?: string[]; + isPollVote: boolean; +} + +export interface SystemMessage extends ParsedBase { + type: "system"; + systemType: string; + groupTitle?: string; + direction?: "incoming" | "outgoing"; +} + +export interface RichLinkMessage extends ParsedBase { + type: "rich-link"; + url: string; + canonicalUrl?: string; + title?: string; + summary?: string; + siteName?: string; + twitterCard?: string; + icon?: { url: string; mimeType?: string }; + image?: { url: string; mimeType?: string }; +} + +export interface CollaborationMessage extends ParsedBase { + type: "collaboration"; + service: string; + title: string; + url: string; + collaborationUrl?: string; + isCollaboration: boolean; + app: { name: string; bundleIds: string[]; icon?: { mimeType?: string } }; +} + +export interface LocationShareMessage extends ParsedBase { + type: "location-share"; + action: string; + kind: "share" | "request" | "unknown"; + duration?: "oneHour" | "indefinitely" | "untilEndOfDay" | string; + isFromRequest?: boolean; + coordinates: { lat: number; lng: number }; + altitude?: number; + horizontalAccuracy?: number; + speed?: number; + address?: { short?: string; long?: string; subtitle?: string }; + mapsUrl: string; +} + +export interface ContactMessage extends ParsedBase { + type: "contact"; + firstName?: string; + lastName?: string; + fullName?: string; + nickname?: string; + phones: string[]; + emails: string[]; + org?: string; + title?: string; + urls: string[]; + addresses: string[]; + birthday?: string; + note?: string; + attachmentGuid: string; +} + +export interface CheckInMessage extends ParsedBase { + type: "checkin"; + mode: "timer" | "destination" | "workout" | "unknown"; + status: "started" | "ended" | "cancelled" | string; + sessionId?: string; + startedAt?: Date; + estimatedEndTime?: Date; + shareUrl?: string; + destinationName?: string; + destinationType?: "none" | "destination" | string; + lowPowerMode: boolean; +} + +export interface UnknownMessage extends ParsedBase { + type: "unknown"; + balloonBundleId?: string; + raw?: any; +} + +export type ParsedMessage = + | TextMessage + | ImageMessage + | GifMessage + | VideoMessage + | FileMessage + | StickerMessage + | AudioMessage + | DigitalTouchMessage + | ReactionMessage + | EditMessage + | UnsendMessage + | PollMessage + | SystemMessage + | RichLinkMessage + | CollaborationMessage + | LocationShareMessage + | ContactMessage + | CheckInMessage + | UnknownMessage; + +export interface ParsedDescription { + summary: string; + type: ParsedMessage["type"]; + data: Record; +} diff --git a/lib/parseaple/vcard.ts b/lib/parseaple/vcard.ts new file mode 100644 index 0000000..166d417 --- /dev/null +++ b/lib/parseaple/vcard.ts @@ -0,0 +1,265 @@ +export interface VCardSocialProfile { + type: string; + username?: string; + url: string; +} + +export interface VCardBinaryData { + base64: string; + mimeType: string; +} + +export interface VCardGeo { + lat: number; + lng: number; +} + +export interface VCardData { + firstName?: string; + lastName?: string; + fullName?: string; + nickname?: string; + phones: string[]; + emails: string[]; + org?: string; + title?: string; + role?: string; + note?: string; + urls: string[]; + birthday?: string; + altBirthday?: string; + categories: string[]; + addresses: string[]; + socialProfiles: VCardSocialProfile[]; + relatedNames: { name: string; label?: string }[]; + dates: { value: string; label?: string }[]; + photo?: VCardBinaryData; + logo?: VCardBinaryData; + sound?: VCardBinaryData | string; + geo?: VCardGeo; + tz?: string; + key?: VCardBinaryData; + phoneticFirstName?: string; + phoneticMiddleName?: string; + phoneticLastName?: string; + uid?: string; + rev?: string; +} + +export function parseVCard(text: string): VCardData { + const result: VCardData = { + phones: [], + emails: [], + urls: [], + categories: [], + addresses: [], + socialProfiles: [], + relatedNames: [], + dates: [], + }; + const lines = unfoldLines(text); + let pendingLabel: string | undefined; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + const [field, value] = splitField(line); + if (!value && !field.toUpperCase().includes("X-ABLABEL")) continue; + + const stripped = field.replace(/^item\d+\./i, ""); + const key = stripped.split(";")[0]!.toUpperCase(); + const params = stripped.toUpperCase(); + + switch (key) { + case "FN": + result.fullName = value; + break; + case "N": { + const parts = value.split(";"); + result.lastName = parts[0] || undefined; + result.firstName = parts[1] || undefined; + break; + } + case "NICKNAME": + result.nickname = value; + break; + case "TEL": + result.phones.push(value); + break; + case "EMAIL": + result.emails.push(value); + break; + case "ORG": + result.org = value.replace(/;/g, " ").trim() || undefined; + break; + case "TITLE": + result.title = value; + break; + case "NOTE": + result.note = value.replace(/\\,/g, ",").replace(/\\n/g, "\n"); + break; + case "URL": + result.urls.push(value); + break; + case "BDAY": + result.birthday = value.replace("value=date:", ""); + break; + case "ADR": { + const addr = value + .split(";") + .filter(Boolean) + .map((s) => s.replace(/\\n/g, ", ")) + .join(", "); + if (addr) result.addresses.push(addr); + break; + } + case "X-SOCIALPROFILE": { + const typeMatch = stripped.match(/type=([^;]+)/i); + const userMatch = stripped.match(/x-user=([^;:]+)/i); + result.socialProfiles.push({ + type: typeMatch?.[1]?.toLowerCase() ?? "unknown", + username: userMatch?.[1] ? decodeURIComponent(userMatch[1]) : undefined, + url: value, + }); + break; + } + case "IMPP": { + const serviceMatch = params.match(/X-SERVICE-TYPE=([^;]+)/i); + result.socialProfiles.push({ + type: (serviceMatch?.[1] ?? "im").toLowerCase(), + url: value, + }); + break; + } + case "PHOTO": + result.photo = { base64: value, mimeType: resolveImageType(stripped, value) }; + break; + case "LOGO": + result.logo = { base64: value, mimeType: resolveImageType(stripped, value) }; + break; + case "SOUND": + if (stripped.toUpperCase().includes("ENCODING=B")) { + const sndType = stripped.match(/TYPE=([^;]+)/i)?.[1]?.toLowerCase(); + result.sound = { base64: value, mimeType: sndType ? `audio/${sndType}` : "audio/unknown" }; + } else { + result.sound = value; + } + break; + case "GEO": { + const parts = value.split(";").map(Number); + if (!Number.isNaN(parts[0]!) && !Number.isNaN(parts[1]!)) + result.geo = { lat: parts[0]!, lng: parts[1]! }; + break; + } + case "TZ": + result.tz = value; + break; + case "KEY": { + const keyType = stripped.match(/TYPE=([^;]+)/i)?.[1]?.toLowerCase(); + result.key = { base64: value, mimeType: keyType ?? "application/x-unknown-key" }; + break; + } + case "ROLE": + result.role = value; + break; + case "CATEGORIES": + result.categories.push( + ...value + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); + break; + case "X-PHONETIC-FIRST-NAME": + result.phoneticFirstName = value; + break; + case "X-PHONETIC-MIDDLE-NAME": + result.phoneticMiddleName = value; + break; + case "X-PHONETIC-LAST-NAME": + result.phoneticLastName = value; + break; + case "X-ALTBDAY": + result.altBirthday = value; + break; + case "UID": + result.uid = value; + break; + case "REV": + result.rev = value; + break; + case "X-ABRELATEDNAMES": + result.relatedNames.push({ name: value }); + break; + case "X-ABDATE": + result.dates.push({ value }); + break; + case "X-ABLABEL": { + const label = cleanLabel(value); + if (label) { + applyLabelToLast(result, label); + } + break; + } + } + } + + if (!result.fullName && (result.firstName || result.lastName)) { + result.fullName = [result.firstName, result.lastName].filter(Boolean).join(" "); + } + + return result; +} + +function cleanLabel(raw: string): string | undefined { + const cleaned = raw.replace(/_\$!<(.+?)>!\$_/g, "$1").trim(); + return cleaned || undefined; +} + +function applyLabelToLast(result: VCardData, label: string): void { + const rn = result.relatedNames[result.relatedNames.length - 1]; + if (rn && !rn.label) { + rn.label = label; + return; + } + + const d = result.dates[result.dates.length - 1]; + if (d && !d.label) { + d.label = label; + return; + } +} + +function unfoldLines(text: string): string[] { + return text + .replace(/\r\n/g, "\n") + .replace(/\n[ \t]/g, "") + .split("\n"); +} + +function splitField(line: string): [string, string] { + const idx = line.indexOf(":"); + if (idx === -1) return [line, ""]; + return [line.slice(0, idx), line.slice(idx + 1)]; +} + +function resolveImageType(field: string, base64: string): string { + const typeParam = field.match(/TYPE=([^;]+)/i)?.[1]?.toLowerCase(); + if (typeParam === "jpeg" || typeParam === "jpg") return "image/jpeg"; + if (typeParam === "png") return "image/png"; + if (typeParam === "gif") return "image/gif"; + if (typeParam === "bmp") return "image/bmp"; + if (typeParam === "tiff") return "image/tiff"; + return detectImageMime(base64); +} + +function detectImageMime(base64: string): string { + try { + const head = Buffer.from(base64.slice(0, 40), "base64"); + if (head[0] === 0x89 && head[1] === 0x50) return "image/png"; + if (head[0] === 0xff && head[1] === 0xd8) return "image/jpeg"; + if (head[0] === 0x47 && head[1] === 0x49) return "image/gif"; + if (head.slice(4, 8).toString() === "ftyp") return "image/heic"; + if (head.slice(0, 4).toString() === "RIFF" && head.slice(8, 12).toString() === "WEBP") return "image/webp"; + } catch {} + return "image/unknown"; +} diff --git a/types/message.ts b/types/message.ts index 1f6560e..b5bc356 100644 --- a/types/message.ts +++ b/types/message.ts @@ -1,3 +1,4 @@ +import type { ParsedMessage } from "../lib/parseaple/types"; import type { Attachment, AttachmentResponse } from "./attachment"; import type { Chat, ChatResponse } from "./chat"; import type { Handle, HandleResponse } from "./handle"; @@ -152,4 +153,5 @@ export type MessageResponse = { shareStatus?: number | null; shareDirection?: number | null; receivingFrom?: string | null; + parsed?: ParsedMessage; };