Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
3 changes: 3 additions & 0 deletions Sources/IMsgCore/IMsgBridgeProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
142 changes: 111 additions & 31 deletions Sources/IMsgHelper/IMsgInjected.m
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -5302,24 +5313,122 @@ 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) {
return errorResponse(requestId,
[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,
Expand Down Expand Up @@ -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 `<verb> "<ams>"`). 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,
Expand Down
27 changes: 24 additions & 3 deletions Sources/imsg/Commands/BridgeMessagingCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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)
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion Sources/imsg/Commands/StatusCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ enum StatusCommand {
bridgeVersion: bridgeVersion,
v2Ready: v2Ready,
selectors: selectors,
rpcMethods: kSupportedRPCMethods
rpcMethods: kSupportedRPCMethods,
rpcFeatures: ["tapback.emoji"]
)
try JSONLines.print(payload)
} else {
Expand Down Expand Up @@ -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
Expand All @@ -166,5 +168,6 @@ private struct StatusPayload: Encodable {
case v2Ready = "v2_ready"
case selectors
case rpcMethods = "rpc_methods"
case rpcFeatures = "rpc_features"
}
}
24 changes: 19 additions & 5 deletions Sources/imsg/RPCServer+BridgeMessageHandlers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
1 change: 1 addition & 0 deletions Tests/imsgTests/LaunchStatusCommandTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions Tests/imsgTests/RPCBridgeMessageHandlersTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down