-
Notifications
You must be signed in to change notification settings - Fork 116
fix: support modern macOS group chat ID format (any;+;) and add watcher checkpoint #43
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -156,6 +156,8 @@ export class IMessageDatabase { | |||||||||||||||||||||||||||||||||||
| handle.id as sender, | ||||||||||||||||||||||||||||||||||||
| handle.ROWID as sender_rowid, | ||||||||||||||||||||||||||||||||||||
| chat.chat_identifier as chat_id, | ||||||||||||||||||||||||||||||||||||
| chat.guid as chat_guid, | ||||||||||||||||||||||||||||||||||||
| chat.service_name as chat_service, | ||||||||||||||||||||||||||||||||||||
| chat.display_name as chat_name, | ||||||||||||||||||||||||||||||||||||
| chat.ROWID as chat_rowid, | ||||||||||||||||||||||||||||||||||||
| (SELECT COUNT(*) FROM chat_handle_join WHERE chat_handle_join.chat_id = chat.ROWID) > 1 as is_group_chat | ||||||||||||||||||||||||||||||||||||
|
|
@@ -513,14 +515,30 @@ export class IMessageDatabase { | |||||||||||||||||||||||||||||||||||
| // Parse reaction information | ||||||||||||||||||||||||||||||||||||
| const reaction = this.mapReactionType(row.associated_message_type) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // Use same chatId logic as listChats: groups use chat.guid, DMs use chat_identifier | ||||||||||||||||||||||||||||||||||||
| const isGroup = bool(row.is_group_chat) | ||||||||||||||||||||||||||||||||||||
| const chatGuid = str(row.chat_guid) | ||||||||||||||||||||||||||||||||||||
| const chatIdentifier = str(row.chat_id) | ||||||||||||||||||||||||||||||||||||
| const chatService = row.chat_service == null ? '' : str(row.chat_service) | ||||||||||||||||||||||||||||||||||||
| let resolvedChatId: string | ||||||||||||||||||||||||||||||||||||
| if (isGroup || !chatIdentifier) { | ||||||||||||||||||||||||||||||||||||
| resolvedChatId = chatGuid || chatIdentifier | ||||||||||||||||||||||||||||||||||||
| } else if (chatIdentifier.includes(';')) { | ||||||||||||||||||||||||||||||||||||
| resolvedChatId = chatIdentifier | ||||||||||||||||||||||||||||||||||||
| } else if (chatService) { | ||||||||||||||||||||||||||||||||||||
| resolvedChatId = `${chatService};${chatIdentifier}` | ||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||
| resolvedChatId = `iMessage;${chatIdentifier}` | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||
| id: str(row.id), | ||||||||||||||||||||||||||||||||||||
| guid: str(row.guid), | ||||||||||||||||||||||||||||||||||||
| text: messageText, | ||||||||||||||||||||||||||||||||||||
| sender: str(row.sender, 'Unknown'), | ||||||||||||||||||||||||||||||||||||
| senderName: null, | ||||||||||||||||||||||||||||||||||||
| chatId: str(row.chat_id), | ||||||||||||||||||||||||||||||||||||
| isGroupChat: bool(row.is_group_chat), | ||||||||||||||||||||||||||||||||||||
| chatId: resolvedChatId, | ||||||||||||||||||||||||||||||||||||
| isGroupChat: isGroup, | ||||||||||||||||||||||||||||||||||||
| service: this.mapService(row.service), | ||||||||||||||||||||||||||||||||||||
| isRead: bool(row.is_read), | ||||||||||||||||||||||||||||||||||||
| isFromMe: bool(row.is_from_me), | ||||||||||||||||||||||||||||||||||||
|
|
@@ -602,6 +620,35 @@ export class IMessageDatabase { | |||||||||||||||||||||||||||||||||||
| return new Date(this.MAC_EPOCH + timestamp / 1000000) | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||
| * Discover the local Messages.app guid prefix for group chats by inspecting one row. | ||||||||||||||||||||||||||||||||||||
| * Returns the prefix that should be prepended to raw GUIDs for AppleScript `chat id`. | ||||||||||||||||||||||||||||||||||||
| * Defaults to `any;+;` (modern macOS) if no group chats exist. | ||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||
| async discoverGroupChatPrefix(): Promise<string> { | ||||||||||||||||||||||||||||||||||||
| await this.ensureInit() | ||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||
| const row = this.db.prepare('SELECT guid, chat_identifier FROM chat WHERE style = 43 LIMIT 1').get() as | ||||||||||||||||||||||||||||||||||||
| | { guid: string; chat_identifier: string } | ||||||||||||||||||||||||||||||||||||
| | undefined | ||||||||||||||||||||||||||||||||||||
| if (!row) return 'any;+;' | ||||||||||||||||||||||||||||||||||||
| const guid = str(row.guid) | ||||||||||||||||||||||||||||||||||||
| const identifier = str(row.chat_identifier) | ||||||||||||||||||||||||||||||||||||
| // guid is e.g. "any;+;534ce85d..." and identifier is "534ce85d..." | ||||||||||||||||||||||||||||||||||||
| const idx = guid.indexOf(identifier) | ||||||||||||||||||||||||||||||||||||
| if (idx > 0) return guid.substring(0, idx) | ||||||||||||||||||||||||||||||||||||
| // identifier might have extra prefix (e.g., "chat534ce85d...") | ||||||||||||||||||||||||||||||||||||
| if (identifier.startsWith('chat') && guid.includes(identifier.substring(4))) { | ||||||||||||||||||||||||||||||||||||
| const rawGuid = identifier.substring(4) | ||||||||||||||||||||||||||||||||||||
| const rawIdx = guid.indexOf(rawGuid) | ||||||||||||||||||||||||||||||||||||
| if (rawIdx > 0) return guid.substring(0, rawIdx) | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+632
to
+639
|
||||||||||||||||||||||||||||||||||||
| const idx = guid.indexOf(identifier) | |
| if (idx > 0) return guid.substring(0, idx) | |
| // identifier might have extra prefix (e.g., "chat534ce85d...") | |
| if (identifier.startsWith('chat') && guid.includes(identifier.substring(4))) { | |
| const rawGuid = identifier.substring(4) | |
| const rawIdx = guid.indexOf(rawGuid) | |
| if (rawIdx > 0) return guid.substring(0, rawIdx) | |
| } | |
| // identifier might have extra prefix (e.g., "chat534ce85d...") | |
| if (identifier.startsWith('chat') && guid.includes(identifier.substring(4))) { | |
| const rawGuid = identifier.substring(4) | |
| const rawIdx = guid.indexOf(rawGuid) | |
| if (rawIdx > 0) return guid.substring(0, rawIdx) | |
| } | |
| // Fall back to direct substring match | |
| const idx = guid.indexOf(identifier) | |
| if (idx > 0) return guid.substring(0, idx) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -112,6 +112,21 @@ export class IMessageSDK { | |
| } | ||
| } | ||
|
|
||
| // Discover the local group chat guid prefix (async, non-blocking) | ||
| this.database | ||
| .discoverGroupChatPrefix() | ||
| .then((prefix) => { | ||
| if ('setGroupChatPrefix' in this.sender && typeof this.sender.setGroupChatPrefix === 'function') { | ||
| this.sender.setGroupChatPrefix(prefix) | ||
| } | ||
| if (this.config.debug) { | ||
| console.log(`[SDK] Discovered group chat prefix: "${prefix}"`) | ||
| } | ||
| }) | ||
| .catch(() => { | ||
| // Non-fatal: falls back to default "any;+;" | ||
| }) | ||
|
Comment on lines
+115
to
+128
|
||
|
|
||
| if (this.config.debug) { | ||
| console.log('[SDK] Initialization complete') | ||
| } | ||
|
|
@@ -514,7 +529,8 @@ export class IMessageSDK { | |
| events, | ||
| this.pluginManager, | ||
| this.config.debug, | ||
| this.outgoingManager | ||
| this.outgoingManager, | ||
| events?.initialLookbackMs | ||
| ) | ||
|
|
||
| try { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,7 +19,7 @@ import { | |
| generateSendWithAttachmentScript, | ||
| generateSendWithAttachmentToChat, | ||
| } from '../utils/applescript' | ||
| import { delay, validateChatId, validateMessageContent } from '../utils/common' | ||
| import { buildGroupChatGuid, delay, validateChatId, validateMessageContent } from '../utils/common' | ||
| import { convertToCompatibleFormat, downloadImage } from '../utils/download' | ||
| import { Semaphore } from '../utils/semaphore' | ||
| import { IMessageError, SendError } from './errors' | ||
|
|
@@ -73,6 +73,8 @@ export class MessageSender { | |
| private readonly scriptTimeout: number | ||
| /** Outgoing message manager */ | ||
| private outgoingManager: OutgoingMessageManager | null = null | ||
| /** Discovered group chat guid prefix for AppleScript (e.g., "any;+;") */ | ||
| private groupChatPrefix = 'any;+;' | ||
|
|
||
| constructor(debug = false, retryConfig?: Required<RetryConfig>, maxConcurrent = 5, scriptTimeout = 30000) { | ||
| this.debug = debug | ||
|
|
@@ -82,6 +84,13 @@ export class MessageSender { | |
| this.scriptTimeout = scriptTimeout | ||
| } | ||
|
|
||
| /** | ||
| * Set the group chat guid prefix discovered from the local Messages database. | ||
| */ | ||
| setGroupChatPrefix(prefix: string): void { | ||
| this.groupChatPrefix = prefix | ||
| } | ||
|
|
||
| /** | ||
| * Set outgoing message manager (called by SDK) | ||
| */ | ||
|
|
@@ -359,32 +368,32 @@ export class MessageSender { | |
| hasText: boolean, | ||
| resolvedPaths: string[] | ||
| ): Promise<void> { | ||
| const normalizedId = buildGroupChatGuid(groupId, this.groupChatPrefix) | ||
|
|
||
| if (hasText && resolvedPaths.length > 0) { | ||
| // Strategy 1: Text + Attachments | ||
| const firstAttachment = resolvedPaths[0]! | ||
| const { script } = generateSendWithAttachmentToChat(groupId, text!, firstAttachment) | ||
| await this.executeWithRetry(script, `Send text and attachment to group ${groupId}`) | ||
| const { script } = generateSendWithAttachmentToChat(normalizedId, text!, firstAttachment) | ||
| await this.executeWithRetry(script, `Send text and attachment to group ${normalizedId}`) | ||
|
Comment on lines
+371
to
+376
|
||
|
|
||
| // Send remaining attachments | ||
| for (let i = 1; i < resolvedPaths.length; i++) { | ||
| const { script: attachScript } = generateSendAttachmentToChat(groupId, resolvedPaths[i]!, this.debug) | ||
| const { script: attachScript } = generateSendAttachmentToChat( | ||
| normalizedId, | ||
| resolvedPaths[i]!, | ||
| this.debug | ||
| ) | ||
| await this.executeWithRetry(attachScript, `Send attachment ${i + 1}/${resolvedPaths.length}`) | ||
| // Extra delay between attachments | ||
| if (i < resolvedPaths.length - 1) { | ||
| await delay(500) | ||
| } | ||
| } | ||
| } else if (hasText) { | ||
| // Strategy 2: Text only | ||
| const script = generateSendTextToChat(groupId, text!) | ||
| await this.executeWithRetry(script, `Send text to group ${groupId}`) | ||
| const script = generateSendTextToChat(normalizedId, text!) | ||
| await this.executeWithRetry(script, `Send text to group ${normalizedId}`) | ||
| } else { | ||
| // Strategy 3: Attachments only | ||
| for (let i = 0; i < resolvedPaths.length; i++) { | ||
| const { script } = generateSendAttachmentToChat(groupId, resolvedPaths[i]!, this.debug) | ||
| const description = `Send attachment ${i + 1}/${resolvedPaths.length} to group ${groupId}` | ||
| const { script } = generateSendAttachmentToChat(normalizedId, resolvedPaths[i]!, this.debug) | ||
| const description = `Send attachment ${i + 1}/${resolvedPaths.length} to group ${normalizedId}` | ||
| await this.executeWithRetry(script, description) | ||
| // Extra delay between attachments to ensure previous one is sent | ||
| if (i < resolvedPaths.length - 1) { | ||
| await delay(500) | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -22,6 +22,11 @@ export interface WatcherEvents { | |||||||||||||||||||||||||||
| onGroupMessage?: MessageCallback | ||||||||||||||||||||||||||||
| /** Triggered when error occurs */ | ||||||||||||||||||||||||||||
| onError?: (error: Error) => void | ||||||||||||||||||||||||||||
| /** How far back (ms) to look for messages when the watcher first starts. Defaults to 10 000. */ | ||||||||||||||||||||||||||||
| initialLookbackMs?: number | ||||||||||||||||||||||||||||
| /** Called after each successful poll with the current checkpoint time. Persist this externally | ||||||||||||||||||||||||||||
| * and pass it back as `initialLookbackMs` on restart to avoid missing messages. */ | ||||||||||||||||||||||||||||
|
Comment on lines
+27
to
+28
|
||||||||||||||||||||||||||||
| /** Called after each successful poll with the current checkpoint time. Persist this externally | |
| * and pass it back as `initialLookbackMs` on restart to avoid missing messages. */ | |
| /** | |
| * Called after each successful poll with the current checkpoint time. | |
| * Persist this `lastCheckTime` externally and, on restart, compute a lookback | |
| * duration such as: | |
| * | |
| * const initialLookbackMs = Math.max(0, Date.now() - lastCheckTime.getTime()) | |
| * | |
| * (optionally clamped to some maximum), and pass that duration as | |
| * `initialLookbackMs` to the watcher constructor. This helps avoid missing | |
| * messages across restarts. | |
| */ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,110 +32,119 @@ export function validateMessageContent( | |
| } | ||
|
|
||
| /** | ||
| * Normalize chatId format | ||
| * - Extracts GUID from AppleScript group format (e.g., `iMessage;+;chat...` -> `chat...`) | ||
| * - Returns normalized chatId for consistent handling | ||
| * @param chatId Chat identifier (may be in various formats) | ||
| * @returns Normalized chatId | ||
| * Normalize chatId format by extracting the core identifier. | ||
| * - Group: `any;+;534ce85d...` -> `534ce85d...` | ||
| * - Legacy group: `iMessage;+;chat534ce85d...` -> `chat534ce85d...` | ||
| * - DM: `any;-;+1234567890` -> `+1234567890` | ||
| * - Already bare: returned as-is | ||
| */ | ||
| export function normalizeChatId(chatId: string): string { | ||
| // AppleScript group format: iMessage;+;chat... | ||
| // Extract GUID part (chat...) for normalization | ||
| if (chatId.includes(';')) { | ||
| const parts = chatId.split(';') | ||
| // Check if it matches AppleScript group format: iMessage;+;chat... | ||
| if (parts.length >= 3 && parts[0] === 'iMessage' && parts[1] === '+' && parts[2]?.startsWith('chat')) { | ||
| // Extract GUID part (everything after the second semicolon) | ||
| return parts.slice(2).join(';') | ||
| } | ||
| return parts[parts.length - 1] ?? chatId | ||
| } | ||
|
Comment on lines
34
to
45
|
||
| return chatId | ||
| } | ||
|
|
||
| /** | ||
| * Check if a chatId represents a group chat (not a DM) | ||
| * | ||
| * @param chatId The chat identifier to check | ||
| * @returns true if it's a group chat, false if it's a DM | ||
| * Check if a chatId represents a group chat (not a DM). | ||
| * Recognizes: | ||
| * - Modern macOS: `any;+;{guid}` or any `service;+;identifier` | ||
| * - Legacy: `iMessage;+;chat{guid}` | ||
| * - Bare GUID: `chat{guid}` (no semicolons, starts with "chat") | ||
| */ | ||
| export function isGroupChatId(chatId: string): boolean { | ||
| // AppleScript group format: iMessage;+;chat... | ||
| if (chatId.startsWith('iMessage;+;chat')) { | ||
| return true | ||
| } | ||
|
|
||
| // Pure GUID format (no semicolon, starts with 'chat') | ||
| if (!chatId.includes(';') && chatId.startsWith('chat') && chatId.length > 10) { | ||
| return true | ||
| } | ||
|
|
||
| if (chatId.includes(';+;')) return true | ||
| if (chatId.startsWith('iMessage;+;chat')) return true | ||
| if (!chatId.includes(';') && chatId.startsWith('chat') && chatId.length > 10) return true | ||
| return false | ||
| } | ||
|
|
||
| /** | ||
| * Extract recipient from a service-prefixed chatId | ||
| * | ||
| * @param chatId The chat identifier (e.g., 'iMessage;+1234567890') | ||
| * @returns The recipient part (e.g., '+1234567890'), or null if not a DM format | ||
| * Extract recipient from a service-prefixed DM chatId. | ||
| * Handles: | ||
| * - 3-part modern: `any;-;+1234567890` -> `+1234567890` | ||
| * - 2-part legacy: `iMessage;+1234567890` -> `+1234567890` | ||
| */ | ||
| export function extractRecipientFromChatId(chatId: string): string | null { | ||
| if (!chatId.includes(';')) { | ||
| return null | ||
| } | ||
| if (!chatId.includes(';')) return null | ||
| if (isGroupChatId(chatId)) return null | ||
|
|
||
| // Skip group chat formats | ||
| if (isGroupChatId(chatId)) { | ||
| return null | ||
| } | ||
|
|
||
| // Extract recipient from service-prefixed format: service;recipient | ||
| const parts = chatId.split(';') | ||
| if (parts.length === 2) { | ||
| return parts[1] || null | ||
| } | ||
|
|
||
| // 3-part DM: service;-;address (e.g., any;-;+1234567890) | ||
| if (parts.length === 3 && parts[1] === '-') return parts[2] || null | ||
| // 2-part DM: service;address (e.g., iMessage;+1234567890) | ||
| if (parts.length === 2) return parts[1] || null | ||
| return null | ||
| } | ||
|
|
||
| /** | ||
| * Validate chatId format | ||
| * - Must be a non-empty string | ||
| * - Three accepted forms: | ||
| * 1) Group chats: GUID-like string without semicolon (e.g., `chat...`) | ||
| * 2) Group chats (AppleScript): `iMessage;+;chat...` format | ||
| * 3) DMs: service-prefixed identifier with semicolon (e.g., `iMessage;+1234567890`) | ||
| * Validate chatId format. | ||
| * Accepted forms: | ||
| * 1) Group: `service;+;guid` (e.g., `any;+;534ce85d...`) | ||
| * 2) Legacy group: `iMessage;+;chat{guid}` | ||
| * 3) Bare group GUID: `chat{guid}` or raw hex GUID (length >= 8) | ||
| * 4) DM: `service;-;address` (e.g., `any;-;+1234567890`) | ||
| * 5) Legacy DM: `service;address` (e.g., `iMessage;+1234567890`) | ||
| * @throws Error when chatId is invalid | ||
| */ | ||
| export function validateChatId(chatId: string): void { | ||
| if (!chatId || typeof chatId !== 'string') { | ||
| throw new Error('chatId must be a non-empty string') | ||
| } | ||
|
|
||
| // Check for AppleScript group format: iMessage;+;chat... | ||
| if (chatId.includes(';')) { | ||
| const parts = chatId.split(';') | ||
| // AppleScript group format: iMessage;+;chat... | ||
| if (parts.length >= 3 && parts[0] === 'iMessage' && parts[1] === '+' && parts[2]?.startsWith('chat')) { | ||
| // Validate GUID part length | ||
|
|
||
| // Group format: service;+;guid (e.g., any;+;534ce85d...) | ||
| if (parts.length >= 3 && parts[1] === '+') { | ||
| const guidPart = parts.slice(2).join(';') | ||
| if (guidPart.length < 8) { | ||
| throw new Error('Invalid chatId format: GUID too short') | ||
| } | ||
| return | ||
| } | ||
|
|
||
| // DM format: <service>;<address> | ||
| const service = parts[0] || '' | ||
| const address = parts[1] || '' | ||
| const allowedServices = new Set(['iMessage', 'SMS', 'RCS']) | ||
| if (!allowedServices.has(service) || !address) { | ||
| throw new Error('Invalid chatId format: expected "<service>;<address>" or group GUID') | ||
| // DM format: service;-;address (e.g., any;-;+1234567890) | ||
| if (parts.length === 3 && parts[1] === '-') { | ||
| if (!parts[2]) { | ||
| throw new Error('Invalid chatId format: missing address') | ||
| } | ||
| return | ||
| } | ||
|
Comment on lines
96
to
+114
|
||
|
|
||
| // Legacy DM format: service;address (e.g., iMessage;+1234567890) | ||
| if (parts.length === 2) { | ||
| const service = parts[0] || '' | ||
| const address = parts[1] || '' | ||
| const allowedServices = new Set(['iMessage', 'SMS', 'RCS', 'any']) | ||
| if (!allowedServices.has(service) || !address) { | ||
| throw new Error('Invalid chatId format: expected "<service>;<address>" or group GUID') | ||
| } | ||
| return | ||
| } | ||
| return | ||
|
|
||
| throw new Error('Invalid chatId format: unrecognized semicolon pattern') | ||
| } | ||
|
|
||
| // No semicolon: treat as GUID-like; ensure non-trivial length | ||
| // No semicolons: bare GUID or chat-prefixed GUID | ||
| if (chatId.length < 8) { | ||
| throw new Error('Invalid chatId format: GUID too short') | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Build a full Messages.app guid for a group chat using the discovered prefix. | ||
| * Strips any existing prefixes and reconstructs with the local format. | ||
| * @param rawChatId Chat identifier in any format | ||
| * @param discoveredPrefix The prefix discovered from chat.db at init (e.g., "any;+;" or "iMessage;+;chat") | ||
| */ | ||
| export function buildGroupChatGuid(rawChatId: string, discoveredPrefix: string): string { | ||
| let guid = rawChatId | ||
| if (guid.includes(';')) { | ||
| const parts = guid.split(';') | ||
| guid = parts[parts.length - 1] ?? guid | ||
| } | ||
| if (guid.startsWith('chat')) guid = guid.substring(4) | ||
| return `${discoveredPrefix}${guid}` | ||
| } | ||
|
Comment on lines
+143
to
+160
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
discoverGroupChatPrefix()can return the wrong prefix on legacy macOS ifchat.chat_identifieralready includes thechatprefix (e.g. identifier =chat<guid>and guid =iMessage;+;chat<guid>). In that caseguid.indexOf(identifier)is > 0 and the function returnsiMessage;+;(missing the requiredchat), which will later produce AppleScript IDs likeiMessage;+;<guid>and break group sends. Consider deriving the prefix by searching for the raw guid (identifier with an optional leadingchatstripped) and returning everything before that position (so legacy returnsiMessage;+;chat, modern returnsany;+;).