Skip to content

Commit 617d941

Browse files
omarshahineOmar Shahinesteipete
authored
feat(poll): cast votes on native Messages polls (#148)
* 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. * fix: harden native poll voting * feat: expose poll vote bridge capability --------- Co-authored-by: Omar Shahine <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent 6a20d3a commit 617d941

12 files changed

Lines changed: 571 additions & 23 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Send
66
- fix: thread attributed-text formatting through the RPC `send` bridge path, not just `send-rich`, so direct/handle sends render **bold**/*italic*/etc. on macOS 15+. `handleSend` now forwards format ranges to the bridge, accepting `formatting` (the key OpenClaw's `message` tool emits) alongside `text_formatting`/`textFormatting` (#143, thanks @omarshahine).
7+
- feat: add bridge-backed native poll voting through `imsg poll vote`, `poll.vote`, and `messages.poll.vote` (#148, thanks @omarshahine).
78

89
## 0.11.1 - 2026-06-10
910

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,12 +129,17 @@ Advanced IMCore (require `imsg launch` with SIP off — see
129129
`imsg send-multipart`, `imsg send-attachment [--reply-to <guid>]`,
130130
`imsg tapback`
131131
- `imsg poll send (--chat <guid> | --chat-id <id>) --question <text> --option <text> --option <text> [--reply-to <guid>]`
132+
- `imsg poll vote (--chat <guid> | --chat-id <id>) --poll <guid> (--option-id <id> | --option-index <n> | --option <text>)`
132133
- `imsg edit`, `imsg unsend`, `imsg delete-message`, `imsg notify-anyways`
133134
- `imsg chat-create`, `imsg chat-name`, `imsg chat-photo`,
134135
`imsg chat-add-member`, `imsg chat-remove-member`, `imsg chat-leave`,
135136
`imsg chat-delete`, `imsg chat-mark`
136137
- `imsg account`, `imsg whois`, `imsg nickname`
137138

139+
`imsg status --json` reports native bridge selector capabilities. Poll creation
140+
requires `selectors.pollPayloadMessage`; poll voting requires
141+
`selectors.pollVoteMessage` plus `poll.vote` in `rpc_methods`.
142+
138143
`react` intentionally sends only the standard tapbacks Messages.app exposes
139144
reliably through automation. Custom emoji tapbacks can be read from
140145
history/watch output, but are sent through the bridge `tapback` command.

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: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,18 @@ 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+
try decodedPollOptions(guid: target, db: db)
35+
}
36+
}
37+
2638
private func pollOptionTextsByID(
2739
pollGUID: String,
2840
db: Connection,
@@ -35,6 +47,21 @@ extension MessageStore {
3547
return [:]
3648
}
3749

50+
let options = try decodedPollOptions(guid: pollGUID, db: db)
51+
guard !options.isEmpty else {
52+
cache.missingPollGUIDs.insert(pollGUID)
53+
return [:]
54+
}
55+
56+
var optionTexts: [String: String] = [:]
57+
for option in options where optionTexts[option.id] == nil {
58+
optionTexts[option.id] = option.text
59+
}
60+
cache.optionsByPollGUID[pollGUID] = optionTexts
61+
return optionTexts
62+
}
63+
64+
private func decodedPollOptions(guid: String, db: Connection) throws -> [MessagePollOption] {
3865
let selection = MessageRowSelection(store: self, includeChatID: false)
3966
let sql = """
4067
SELECT \(selection.selectList)
@@ -43,26 +70,13 @@ extension MessageStore {
4370
WHERE m.guid = ?
4471
LIMIT 1
4572
"""
46-
let rows = try db.prepareRowIterator(sql, bindings: [pollGUID])
47-
guard let row = try rows.failableNext() else {
48-
cache.missingPollGUIDs.insert(pollGUID)
49-
return [:]
50-
}
73+
let rows = try db.prepareRowIterator(sql, bindings: [guid])
74+
guard let row = try rows.failableNext() else { return [] }
5175
let decoded = try decodeMessageRow(
5276
row,
5377
columns: selection.columns,
5478
fallbackChatID: nil
5579
)
56-
guard let options = decoded.poll?.options, !options.isEmpty else {
57-
cache.missingPollGUIDs.insert(pollGUID)
58-
return [:]
59-
}
60-
61-
var optionTexts: [String: String] = [:]
62-
for option in options where optionTexts[option.id] == nil {
63-
optionTexts[option.id] = option.text
64-
}
65-
cache.optionsByPollGUID[pollGUID] = optionTexts
66-
return optionTexts
80+
return decoded.poll?.options ?? []
6781
}
6882
}

Sources/IMsgHelper/IMsgInjected.m

Lines changed: 190 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ static BOOL ensureSecureDirectory(NSString *path, NSError **error) {
208208
static BOOL gHasSendMessageReason = NO; // sendMessage:reason:
209209

210210
static BOOL pollPayloadMessageInitializerAvailable(void);
211+
static BOOL pollVoteMessageInitializerAvailable(void);
211212

212213
static void probeSelectors(void) {
213214
Class chatClass = NSClassFromString(@"IMChat");
@@ -812,7 +813,8 @@ static id findChat(NSString *identifier) {
812813
@"editMessage": @(gHasEditMessage),
813814
@"retractMessagePart": @(gHasRetractMessagePart),
814815
@"sendMessageReason": @(gHasSendMessageReason),
815-
@"pollPayloadMessage": @(pollPayloadMessageInitializerAvailable())
816+
@"pollPayloadMessage": @(pollPayloadMessageInitializerAvailable()),
817+
@"pollVoteMessage": @(pollVoteMessageInitializerAvailable())
816818
};
817819

818820
return successResponse(requestId, @{
@@ -1639,6 +1641,12 @@ static BOOL pollPayloadMessageInitializerAvailable(void) {
16391641
return messageClass && [messageClass instancesRespondToSelector:sel];
16401642
}
16411643

1644+
static BOOL pollVoteMessageInitializerAvailable(void) {
1645+
Class messageClass = NSClassFromString(@"IMMessage");
1646+
SEL sel = @selector(initWithSender:time:text:messageSubject:fileTransferGUIDs:flags:error:guid:subject:associatedMessageGUID:associatedMessageType:associatedMessageRange:messageSummaryInfo:);
1647+
return messageClass && [messageClass instancesRespondToSelector:sel];
1648+
}
1649+
16421650
static NSString *trimmedPollString(id value) {
16431651
if (![value isKindOfClass:[NSString class]]) return nil;
16441652
NSString *trimmed = [(NSString *)value stringByTrimmingCharactersInSet:
@@ -2745,6 +2753,186 @@ static void appendEvent(NSDictionary *evt) {
27452753
}
27462754
}
27472755

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

0 commit comments

Comments
 (0)