diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ff4829..63ce975 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## 0.8.3 - Unreleased +### Private API Bridge +- fix: support threaded attachment replies via `send-rich --file` and + `send-attachment --reply-to`, including the macOS 26 attachment staging + fallback (#113, #114, thanks @omarshahine). + ## 0.8.2 - 2026-05-11 ### JSON-RPC diff --git a/README.md b/README.md index e1d482b..06a7f90 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,8 @@ Advanced IMCore (require `imsg launch` with SIP off — see - `imsg typing --to [--duration 5s] [--stop true]` - `imsg launch [--dylib ] [--kill-only] [--json]` - `imsg status [--json]` -- `imsg send-rich`, `imsg send-multipart`, `imsg send-attachment`, +- `imsg send-rich [--reply-to ] [--file ]`, + `imsg send-multipart`, `imsg send-attachment [--reply-to ]`, `imsg tapback` - `imsg edit`, `imsg unsend`, `imsg delete-message`, `imsg notify-anyways` - `imsg chat-create`, `imsg chat-name`, `imsg chat-photo`, @@ -274,6 +275,10 @@ imsg send-rich --chat 'iMessage;-;+15551234567' --text "boom" \ --effect com.apple.MobileSMS.expressivesend.impact \ --reply-to +# Threaded reply with an attachment in one message +imsg send-rich --chat 'iMessage;-;+15551234567' \ + --reply-to --text "here it is" --file ~/Pictures/img.jpg + # Text formatting (macOS 15+ Sequoia): bold/italic/underline/strikethrough # applied to specific ranges of the message body. imsg send-rich --chat ... --text 'hello world' \ @@ -287,6 +292,7 @@ imsg send-multipart --chat 'iMessage;+;chat0000' \ # Attachment (file or audio) imsg send-attachment --chat ... --file ~/Pictures/img.jpg --transport auto +imsg send-attachment --chat ... --reply-to --file ~/Pictures/img.jpg imsg send-attachment --chat ... --file ~/audio.caf --audio # Bridge tapback (custom emoji + remove supported here, unlike `imsg react`) diff --git a/Sources/IMsgHelper/IMsgInjected.m b/Sources/IMsgHelper/IMsgInjected.m index f5bab6b..71e4df3 100644 --- a/Sources/IMsgHelper/IMsgInjected.m +++ b/Sources/IMsgHelper/IMsgInjected.m @@ -1297,6 +1297,30 @@ static void dispatchIMMessageInChat(IMChat *chat, id message) { [chat performSelector:@selector(sendMessage:) withObject:message]; } +static unsigned long long flagsForMessagePayload(NSAttributedString *subject, + NSArray *fileTransferGuids, + BOOL isAudioMessage) { + if (isAudioMessage) { + return 0x300005ULL; + } + if (subject.length) { + return 0x10000dULL; + } + if (fileTransferGuids.count > 0) { + return 0x100005ULL; + } + return 0x100005ULL; +} + +static unsigned long long flagsForAssociatedMessagePayload(NSAttributedString *subject, + NSArray *fileTransferGuids, + BOOL isAudioMessage) { + if (fileTransferGuids.count == 0) { + return 0x5ULL; + } + return flagsForMessagePayload(subject, fileTransferGuids, isAudioMessage); +} + /// Build an IMMessage suitable for `[chat sendMessage:]`. Handles plain text, /// optional subject, optional effect (`com.apple.MobileSMS.expressivesend.*`), /// optional reply target (`selectedMessageGuid`), and ddScan flag. @@ -1350,7 +1374,9 @@ static id buildIMMessage(NSAttributedString *body, // longer initializer on the result. SEL macos26Sel = @selector(initWithSender:time:text:messageSubject:fileTransferGUIDs:flags:error:guid:subject:associatedMessageGUID:associatedMessageType:associatedMessageRange:messageSummaryInfo:); if ([messageClass instancesRespondToSelector:macos26Sel]) { - unsigned long long flags = 0x5; + unsigned long long flags = flagsForAssociatedMessagePayload(subject, + fileTransferGuids, + isAudioMessage); id msg = [[messageClass alloc] init]; NSMethodSignature *sig = [messageClass instanceMethodSignatureForSelector:macos26Sel]; @@ -1376,7 +1402,14 @@ static id buildIMMessage(NSAttributedString *body, id result = invokeReturningObject(inv); debugLog(@"buildIMMessage: reaction via macos26Sel result=%@", result ? NSStringFromClass([result class]) : @"(nil)"); - if (result) return result; + if (result) { + if (threadIdentifier + && [result respondsToSelector:@selector(setThreadIdentifier:)]) { + [result performSelector:@selector(setThreadIdentifier:) + withObject:threadIdentifier]; + } + return result; + } } // Legacy 17-arg form for older macOS. @@ -1386,7 +1419,9 @@ static id buildIMMessage(NSAttributedString *body, responds, associatedMessageType, associatedMessageGuid); id msg = [messageClass alloc]; if ([msg respondsToSelector:sel]) { - unsigned long long flags = 0x5; + unsigned long long flags = flagsForAssociatedMessagePayload(subject, + fileTransferGuids, + isAudioMessage); NSMethodSignature *sig = [messageClass instanceMethodSignatureForSelector:sel]; NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig]; [inv setSelector:sel]; @@ -1412,6 +1447,11 @@ static id buildIMMessage(NSAttributedString *body, [inv invoke]; __unsafe_unretained id result = nil; [inv getReturnValue:&result]; + if (threadIdentifier + && [result respondsToSelector:@selector(setThreadIdentifier:)]) { + [result performSelector:@selector(setThreadIdentifier:) + withObject:threadIdentifier]; + } return result; } } @@ -1422,14 +1462,8 @@ static id buildIMMessage(NSAttributedString *body, // older releases. SEL bbSendSel = @selector(initWithSender:time:text:messageSubject:fileTransferGUIDs:flags:error:guid:subject:balloonBundleID:payloadData:expressiveSendStyleID:); if ([messageClass instancesRespondToSelector:bbSendSel]) { - unsigned long long flags; - if (isAudioMessage) { - flags = 0x300005ULL; - } else if (subject.length) { - flags = 0x10000dULL; - } else { - flags = 0x100005ULL; - } + unsigned long long flags = flagsForMessagePayload(subject, fileTransferGuids, + isAudioMessage); id m = [[messageClass alloc] init]; NSMethodSignature *sig = [messageClass instanceMethodSignatureForSelector:bbSendSel]; NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig]; @@ -1464,14 +1498,8 @@ static id buildIMMessage(NSAttributedString *body, SEL sel = @selector(initIMMessageWithSender:time:text:messageSubject:fileTransferGUIDs:flags:error:guid:subject:balloonBundleID:payloadData:expressiveSendStyleID:); id msg = [messageClass alloc]; if ([msg respondsToSelector:sel]) { - unsigned long long flags; - if (isAudioMessage) { - flags = 0x300005ULL; - } else if (subject.length) { - flags = 0x10000dULL; - } else { - flags = 0x100005ULL; - } + unsigned long long flags = flagsForMessagePayload(subject, fileTransferGuids, + isAudioMessage); NSMethodSignature *sig = [messageClass instanceMethodSignatureForSelector:sel]; NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig]; [inv setSelector:sel]; @@ -1888,67 +1916,6 @@ static void retargetPreparedTransfer(id ftc, IMFileTransfer *transfer, } } -static NSString *saveAttachmentForTransfer(id pac, IMFileTransfer *transfer, - NSString *chatGuid, NSString **outErr) { - SEL saveSel = @selector(saveAttachmentsForTransfer:chatGUID:storeAtExternalLocation:completion:); - if (!pac || ![pac respondsToSelector:saveSel]) { - return nil; - } - - __block BOOL done = NO; - __block NSString *savedPath = nil; - __block id saveError = nil; - // Runtime probe on macOS 26 shows the completion receives: - // primaryPath, error, externalPath - // `externalPath` is only populated when `storeAtExternalLocation:YES`. - void (^completion)(id, id, id) = ^(id primaryPath, id error, id externalPath) { - if ([primaryPath isKindOfClass:[NSString class]] && [(NSString *)primaryPath length]) { - savedPath = primaryPath; - } else if ([externalPath isKindOfClass:[NSString class]] - && [(NSString *)externalPath length]) { - savedPath = externalPath; - } - saveError = error; - done = YES; - }; - - NSMethodSignature *sig = [pac methodSignatureForSelector:saveSel]; - NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig]; - [inv setSelector:saveSel]; - [inv setTarget:pac]; - __unsafe_unretained IMFileTransfer *xfer = transfer; - __unsafe_unretained NSString *cg = chatGuid; - BOOL external = chatGuid.length > 0; - // Call via NSInvocation because this private selector and block signature - // are absent from public SDK headers. - [inv setArgument:&xfer atIndex:2]; - [inv setArgument:&cg atIndex:3]; - [inv setArgument:&external atIndex:4]; - [inv setArgument:&completion atIndex:5]; - [inv retainArguments]; - [inv invoke]; - - // The completion is usually synchronous, but keep the same bounded run-loop - // pump used by other bridge helpers in case IMDPersistence hops queues. - NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:3.0]; - while (!done && [deadline timeIntervalSinceNow] > 0) { - [[NSRunLoop currentRunLoop] - runMode:NSDefaultRunLoopMode - beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.05]]; - } - - debugLog(@"prepareOutgoingTransfer: saveAttachments path=%@ error=%@ done=%d", - savedPath ?: @"(nil)", saveError ?: @"(nil)", done); - if (!done) { - if (outErr) *outErr = @"Timed out staging attachment"; - return nil; - } - if (saveError && outErr) { - *outErr = [NSString stringWithFormat:@"Failed to stage attachment: %@", saveError]; - } - return savedPath; -} - static IMFileTransfer *prepareOutgoingTransfer(NSURL *originalURL, NSString *filename, NSString *chatGuid, NSString **outErr) { Class ftcClass = NSClassFromString(@"IMFileTransferCenter"); @@ -2016,44 +1983,59 @@ static void retargetPreparedTransfer(id ftc, IMFileTransfer *transfer, debugLog(@"prepareOutgoingTransfer: persistentPath=%@ filename=%@", persistentPath ?: @"(nil)", fn); + NSError *legacyErr = nil; + BOOL legacyStaged = NO; if (persistentPath.length) { NSURL *persistentURL = [NSURL fileURLWithPath:persistentPath]; NSURL *parent = [persistentURL URLByDeletingLastPathComponent]; - NSError *folderErr = nil; [[NSFileManager defaultManager] createDirectoryAtURL:parent withIntermediateDirectories:YES attributes:nil - error:&folderErr]; - if (folderErr) { - if (outErr) *outErr = [NSString stringWithFormat: - @"Failed to create attachment dir: %@", folderErr.localizedDescription]; - return nil; - } - // If the destination already exists (e.g., re-send of the same - // file), nuke the stale copy so copyItem doesn't fail. - if ([[NSFileManager defaultManager] fileExistsAtPath:persistentPath]) { - [[NSFileManager defaultManager] removeItemAtURL:persistentURL error:NULL]; - } - NSError *copyErr = nil; - [[NSFileManager defaultManager] copyItemAtURL:originalURL - toURL:persistentURL - error:©Err]; - if (copyErr) { - if (outErr) *outErr = [NSString stringWithFormat: - @"Failed to copy attachment: %@", copyErr.localizedDescription]; - return nil; + error:&legacyErr]; + if (!legacyErr) { + // If the destination already exists (e.g., re-send of the + // same file), nuke the stale copy so copyItem doesn't fail. + if ([[NSFileManager defaultManager] fileExistsAtPath:persistentPath]) { + [[NSFileManager defaultManager] removeItemAtURL:persistentURL error:NULL]; + } + [[NSFileManager defaultManager] copyItemAtURL:originalURL + toURL:persistentURL + error:&legacyErr]; + if (!legacyErr) { + retargetPreparedTransfer(ftc, transfer, transferGuid, persistentPath); + legacyStaged = YES; + } } - retargetPreparedTransfer(ftc, transfer, transferGuid, persistentPath); - } else { - // Newer IMDPersistence builds also expose a block-based save - // API. Use it as a fallback when the older path helper refuses - // to return a staging path for this transfer. - NSString *savedPath = saveAttachmentForTransfer(pac, transfer, chatGuid, outErr); - if (savedPath.length) { - retargetPreparedTransfer(ftc, transfer, transferGuid, savedPath); - } else if (outErr && !*outErr) { - *outErr = @"Could not stage attachment"; - return nil; + } + if (!legacyStaged) { + // IMDPersistence on macOS 26 / Tahoe returns either nil (when + // chatGUID is nil, per BlueBubbles' reference implementation) + // or an iOS-style /var/mobile/... path (when chatGUID is + // non-nil) that Messages.app can't actually write to. The + // _alternative_ fallback that some IMDPersistence builds + // expose, saveAttachmentsForTransfer:chatGUID:storeAtExternalLocation:completion:, + // returns a path inside the Messages.app sandbox container + // that imagent can't read from for outgoing sends (the row + // lands in chat.db but error=25, is_sent=0). + // + // The transfer was created via guidForNewOutgoingTransferWithLocalURL: + // with the source already living under + // ~/Library/Messages/Attachments/imsg// (Swift's + // MessageSender.stageAttachmentForMessagesApp puts it there + // before we get here). That path is in the user-visible + // Attachments tree, which imagent reads happily — BlueBubbles + // takes the same approach when its persistentPath comes back + // nil. So when the legacy retarget can't run, leave the + // transfer pointing at its original localURL and let + // registerTransferWithDaemon: pick it up directly. + if (legacyErr) { + debugLog(@"prepareOutgoingTransfer: legacy path %@ unusable (%@); " + @"keeping original localURL=%@ for registerTransferWithDaemon", + persistentPath ?: @"(nil)", legacyErr.localizedDescription, + originalURL.path); + } else { + debugLog(@"prepareOutgoingTransfer: no persistent path; keeping " + @"original localURL=%@", originalURL.path); } } } @@ -2075,11 +2057,19 @@ static void retargetPreparedTransfer(id ftc, IMFileTransfer *transfer, static NSDictionary *handleSendAttachment(NSInteger requestId, NSDictionary *params) { NSString *chatGuid = params[@"chatGuid"]; NSString *filePath = params[@"filePath"]; + NSString *message = params[@"message"]; + NSString *effectId = params[@"effectId"]; + NSString *subject = params[@"subject"]; + NSString *selectedMessageGuid = params[@"selectedMessageGuid"]; + NSNumber *partIndexNum = params[@"partIndex"]; + NSInteger partIndex = partIndexNum ? [partIndexNum integerValue] : 0; NSNumber *audioFlag = params[@"isAudioMessage"]; BOOL isAudio = [audioFlag boolValue]; + NSArray *textFormatting = params[@"textFormatting"]; if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid"); if (!filePath.length) return errorResponse(requestId, @"Missing filePath"); + if (!message) message = @""; NSError *attrErr = nil; NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:filePath error:&attrErr]; @@ -2119,19 +2109,58 @@ static void retargetPreparedTransfer(id ftc, IMFileTransfer *transfer, return errorResponse(requestId, @"Transfer registered without guid"); } - NSAttributedString *body = buildAttachmentAttributed(transferGuid, filename, 0); - id imMessage = buildIMMessage(body, nil, nil, nil, nil, 0, + NSMutableAttributedString *body = [[NSMutableAttributedString alloc] init]; + NSInteger attachmentPartIndex = partIndex; + if (message.length) { + NSString *textPrefix = [message stringByAppendingString:@"\n"]; + NSAttributedString *textBody = nil; + if ([textFormatting isKindOfClass:[NSArray class]] && textFormatting.count > 0) { + textBody = buildFormattedAttributed(textPrefix, textFormatting, partIndex); + } else { + textBody = buildPlainAttributed(textPrefix, partIndex); + } + [body appendAttributedString:textBody]; + attachmentPartIndex = partIndex + 1; + } + [body appendAttributedString:buildAttachmentAttributed(transferGuid, filename, + attachmentPartIndex)]; + + NSAttributedString *subjectAttr = subject.length + ? buildPlainAttributed(subject, 0) + : nil; + long long associatedType = selectedMessageGuid.length ? 100 : 0; + id parentMessage = nil; + NSString *threadIdentifier = nil; + if (selectedMessageGuid.length) { + threadIdentifier = deriveThreadIdentifier(selectedMessageGuid, &parentMessage); + debugLog(@"handleSendAttachment: parent=%@ threadId=%@", + selectedMessageGuid, threadIdentifier ?: @"(none)"); + } + + id imMessage = buildIMMessage(body, subjectAttr, effectId, threadIdentifier, + selectedMessageGuid, associatedType, NSMakeRange(0, body.length), nil, @[transferGuid], isAudio, NO); if (!imMessage) { return errorResponse(requestId, @"Could not build IMMessage with attachment"); } + if (parentMessage + && [imMessage respondsToSelector:@selector(setThreadOriginator:)]) { + [imMessage performSelector:@selector(setThreadOriginator:) + withObject:parentMessage]; + } + if (threadIdentifier + && [imMessage respondsToSelector:@selector(setThreadIdentifier:)]) { + [imMessage performSelector:@selector(setThreadIdentifier:) + withObject:threadIdentifier]; + } dispatchIMMessageInChat(chat, imMessage); NSString *guid = lastSentMessageGuid(chat); return successResponse(requestId, @{ @"chatGuid": chatGuid, @"messageGuid": guid ?: @"", - @"transferGuid": transferGuid + @"transferGuid": transferGuid, + @"selectedMessageGuid": selectedMessageGuid ?: @"" }); } @catch (NSException *exception) { return errorResponse(requestId, diff --git a/Sources/imsg/Commands/BridgeAttachmentCommand.swift b/Sources/imsg/Commands/BridgeAttachmentCommand.swift index 0e11dd8..e40be54 100644 --- a/Sources/imsg/Commands/BridgeAttachmentCommand.swift +++ b/Sources/imsg/Commands/BridgeAttachmentCommand.swift @@ -12,6 +12,7 @@ enum SendAttachmentCommand { options: CommandSignatures.baseOptions() + [ .make(label: "chat", names: [.long("chat")], help: "chat guid"), .make(label: "file", names: [.long("file")], help: "absolute path to file"), + .make(label: "replyTo", names: [.long("reply-to")], help: "guid of message to reply to"), .make( label: "transport", names: [.long("transport")], help: "transport to use: auto|dylib|applescript"), @@ -44,21 +45,28 @@ enum SendAttachmentCommand { if transport == "applescript" && audio { throw ParsedValuesError.invalidOption("audio") } + let replyTo = values.option("replyTo") ?? "" + if transport == "applescript" && !replyTo.isEmpty { + throw ParsedValuesError.invalidOption("reply-to") + } if transport != "applescript" { let staged = try MessageSender.stageAttachmentForMessagesApp(at: expanded) - let params: [String: Any] = [ + var params: [String: Any] = [ "chatGuid": chat, "filePath": staged, "isAudioMessage": audio, ] + if !replyTo.isEmpty { + params["selectedMessageGuid"] = replyTo + } do { let data = try await IMsgBridgeClient.shared.invoke(action: .sendAttachment, params: params) let guid = (data["messageGuid"] as? String) ?? "" BridgeOutput.emit(data, runtime: runtime, summary: "send-attachment: queued (guid=\(guid))") return } catch { - if transport == "dylib" || audio { + if transport == "dylib" || audio || !replyTo.isEmpty { BridgeOutput.emitError(String(describing: error), runtime: runtime) throw BridgeOutput.EmittedError() } diff --git a/Sources/imsg/Commands/BridgeMessagingCommands.swift b/Sources/imsg/Commands/BridgeMessagingCommands.swift index 7b7d5fb..bc3cc72 100644 --- a/Sources/imsg/Commands/BridgeMessagingCommands.swift +++ b/Sources/imsg/Commands/BridgeMessagingCommands.swift @@ -65,10 +65,14 @@ enum BridgeOutput { action: BridgeAction, params: [String: Any], runtime: RuntimeOptions, + invokeBridge: @escaping (BridgeAction, [String: Any]) async throws -> [String: Any] = { + action, params in + try await IMsgBridgeClient.shared.invoke(action: action, params: params) + }, summary: (([String: Any]) -> String) ) async throws -> [String: Any] { do { - let data = try await IMsgBridgeClient.shared.invoke(action: action, params: params) + let data = try await invokeBridge(action, params) emit(data, runtime: runtime, summary: summary(data)) return data } catch { @@ -95,6 +99,7 @@ enum SendRichCommand { .make( label: "chat", names: [.long("chat")], help: "chat guid (e.g. iMessage;-;+15551234567)"), .make(label: "text", names: [.long("text")], help: "message body"), + .make(label: "file", names: [.long("file")], help: "path to attachment"), .make( label: "effect", names: [.long("effect")], help: "expressive send id (impact, loud, gentle, invisibleink, confetti, …)"), @@ -118,6 +123,7 @@ enum SendRichCommand { ), usageExamples: [ "imsg send-rich --chat 'iMessage;-;+15551234567' --text 'hi'", + "imsg send-rich --chat 'iMessage;-;+15551234567' --reply-to ABCD --file ~/Desktop/pic.jpg", "imsg send-rich --chat 'iMessage;-;+15551234567' --text 'BOOM' --effect impact", "imsg send-rich --chat 'iMessage;-;+15551234567' --text 'pew pew' --effect lasers", "imsg send-rich --chat ... --text 'hello world' --format '[{\"start\":0,\"length\":5,\"styles\":[\"bold\"]}]'", @@ -126,11 +132,21 @@ enum SendRichCommand { try await run(values: values, runtime: runtime) } - static func run(values: ParsedValues, runtime: RuntimeOptions) async throws { + static func run( + values: ParsedValues, + runtime: RuntimeOptions, + invokeBridge: @escaping (BridgeAction, [String: Any]) async throws -> [String: Any] = { + action, params in + try await IMsgBridgeClient.shared.invoke(action: action, params: params) + }, + stageAttachment: @escaping (String) throws -> String = MessageSender + .stageAttachmentForMessagesApp + ) async throws { guard let chat = values.option("chat"), !chat.isEmpty else { throw ParsedValuesError.missingOption("chat") } let text = values.option("text") ?? "" + let file = values.option("file") ?? "" var params: [String: Any] = [ "chatGuid": chat, "message": text, @@ -166,8 +182,24 @@ enum SendRichCommand { params["textFormatting"] = ranges } + if !file.isEmpty { + let expanded = (file as NSString).expandingTildeInPath + params["filePath"] = try stageAttachment(expanded) + params["isAudioMessage"] = false + _ = try await BridgeOutput.invokeAndEmit( + action: .sendAttachment, + params: params, + runtime: runtime, + invokeBridge: invokeBridge + ) { data in + let guid = (data["messageGuid"] as? String) ?? "" + return guid.isEmpty ? "send-rich: attachment queued" : "send-rich: sent (guid=\(guid))" + } + return + } + _ = try await BridgeOutput.invokeAndEmit( - action: .sendMessage, params: params, runtime: runtime + action: .sendMessage, params: params, runtime: runtime, invokeBridge: invokeBridge ) { data in let guid = (data["messageGuid"] as? String) ?? "" return guid.isEmpty ? "send-rich: queued" : "send-rich: sent (guid=\(guid))" diff --git a/Tests/imsgTests/BridgeRichCommandTests.swift b/Tests/imsgTests/BridgeRichCommandTests.swift new file mode 100644 index 0000000..898486b --- /dev/null +++ b/Tests/imsgTests/BridgeRichCommandTests.swift @@ -0,0 +1,89 @@ +import Commander +import Foundation +import Testing + +@testable import IMsgCore +@testable import imsg + +@Test +func sendRichWithFileAndReplyUsesAttachmentBridge() async throws { + let values = ParsedValues( + positional: [], + options: [ + "chat": ["iMessage;-;+15551234567"], + "text": ["here it is"], + "file": ["~/Desktop/pic.jpg"], + "replyTo": ["parent-guid"], + "effect": ["impact"], + "subject": ["subject"], + "part": ["2"], + ], + flags: [] + ) + let runtime = RuntimeOptions(parsedValues: values) + var capturedAction: BridgeAction? + var capturedParams: [String: Any] = [:] + var stagedSource = "" + + let (output, _) = try await StdoutCapture.capture { + try await SendRichCommand.run( + values: values, + runtime: runtime, + invokeBridge: { action, params in + capturedAction = action + capturedParams = params + return ["messageGuid": "sent-guid"] + }, + stageAttachment: { path in + stagedSource = path + return "/staged/pic.jpg" + } + ) + } + + #expect(capturedAction == .sendAttachment) + #expect(capturedParams["chatGuid"] as? String == "iMessage;-;+15551234567") + #expect(capturedParams["message"] as? String == "here it is") + #expect(capturedParams["filePath"] as? String == "/staged/pic.jpg") + #expect(capturedParams["selectedMessageGuid"] as? String == "parent-guid") + #expect(capturedParams["effectId"] as? String == "com.apple.MobileSMS.expressivesend.impact") + #expect(capturedParams["subject"] as? String == "subject") + #expect(capturedParams["partIndex"] as? Int == 2) + #expect(capturedParams["isAudioMessage"] as? Bool == false) + #expect(stagedSource.hasSuffix("/Desktop/pic.jpg")) + #expect(output.contains("send-rich: sent (guid=sent-guid)")) +} + +@Test +func sendRichTextOnlyStillUsesMessageBridge() async throws { + let values = ParsedValues( + positional: [], + options: [ + "chat": ["iMessage;-;+15551234567"], + "text": ["hi"], + "replyTo": ["parent-guid"], + ], + flags: [] + ) + let runtime = RuntimeOptions(parsedValues: values) + var capturedAction: BridgeAction? + var capturedParams: [String: Any] = [:] + + _ = try await StdoutCapture.capture { + try await SendRichCommand.run( + values: values, + runtime: runtime, + invokeBridge: { action, params in + capturedAction = action + capturedParams = params + return ["messageGuid": "sent-guid"] + } + ) + } + + #expect(capturedAction == .sendMessage) + #expect(capturedParams["chatGuid"] as? String == "iMessage;-;+15551234567") + #expect(capturedParams["message"] as? String == "hi") + #expect(capturedParams["selectedMessageGuid"] as? String == "parent-guid") + #expect(capturedParams["filePath"] == nil) +} diff --git a/docs/advanced-imcore.md b/docs/advanced-imcore.md index 923cd9d..2067132 100644 --- a/docs/advanced-imcore.md +++ b/docs/advanced-imcore.md @@ -15,8 +15,11 @@ You almost certainly do not need any of this for normal use. - `imsg typing --to [--duration 5s] [--stop true]` — show or stop the typing indicator. - `imsg launch [--dylib ] [--kill-only]` — launch Messages.app with the helper dylib injected. - `imsg status` — read-only IMCore bridge status. -- `imsg send-attachment --chat --file ` — prefers the bridge for - private attachment sends, with AppleScript fallback for normal files. +- `imsg send-rich --chat --reply-to --file ` — + sends a threaded reply with an attachment through the bridge. +- `imsg send-attachment --chat --file [--reply-to ]` — + prefers the bridge for private attachment sends, with AppleScript fallback + for normal files when no reply target is requested. ## Why they're separate diff --git a/docs/attachments.md b/docs/attachments.md index 60cd31e..778a399 100644 --- a/docs/attachments.md +++ b/docs/attachments.md @@ -61,6 +61,16 @@ Before invoking AppleScript, `imsg` stages the file under `~/Library/Messages/At The staged copies live under `imsg/`, distinct from Messages' own subdirectories, and are not pruned automatically. Clear them periodically if disk space matters. +For bridge-backed threaded replies, use `send-rich --file` or +`send-attachment --reply-to`: + +```bash +imsg send-rich --chat 'iMessage;-;+15551234567' \ + --reply-to --text "here it is" --file ~/Desktop/photo.jpg +imsg send-attachment --chat 'iMessage;-;+15551234567' \ + --reply-to --file ~/Desktop/photo.jpg +``` + ## Why not just copy or upload? The CLI's contract is "read what's there, send what you give it." Anything beyond that — bulk archival, cloud upload, format conversion at rest — is left to callers, who know their retention and privacy requirements. The conversion feature is the one exception, and only because some receive-side formats (CAF, animated GIF) are awkward for downstream tools to handle.