Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 49 additions & 2 deletions src/core/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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 +625 to +639
Copy link

Copilot AI Mar 7, 2026

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 if chat.chat_identifier already includes the chat prefix (e.g. identifier = chat<guid> and guid = iMessage;+;chat<guid>). In that case guid.indexOf(identifier) is > 0 and the function returns iMessage;+; (missing the required chat), which will later produce AppleScript IDs like iMessage;+;<guid> and break group sends. Consider deriving the prefix by searching for the raw guid (identifier with an optional leading chat stripped) and returning everything before that position (so legacy returns iMessage;+;chat, modern returns any;+;).

Suggested change
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)
}
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)
// On modern macOS:
// guid = "any;+;534ce85d..."
// identifier = "534ce85d..."
// On legacy macOS:
// guid = "iMessage;+;chat534ce85d..."
// identifier = "chat534ce85d..." or "534ce85d..."
// We derive the prefix by locating the raw GUID (identifier without optional "chat").
let rawGuid = identifier
if (rawGuid.startsWith('chat')) rawGuid = rawGuid.substring(4)
const idx = guid.indexOf(rawGuid)
if (idx > 0) return guid.substring(0, idx)

Copilot uses AI. Check for mistakes.
Comment on lines +632 to +639
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

discoverGroupChatPrefix() returns guid.substring(0, idx) as soon as chat_identifier is found inside guid. If chat_identifier itself includes the legacy chat prefix (e.g., identifier=chat493..., guid=iMessage;+;chat493...), this will return "iMessage;+;" (missing the required "chat" segment), and downstream buildGroupChatGuid() will produce iMessage;+;493... which won't match Messages.app chat ids. The later identifier.startsWith('chat') fallback is currently unreachable in this case because the early idx > 0 return happens first. Consider handling the identifier.startsWith('chat') case before the idx-based return (or compute the prefix using the raw GUID without chat first).

Suggested change
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)

Copilot uses AI. Check for mistakes.
return 'any;+;'
} catch {
return 'any;+;'
}
}

/**
* Close database connection
*/
Expand Down
18 changes: 17 additions & 1 deletion src/core/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Group-chat prefix discovery is kicked off in the constructor but not awaited. Since MessageSender defaults groupChatPrefix to any;+; and sendToGroupChat() always rewrites the provided groupId using that prefix, there’s a race on older macOS where group sends can be rewritten to the wrong format until discovery completes (or if discovery fails silently). To avoid intermittent failures, consider awaiting discovery as part of a dedicated async init path, or make sendToGroupChat() avoid rewriting when the caller already supplied a full service;+;... guid (or retry with the original ID on -1728).

Copilot uses AI. Check for mistakes.

if (this.config.debug) {
console.log('[SDK] Initialization complete')
}
Expand Down Expand Up @@ -514,7 +529,8 @@ export class IMessageSDK {
events,
this.pluginManager,
this.config.debug,
this.outgoingManager
this.outgoingManager,
events?.initialLookbackMs
)

try {
Expand Down
37 changes: 23 additions & 14 deletions src/core/sender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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)
*/
Expand Down Expand Up @@ -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
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sendToGroupChat() now normalizes groupId to normalizedId for AppleScript, but sendToGroup() still constructs MessagePromise instances using the original groupId. On modern macOS where the database chatId for groups is any;+;{guid}, a caller-provided groupId like chat{guid} will not match the database value in MessagePromise.matchesChatId() (it only strips semicolon prefixes, not the chat prefix). Consider using the same normalized id for both AppleScript routing and outgoing-message tracking to keep confirmation reliable.

Copilot uses AI. Check for mistakes.

// 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)
}
Expand Down
6 changes: 6 additions & 0 deletions src/core/watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The onCheckpoint doc suggests persisting a Date and passing it back as initialLookbackMs, but initialLookbackMs is a duration (ms) rather than an absolute timestamp. This is likely to confuse consumers and lead to incorrect restarts. Consider updating the comment to explain that callers should persist lastCheckTime and compute Date.now() - lastCheckTime.getTime() (optionally clamped) to derive initialLookbackMs on restart, or change the API to accept an absolute checkpoint time instead.

Suggested change
/** 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.
*/

Copilot uses AI. Check for mistakes.
onCheckpoint?: (lastCheckTime: Date) => void
}

/**
Expand Down Expand Up @@ -117,6 +122,7 @@ export class MessageWatcher {
})

this.lastCheckTime = checkStart
this.events.onCheckpoint?.(checkStart)

/** Filter out new messages */
let newMessages = messages.filter((msg) => !this.seenMessageIds.has(msg.id))
Expand Down
131 changes: 70 additions & 61 deletions src/utils/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

normalizeChatId() now normalizes any semicolon-form chatId by returning only the last segment. This changes behavior for legacy DM inputs like iMessage;+1234567890 (it will now return +1234567890), which will break existing unit tests and may be a breaking change for callers relying on the previous “unchanged for other formats” behavior. Either update tests/callers and document the new semantics clearly, or narrow the normalization to only the formats you intend to normalize.

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validateChatId() currently accepts semicolon patterns with an empty service segment (e.g. ;+;12345678 or ;-;+1234567890) because the service;+;guid and service;-;address branches don’t validate parts[0]. This can allow clearly invalid chatIds through validation and then fail later at send time. Suggest validating that the service part is non-empty (and optionally in the same allowed-service set used for the 2-part legacy DM case).

Copilot uses AI. Check for mistakes.

// 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
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildGroupChatGuid is new exported behavior but there are no unit tests covering its normalization rules (e.g., stripping any;+; / iMessage;+;chat prefixes, handling chat{guid} inputs, and avoiding double-prefixing). Adding focused tests will help prevent regressions, especially across macOS versions where the prefix differs.

Copilot uses AI. Check for mistakes.
Loading