@@ -2745,6 +2745,191 @@ static void appendEvent(NSDictionary *evt) {
27452745 }
27462746}
27472747
2748+ // / Build the vote payload: a data URL carrying the vote JSON, wrapped in the
2749+ // / same archived envelope as poll creation. Mirrors Apple's native vote shape
2750+ // / (verified against a real received vote): {"version":1,"item":{"votes":
2751+ // / [{"voteOptionIdentifier":...,"participantHandle":...}]}}. Unlike poll
2752+ // / creation the data URL carries no `?src=p&c=` suffix.
2753+ static NSData *buildPollVotePayloadData (NSString *optionIdentifier,
2754+ NSString *voterHandle,
2755+ NSString **outError) {
2756+ NSDictionary *vote = @{
2757+ @" voteOptionIdentifier" : optionIdentifier,
2758+ @" participantHandle" : voterHandle ?: @" "
2759+ };
2760+ NSDictionary *root = @{
2761+ @" version" : @1 ,
2762+ @" item" : @{ @" votes" : @[vote] }
2763+ };
2764+ NSError *jsonError = nil ;
2765+ NSData *jsonData = [NSJSONSerialization dataWithJSONObject: root options: 0 error: &jsonError];
2766+ if (!jsonData) {
2767+ if (outError) *outError = jsonError.localizedDescription ?: @" Could not encode vote payload" ;
2768+ return nil ;
2769+ }
2770+ NSString *encoded = [jsonData base64EncodedStringWithOptions: 0 ];
2771+ NSString *urlString = [NSString stringWithFormat: @" data:,%@ " , encoded];
2772+ NSURL *url = [NSURL URLWithString: urlString];
2773+ if (!url) {
2774+ if (outError) *outError = @" Could not create vote URL" ;
2775+ return nil ;
2776+ }
2777+ NSUUID *sessionIdentifier = [NSUUID UUID ];
2778+ NSError *archiveError = nil ;
2779+ NSData *payload = archivePollPayloadEnvelope (url, sessionIdentifier, &archiveError);
2780+ if (!payload && outError) {
2781+ *outError = archiveError.localizedDescription ?: @" Could not archive vote payload" ;
2782+ }
2783+ return payload;
2784+ }
2785+
2786+ // / Construct a poll-vote IMMessage: a Polls balloon carrying the vote payload,
2787+ // / associated to the original poll via a bare poll GUID + associatedMessageType
2788+ // / 4000. Native votes (verified against chat.db) use a bare poll GUID, not the
2789+ // / `p:<part>/<guid>` form tapbacks use.
2790+ // /
2791+ // / A vote is the one message that needs balloon + payload AND an associated
2792+ // / message atomically. The IMMessageItem-first path can't persist associated
2793+ // / fields through the IMMessage wrap (see buildIMMessage), and the macOS 26
2794+ // / 13-arg reaction initializer has no balloonBundleID/payloadData slots. The
2795+ // / legacy `initIMMessageWith…` 17-arg initializer is the only one exposing
2796+ // / balloon, payload, and association together, so votes use it directly — the
2797+ // / same family as the working poll-send balloon path.
2798+ static id buildPollVoteIMMessage (NSAttributedString *body,
2799+ NSData *payloadData,
2800+ NSDictionary *summaryInfo,
2801+ NSString *pollMessageGuid) {
2802+ Class messageClass = NSClassFromString (@" IMMessage" );
2803+ if (!messageClass) return nil ;
2804+
2805+ // No macOS 26 IMMessage initializer carries balloon + payload AND
2806+ // association together (verified by selector probe). Reactions prove the
2807+ // associated-message initializer persists association atomically, and
2808+ // balloon/payload set on the backing IMMessageItem persist (verified: an
2809+ // earlier item-first vote landed with the balloon intact, only the
2810+ // association — set via the wrap — was lost). So: init with association
2811+ // atomically, then stamp balloonBundleID + payloadData onto the message's
2812+ // own backing item (a real item from a direct init, not the transient one
2813+ // the messageFromIMMessageItem: wrap returns).
2814+ SEL sel = @selector (initWithSender:time:text:messageSubject:fileTransferGUIDs:flags:error:guid:subject:associatedMessageGUID:associatedMessageType:associatedMessageRange:messageSummaryInfo: );
2815+ if (![messageClass instancesRespondToSelector: sel]) return nil ;
2816+
2817+ id msg = [messageClass alloc ];
2818+ NSMethodSignature *sig = [messageClass instanceMethodSignatureForSelector: sel];
2819+ NSInvocation *inv = [NSInvocation invocationWithMethodSignature: sig];
2820+ [inv setSelector: sel];
2821+ [inv setTarget: msg];
2822+
2823+ id nilObj = nil ;
2824+ NSDate *now = [NSDate date ];
2825+ NSArray *fileTransferGuids = @[];
2826+ unsigned long long flags = flagsForAssociatedMessagePayload (nil , fileTransferGuids, NO );
2827+ long long associatedType = 4000 ;
2828+ NSRange associatedRange = NSMakeRange (0 , 1 );
2829+
2830+ [inv setArgument: &nilObj atIndex: 2 ]; // sender
2831+ [inv setArgument: &now atIndex: 3 ]; // time
2832+ [inv setArgument: &body atIndex: 4 ]; // text
2833+ [inv setArgument: &nilObj atIndex: 5 ]; // messageSubject
2834+ [inv setArgument: &fileTransferGuids atIndex: 6 ];
2835+ [inv setArgument: &flags atIndex: 7 ];
2836+ [inv setArgument: &nilObj atIndex: 8 ]; // error
2837+ [inv setArgument: &nilObj atIndex: 9 ]; // guid
2838+ [inv setArgument: &nilObj atIndex: 10 ]; // subject (string)
2839+ [inv setArgument: &pollMessageGuid atIndex: 11 ];
2840+ [inv setArgument: &associatedType atIndex: 12 ];
2841+ [inv setArgument: &associatedRange atIndex: 13 ];
2842+ [inv setArgument: &summaryInfo atIndex: 14 ];
2843+ [inv retainArguments ];
2844+ id result = invokeReturningObject (inv);
2845+ if (!result) return nil ;
2846+
2847+ // Stamp the balloon payload onto the message's backing item. Try the
2848+ // message directly first (some IMMessage builds forward these), then the
2849+ // underlying item.
2850+ NSString *balloonID = pollsBalloonBundleIdentifier ();
2851+ NSArray <id > *targets = @[result];
2852+ SEL itemSel = NSSelectorFromString (@" _imMessageItem" );
2853+ if ([result respondsToSelector: itemSel]) {
2854+ @try {
2855+ id item = [result performSelector: itemSel];
2856+ if (item) targets = @[item, result];
2857+ } @catch (__unused NSException *e) {}
2858+ }
2859+ for (id target in targets) {
2860+ if ([target respondsToSelector: @selector (setBalloonBundleID: )]) {
2861+ [target performSelector: @selector (setBalloonBundleID: ) withObject: balloonID];
2862+ }
2863+ if ([target respondsToSelector: @selector (setPayloadData: )]) {
2864+ [target performSelector: @selector (setPayloadData: ) withObject: payloadData];
2865+ }
2866+ }
2867+ return result;
2868+ }
2869+
2870+ // / `send-poll-vote`: cast a vote on an existing poll. Builds a Polls balloon
2871+ // / carrying the vote payload, associated to the poll message via type 4000.
2872+ // / The caller passes the original poll message GUID and the chosen option's
2873+ // / UUID (resolved CLI-side from the poll's decoded options).
2874+ static NSDictionary *handleSendPollVote (NSInteger requestId, NSDictionary *params) {
2875+ NSString *chatGuid = params[@" chatGuid" ];
2876+ NSString *pollMessageGuid = trimmedPollString (params[@" pollMessageGuid" ]);
2877+ NSString *optionIdentifier = trimmedPollString (params[@" optionIdentifier" ]);
2878+ NSString *optionText = trimmedPollString (params[@" optionText" ]);
2879+ NSString *voterHandle = trimmedPollString (params[@" voterHandle" ]);
2880+
2881+ if (!chatGuid.length ) return errorResponse (requestId, @" Missing chatGuid" );
2882+ if (!pollMessageGuid.length ) return errorResponse (requestId, @" Missing pollMessageGuid" );
2883+ if (!optionIdentifier.length ) return errorResponse (requestId, @" Missing optionIdentifier" );
2884+ if (!pollPayloadMessageInitializerAvailable ()) {
2885+ return errorResponse (requestId, @" Poll IMMessage initializer unavailable on this macOS" );
2886+ }
2887+
2888+ IMChat *chat = resolveChatByGuid (chatGuid);
2889+ if (!chat) {
2890+ return errorResponse (requestId,
2891+ [NSString stringWithFormat: @" Chat not found: %@ " , chatGuid]);
2892+ }
2893+
2894+ if (!voterHandle.length ) {
2895+ voterHandle = activeIMessageSenderHandle ();
2896+ }
2897+ if (!voterHandle.length ) {
2898+ return errorResponse (requestId,
2899+ @" Could not resolve active iMessage sender handle for vote payload" );
2900+ }
2901+
2902+ NSString *payloadError = nil ;
2903+ NSData *payloadData = buildPollVotePayloadData (optionIdentifier, voterHandle, &payloadError);
2904+ if (!payloadData) {
2905+ return errorResponse (requestId, payloadError ?: @" Could not build vote payload" );
2906+ }
2907+
2908+ NSDictionary *summary = @{ @" amc" : @0 , @" ust" : @YES };
2909+ NSAttributedString *body = buildPollBreadcrumbAttributed ();
2910+
2911+ @try {
2912+ clearThreadContextForChat (chat, nil );
2913+ id imMessage = buildPollVoteIMMessage (body, payloadData, summary, pollMessageGuid);
2914+ if (!imMessage) {
2915+ return errorResponse (requestId, @" Could not construct vote IMMessage" );
2916+ }
2917+ [chat performSelector: @selector (sendMessage: ) withObject: imMessage];
2918+ NSString *guid = lastSentMessageGuid (chat);
2919+ return successResponse (requestId, @{
2920+ @" chatGuid" : chatGuid,
2921+ @" messageGuid" : guid ?: @" " ,
2922+ @" pollMessageGuid" : pollMessageGuid,
2923+ @" optionIdentifier" : optionIdentifier,
2924+ @" optionText" : optionText ?: @" " ,
2925+ @" balloonBundleID" : pollsBalloonBundleIdentifier ()
2926+ });
2927+ } @catch (NSException *exception) {
2928+ return errorResponse (requestId,
2929+ [NSString stringWithFormat: @" send-poll-vote failed: %@ " , exception.reason]);
2930+ }
2931+ }
2932+
27482933// / `send-multipart`: at minimum, sends an attributedBody composed of multiple
27492934// / text parts. v1 supports text-only multipart; mention/file parts can land in
27502935// / a follow-up.
@@ -4061,6 +4246,7 @@ static void retargetPreparedTransfer(id ftc, IMFileTransfer *transfer,
40614246 if ([action isEqualToString: @" send-multipart" ]) return handleSendMultipart (legacyId, params);
40624247 if ([action isEqualToString: @" send-attachment" ]) return handleSendAttachment (legacyId, params);
40634248 if ([action isEqualToString: @" send-poll" ]) return handleSendPoll (legacyId, params);
4249+ if ([action isEqualToString: @" send-poll-vote" ]) return handleSendPollVote (legacyId, params);
40644250 if ([action isEqualToString: @" send-reaction" ]) return handleSendReaction (legacyId, params);
40654251 if ([action isEqualToString: @" notify-anyways" ]) return handleNotifyAnyways (legacyId, params);
40664252 if ([action isEqualToString: @" edit-message" ]) return handleEditMessage (legacyId, params);
0 commit comments