From 0fa09c993fdde18c47fd04147d05cdb30d331595 Mon Sep 17 00:00:00 2001 From: markxiong0122 Date: Tue, 14 Jul 2026 10:35:30 -0700 Subject: [PATCH] feat: send arbitrary emoji tapbacks --- Sources/IMsgCore/IMsgBridgeProtocol.swift | 3 + Sources/IMsgHelper/IMsgInjected.m | 142 ++++++++++++++---- .../Commands/BridgeMessagingCommands.swift | 27 +++- Sources/imsg/Commands/StatusCommand.swift | 5 +- .../RPCServer+BridgeMessageHandlers.swift | 24 ++- .../imsgTests/LaunchStatusCommandTests.swift | 1 + .../RPCBridgeMessageHandlersTests.swift | 30 ++++ 7 files changed, 192 insertions(+), 40 deletions(-) diff --git a/Sources/IMsgCore/IMsgBridgeProtocol.swift b/Sources/IMsgCore/IMsgBridgeProtocol.swift index adedaf26..b895cca3 100644 --- a/Sources/IMsgCore/IMsgBridgeProtocol.swift +++ b/Sources/IMsgCore/IMsgBridgeProtocol.swift @@ -100,6 +100,9 @@ public enum BridgeAction: String, Sendable, CaseIterable { /// /// Constants are stable across macOS 11–15. Add 1000 to the kind id to send a /// removal (e.g. `love` → 2000, `remove-love` → 3000). +/// +/// Arbitrary iOS-18 emoji tapbacks (`associated_message_type` 2006) bypass +/// this whitelist through the dedicated `emoji` bridge parameter. public enum BridgeReactionKind: String, Sendable, CaseIterable { case love case like diff --git a/Sources/IMsgHelper/IMsgInjected.m b/Sources/IMsgHelper/IMsgInjected.m index 5728db9b..daca4b56 100644 --- a/Sources/IMsgHelper/IMsgInjected.m +++ b/Sources/IMsgHelper/IMsgInjected.m @@ -950,6 +950,16 @@ static BOOL stickerTransferSelectorsAvailable(void) { NSDictionary *nicknameSelectors = nicknameSharingSelectorStatus(); Class stickerTransferClass = NSClassFromString(@"IMFileTransfer"); Class transferCenterClass = NSClassFromString(@"IMFileTransferCenter"); + Class emojiTapbackClass = NSClassFromString(@"IMEmojiTapback"); + Class tapbackSenderClass = NSClassFromString(@"IMTapbackSender"); + BOOL emojiTapbackSend = + [emojiTapbackClass instancesRespondToSelector: + NSSelectorFromString(@"initWithEmoji:isRemoved:")] + && ([tapbackSenderClass instancesRespondToSelector: + NSSelectorFromString(@"initWithTapback:chat:messagePartChatItem:")] + || [tapbackSenderClass instancesRespondToSelector: + NSSelectorFromString(@"initWithTapback:chat:messageGUID:messagePartRange:messageSummaryInfo:threadIdentifier:")]) + && [tapbackSenderClass instancesRespondToSelector:NSSelectorFromString(@"send")]; BOOL stickerSetIsSticker = [stickerTransferClass instancesRespondToSelector: NSSelectorFromString(@"setIsSticker:")]; BOOL stickerSetUserInfo = [stickerTransferClass instancesRespondToSelector: @@ -992,6 +1002,7 @@ static BOOL stickerTransferSelectorsAvailable(void) { @"nicknameLookup": nicknameSelectors[@"nickname_lookup"], @"namePhotoShouldOffer": nicknameSelectors[@"should_offer"], @"namePhotoShare": nicknameSelectors[@"share"], + @"emojiTapbackSend": @(emojiTapbackSend), @"stickerSetIsSticker": @(stickerSetIsSticker), @"stickerSetUserInfo": @(stickerSetUserInfo), @"stickerSetAttribution": @(stickerSetAttribution), @@ -5302,17 +5313,22 @@ static void cleanupPreparedStickerPaths(NSString *snapshotPath, } } -/// `send-reaction`: builds a reaction IMMessage tied to the target guid. +/// `send-reaction`: builds a reaction tied to the target guid. Arbitrary +/// emoji use IMEmojiTapback/IMTapbackSender; classic kinds use IMMessage. static NSDictionary *handleSendReaction(NSInteger requestId, NSDictionary *params) { NSString *chatGuid = params[@"chatGuid"]; NSString *selectedMessageGuid = params[@"selectedMessageGuid"]; NSString *reactionType = params[@"reactionType"]; + NSString *emoji = params[@"emoji"]; + BOOL removeEmoji = [params[@"remove"] boolValue]; NSNumber *partIndexNum = params[@"partIndex"]; NSInteger partIndex = partIndexNum ? [partIndexNum integerValue] : 0; if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid"); if (!selectedMessageGuid.length) return errorResponse(requestId, @"Missing selectedMessageGuid"); - if (!reactionType.length) return errorResponse(requestId, @"Missing reactionType"); + if (!emoji.length && !reactionType.length) { + return errorResponse(requestId, @"Missing reactionType"); + } IMChat *chat = resolveChatByGuid(chatGuid); if (!chat) { @@ -5320,6 +5336,99 @@ static void cleanupPreparedStickerPaths(NSString *snapshotPath, [NSString stringWithFormat:@"Chat not found: %@", chatGuid]); } + id parentMsg = nil; + id parentChatItem = loadParentFirstChatItem(selectedMessageGuid, &parentMsg); + NSString *parentText = nil; + if (parentMsg && [parentMsg respondsToSelector:@selector(text)]) { + id t = [parentMsg performSelector:@selector(text)]; + if ([t isKindOfClass:[NSAttributedString class]]) { + parentText = [(NSAttributedString *)t string]; + } + } + NSRange targetRange = NSMakeRange(0, 1); + if (parentChatItem + && [parentChatItem respondsToSelector:@selector(messagePartRange)]) { + targetRange = [(IMMessagePartChatItem *)parentChatItem messagePartRange]; + if (targetRange.length == 0) targetRange = NSMakeRange(0, 1); + } + NSDictionary *summary = @{ @"amc": @1, + @"ams": parentText ?: @"" }; + + if (emoji.length) { + @try { + Class tapbackClass = NSClassFromString(@"IMEmojiTapback"); + Class senderClass = NSClassFromString(@"IMTapbackSender"); + SEL tapbackInitSel = @selector(initWithEmoji:isRemoved:); + if (!tapbackClass || !senderClass + || ![tapbackClass instancesRespondToSelector:tapbackInitSel]) { + return errorResponse(requestId, + @"Emoji tapbacks unsupported on this macOS version"); + } + + id tapback = [tapbackClass alloc]; + NSMethodSignature *tsig = + [tapbackClass instanceMethodSignatureForSelector:tapbackInitSel]; + NSInvocation *tinv = [NSInvocation invocationWithMethodSignature:tsig]; + [tinv setSelector:tapbackInitSel]; + [tinv setTarget:tapback]; + [tinv setArgument:&emoji atIndex:2]; + [tinv setArgument:&removeEmoji atIndex:3]; + [tinv retainArguments]; + tapback = invokeReturningObject(tinv); + if (!tapback) { + return errorResponse(requestId, @"Could not construct IMEmojiTapback"); + } + + SEL senderPartSel = @selector(initWithTapback:chat:messagePartChatItem:); + SEL senderRangeSel = @selector(initWithTapback:chat:messageGUID:messagePartRange:messageSummaryInfo:threadIdentifier:); + id sender = nil; + if (parentChatItem && [senderClass instancesRespondToSelector:senderPartSel]) { + id senderAlloc = [senderClass alloc]; + NSMethodSignature *ssig = + [senderClass instanceMethodSignatureForSelector:senderPartSel]; + NSInvocation *sinv = [NSInvocation invocationWithMethodSignature:ssig]; + [sinv setSelector:senderPartSel]; + [sinv setTarget:senderAlloc]; + [sinv setArgument:&tapback atIndex:2]; + [sinv setArgument:&chat atIndex:3]; + [sinv setArgument:&parentChatItem atIndex:4]; + [sinv retainArguments]; + sender = invokeReturningObject(sinv); + } else if ([senderClass instancesRespondToSelector:senderRangeSel]) { + id senderAlloc = [senderClass alloc]; + NSMethodSignature *ssig = + [senderClass instanceMethodSignatureForSelector:senderRangeSel]; + NSInvocation *sinv = [NSInvocation invocationWithMethodSignature:ssig]; + [sinv setSelector:senderRangeSel]; + [sinv setTarget:senderAlloc]; + [sinv setArgument:&tapback atIndex:2]; + [sinv setArgument:&chat atIndex:3]; + [sinv setArgument:&selectedMessageGuid atIndex:4]; + [sinv setArgument:&targetRange atIndex:5]; + [sinv setArgument:&summary atIndex:6]; + id nilThreadId = nil; + [sinv setArgument:&nilThreadId atIndex:7]; + [sinv retainArguments]; + sender = invokeReturningObject(sinv); + } + if (!sender || ![sender respondsToSelector:@selector(send)]) { + return errorResponse(requestId, @"Could not construct IMTapbackSender"); + } + + [sender performSelector:@selector(send)]; + NSString *guid = lastSentMessageGuid(chat); + return successResponse(requestId, @{ + @"chatGuid": chatGuid, + @"selectedMessageGuid": selectedMessageGuid, + @"emoji": emoji, + @"messageGuid": guid ?: @"" + }); + } @catch (NSException *exception) { + return errorResponse(requestId, + [NSString stringWithFormat:@"send-reaction (emoji) failed: %@", exception.reason]); + } + } + long long associatedType = -1; NSDictionary *kindMap = @{ @"love": @2000, @"like": @2001, @"dislike": @2002, @@ -5367,39 +5476,10 @@ static void cleanupPreparedStickerPaths(NSString *snapshotPath, } verb = removed; } - id parentMsg = nil; - id parentChatItem = loadParentFirstChatItem(selectedMessageGuid, &parentMsg); - NSString *parentText = nil; - if (parentMsg && [parentMsg respondsToSelector:@selector(text)]) { - id t = [parentMsg performSelector:@selector(text)]; - if ([t isKindOfClass:[NSAttributedString class]]) { - parentText = [(NSAttributedString *)t string]; - } - } - // BB-verified: derive `associatedMessageRange` from the parent's first - // chat item — `[item messagePartRange]`. Hardcoding `{0,1}` (what we did - // before) targets the wrong part on multipart parents (e.g. tapback on - // the second image of a photo grid). For non-text parts (attachments) - // BB substitutes "an attachment" for the quoted text. - NSRange targetRange = NSMakeRange(0, 1); - if (parentChatItem - && [parentChatItem respondsToSelector:@selector(messagePartRange)]) { - targetRange = [(IMMessagePartChatItem *)parentChatItem messagePartRange]; - if (targetRange.length == 0) targetRange = NSMakeRange(0, 1); - } NSString *quoted = parentText.length ? [NSString stringWithFormat:@"%@“%@”", verb, parentText] : [verb stringByAppendingString:@"a message"]; NSAttributedString *body = buildPlainAttributed(quoted, partIndex); - - // BB-verified `messageSummaryInfo` shape: `amc` is an integer count - // (always `@1` for single-target tapbacks), `ams` is the parent text - // (the receiver's notification preview reads ` ""`). Earlier - // we were stuffing the parent guid into `amc` as a string — the - // resulting `message_summary_info` blob was malformed and on macOS 26 - // imagent silently dropped the reaction. - NSDictionary *summary = @{ @"amc": @1, - @"ams": parentText ?: @"" }; debugLog(@"handleSendReaction: target=%@ type=%lld range={%lu,%lu} body=%@", associatedRef, associatedType, (unsigned long)targetRange.location, (unsigned long)targetRange.length, diff --git a/Sources/imsg/Commands/BridgeMessagingCommands.swift b/Sources/imsg/Commands/BridgeMessagingCommands.swift index 32391f14..03a9fb76 100644 --- a/Sources/imsg/Commands/BridgeMessagingCommands.swift +++ b/Sources/imsg/Commands/BridgeMessagingCommands.swift @@ -340,6 +340,9 @@ enum BridgeReactCommand { .make( label: "kind", names: [.long("kind")], help: "love|like|dislike|laugh|emphasize|question"), + .make( + label: "emoji", names: [.long("emoji")], + help: "arbitrary emoji tapback (iOS 18+, e.g. 🎉) — --kind not required when set"), .make(label: "part", names: [.long("part")], help: "part index"), ], flags: [ @@ -350,7 +353,8 @@ enum BridgeReactCommand { ) ), usageExamples: [ - "imsg tapback --chat 'iMessage;-;+15551234567' --message ABCD-EFGH --kind love" + "imsg tapback --chat 'iMessage;-;+15551234567' --message ABCD-EFGH --kind love", + "imsg tapback --chat 'iMessage;-;+15551234567' --message ABCD-EFGH --emoji 🎉", ] ) { values, runtime in try await run(values: values, runtime: runtime) @@ -363,16 +367,33 @@ enum BridgeReactCommand { guard let message = values.option("message"), !message.isEmpty else { throw ParsedValuesError.missingOption("message") } + let remove = values.flag("remove") + let partIndex = Int(values.option("part") ?? "0") ?? 0 + + if let emoji = values.option("emoji"), !emoji.isEmpty { + let params: [String: Any] = [ + "chatGuid": chat, + "selectedMessageGuid": message, + "emoji": emoji, + "remove": remove, + "partIndex": partIndex, + ] + _ = try await BridgeOutput.invokeAndEmit( + action: .sendReaction, params: params, runtime: runtime + ) { _ in "tapback: \(emoji) sent" } + return + } + guard let kind = values.option("kind"), !kind.isEmpty else { throw ParsedValuesError.missingOption("kind") } let normalized = kind.lowercased() - let prefixed = values.flag("remove") ? "remove-\(normalized)" : normalized + let prefixed = remove ? "remove-\(normalized)" : normalized let params: [String: Any] = [ "chatGuid": chat, "selectedMessageGuid": message, "reactionType": prefixed, - "partIndex": Int(values.option("part") ?? "0") ?? 0, + "partIndex": partIndex, ] _ = try await BridgeOutput.invokeAndEmit( action: .sendReaction, params: params, runtime: runtime diff --git a/Sources/imsg/Commands/StatusCommand.swift b/Sources/imsg/Commands/StatusCommand.swift index 4b923ab7..b687104a 100644 --- a/Sources/imsg/Commands/StatusCommand.swift +++ b/Sources/imsg/Commands/StatusCommand.swift @@ -69,7 +69,8 @@ enum StatusCommand { bridgeVersion: bridgeVersion, v2Ready: v2Ready, selectors: selectors, - rpcMethods: kSupportedRPCMethods + rpcMethods: kSupportedRPCMethods, + rpcFeatures: ["tapback.emoji"] ) try JSONLines.print(payload) } else { @@ -153,6 +154,7 @@ private struct StatusPayload: Encodable { let v2Ready: Bool let selectors: [String: Bool] let rpcMethods: [String] + let rpcFeatures: [String] enum CodingKeys: String, CodingKey { case version @@ -166,5 +168,6 @@ private struct StatusPayload: Encodable { case v2Ready = "v2_ready" case selectors case rpcMethods = "rpc_methods" + case rpcFeatures = "rpc_features" } } diff --git a/Sources/imsg/RPCServer+BridgeMessageHandlers.swift b/Sources/imsg/RPCServer+BridgeMessageHandlers.swift index 63dd82fa..4ee5be84 100644 --- a/Sources/imsg/RPCServer+BridgeMessageHandlers.swift +++ b/Sources/imsg/RPCServer+BridgeMessageHandlers.swift @@ -347,11 +347,25 @@ extension RPCServer { guard let messageGUID = rpcMessageGUIDParam(params) else { throw RPCError.invalidParams("message_id or message_guid is required") } - let rawReaction = stringParam(params["reaction"] ?? params["kind"] ?? params["emoji"]) ?? "" - let reactionType = try normalizeBridgeReactionType( - rawReaction, - remove: boolParam(params["remove"]) ?? false - ) + let remove = boolParam(params["remove"]) ?? false + + if let emoji = stringParam(params["emoji"]), !emoji.isEmpty { + _ = try await invokeBridge( + action: .sendReaction, + params: [ + "chatGuid": chatGUID, + "selectedMessageGuid": messageGUID, + "emoji": emoji, + "remove": remove, + "partIndex": intParam(params["part_index"] ?? params["partIndex"]) ?? 0, + ] + ) + respond(id: id, result: ["ok": true, "emoji": emoji]) + return + } + + let rawReaction = stringParam(params["reaction"] ?? params["kind"]) ?? "" + let reactionType = try normalizeBridgeReactionType(rawReaction, remove: remove) _ = try await invokeBridge( action: .sendReaction, params: [ diff --git a/Tests/imsgTests/LaunchStatusCommandTests.swift b/Tests/imsgTests/LaunchStatusCommandTests.swift index d8dab2f8..61b37fc4 100644 --- a/Tests/imsgTests/LaunchStatusCommandTests.swift +++ b/Tests/imsgTests/LaunchStatusCommandTests.swift @@ -42,6 +42,7 @@ func statusCommandProducesJsonOutput() async throws { #expect(output.contains(#""version":"\#(IMsgVersion.current)""#)) #expect(output.contains("basic_features")) #expect(output.contains("advanced_features")) + #expect(output.contains(#""rpc_features":["tapback.emoji"]"#)) } @Test diff --git a/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift b/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift index 06eb1b4e..0d91f98f 100644 --- a/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift +++ b/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift @@ -226,6 +226,36 @@ func rpcNormalizesTapbackReactionAliases() throws { #expect(try normalizeBridgeReactionType("remove-like") == "remove-like") } +@Test +func rpcTapbackForwardsCustomEmojiOutsideTheClassicKindWhitelist() async throws { + let store = try CommandTestDatabase.makeStoreForRPC() + let output = TestRPCOutput() + var capturedAction: BridgeAction? + var capturedParams: [String: Any] = [:] + let server = RPCServer( + store: store, + verbose: false, + output: output, + invokeBridge: { action, params in + capturedAction = action + capturedParams = params + return [:] + } + ) + + await server.handleLineForTesting( + #"{"jsonrpc":"2.0","id":"emoji","method":"tapback","params":{"chat_id":1,"message_guid":"parent-guid","emoji":"💀","remove":true}}"# + ) + + #expect(capturedAction == .sendReaction) + #expect(capturedParams["emoji"] as? String == "💀") + #expect(capturedParams["remove"] as? Bool == true) + #expect(capturedParams["reactionType"] == nil) + let result = output.responses.first?["result"] as? [String: Any] + #expect(result?["ok"] as? Bool == true) + #expect(result?["emoji"] as? String == "💀") +} + @Test func rpcSendRichInvokesBridgeWithResolvedChat() async throws { let store = try CommandTestDatabase.makeStoreForRPC()