Skip to content

Commit 2ca0fad

Browse files
Omar Shahineomarshahine
authored andcommitted
feat(poll): add poll voting (cast a vote on a poll)
Adds `imsg poll vote` plus RPC `poll.vote` and the `send-poll-vote` bridge action, so the CLI can cast a vote on an existing native Messages poll. The vote is an associated message (associatedMessageType 4000, bare poll GUID) carrying the Polls balloon payload. Key detail: macOS 26 exposes no IMMessage initializer that carries a balloon payload AND an associated message together, so the vote is built with the associated-message initializer (the path reactions use, which persists the association) and the balloonBundleID/payloadData are then stamped onto the message's backing item. Option selection resolves a 1-based index or option text to the stable optionIdentifier via a new `MessageStore.pollOptions(guid:)`, and the resolved option label is returned so callers can suppress a redundant text reply that merely restates the vote.
1 parent 6a20d3a commit 2ca0fad

6 files changed

Lines changed: 408 additions & 5 deletions

File tree

Sources/IMsgCore/IMsgBridgeProtocol.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ public enum IMsgBridgeProtocol {
3131

3232
public static func defaultResponseTimeout(for action: BridgeAction) -> TimeInterval {
3333
switch action {
34-
case .sendMessage, .sendMultipart, .sendAttachment, .sendPoll, .sendReaction,
35-
.createChat:
34+
case .sendMessage, .sendMultipart, .sendAttachment, .sendPoll, .sendPollVote,
35+
.sendReaction, .createChat:
3636
return defaultSendResponseTimeout
3737
default:
3838
return defaultResponseTimeout
@@ -65,6 +65,7 @@ public enum BridgeAction: String, Sendable, CaseIterable {
6565
case sendMultipart = "send-multipart"
6666
case sendAttachment = "send-attachment"
6767
case sendPoll = "send-poll"
68+
case sendPollVote = "send-poll-vote"
6869
case sendReaction = "send-reaction"
6970
case notifyAnyways = "notify-anyways"
7071

Sources/IMsgCore/MessageStore+Polls.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,29 @@ extension MessageStore {
2323
return poll.resolvingVoteOptionTexts(optionTexts)
2424
}
2525

26+
/// Ordered options of the poll identified by `guid`, decoded from its
27+
/// creation message. Used by `poll vote` to resolve a 1-based option index
28+
/// or option text into the stable optionIdentifier the bridge needs.
29+
public func pollOptions(guid: String) throws -> [MessagePollOption] {
30+
let normalized = normalizeAssociatedGUID(guid)
31+
let target = normalized.isEmpty ? guid : normalized
32+
guard !target.isEmpty else { return [] }
33+
return try withConnection { db in
34+
let selection = MessageRowSelection(store: self, includeChatID: false)
35+
let sql = """
36+
SELECT \(selection.selectList)
37+
FROM message m
38+
LEFT JOIN handle h ON m.handle_id = h.ROWID
39+
WHERE m.guid = ?
40+
LIMIT 1
41+
"""
42+
let rows = try db.prepareRowIterator(sql, bindings: [target])
43+
guard let row = try rows.failableNext() else { return [] }
44+
let decoded = try decodeMessageRow(row, columns: selection.columns, fallbackChatID: nil)
45+
return decoded.poll?.options ?? []
46+
}
47+
}
48+
2649
private func pollOptionTextsByID(
2750
pollGUID: String,
2851
db: Connection,

Sources/IMsgHelper/IMsgInjected.m

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)