From ed7db8ca1c710bc72ebec484c5d8850e23c963e1 Mon Sep 17 00:00:00 2001 From: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:43:12 -0700 Subject: [PATCH 01/12] feat: add poll unvote and option updates --- .agents/skills/imsg/SKILL.md | 2 + CHANGELOG.md | 1 + Sources/IMsgCore/IMsgBridgeProtocol.swift | 4 +- Sources/IMsgHelper/IMsgInjected.m | 159 ++++++++++++++++-- Sources/imsg/Commands/PollCommand.swift | 72 +++++++- .../RPCServer+BridgeMessageHandlers.swift | 62 ++++++- Sources/imsg/RPCServer.swift | 10 ++ .../IMsgBridgeProtocolTests.swift | 5 + .../BridgeCommandRegistrationTests.swift | 8 + Tests/imsgTests/BridgeRichCommandTests.swift | 69 ++++++++ .../RPCBridgeMessageHandlersTests.swift | 67 ++++++++ docs/advanced-imcore.md | 2 + docs/rpc.md | 10 ++ 13 files changed, 449 insertions(+), 22 deletions(-) diff --git a/.agents/skills/imsg/SKILL.md b/.agents/skills/imsg/SKILL.md index c6491d6d..6e67c3b8 100644 --- a/.agents/skills/imsg/SKILL.md +++ b/.agents/skills/imsg/SKILL.md @@ -77,6 +77,8 @@ Only after `imsg status` confirms the bridge is loaded (`imsg launch` injects it ```bash imsg send-rich --chat 'iMessage;-;+15551234567' --text 'hi' --reply-to MSG_GUID # replies, effects, subjects imsg poll send --chat GUID --question 'Dinner?' --option 'Pizza' --option 'Sushi' --comment 'Vote by 5pm' +imsg poll unvote --chat GUID --poll POLL_GUID --option-index 1 +imsg poll add-option --chat GUID --poll POLL_GUID --option 'Tacos' imsg edit --chat GUID --message MSG_GUID --new-text 'updated' # macOS 13+ imsg chat-create --addresses '+15551234567,+15559876543' --name 'Crew' ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index ad98b064..ece3e949 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Native Polls - fix: match native poll vote envelopes, participant handles, and summary metadata so votes render participant markers and correct notifications (#162, thanks @omarshahine). +- feat: add bridge-backed native poll `unvote` and `add-option` actions, exposed through CLI and JSON-RPC. ## 0.12.3 - 2026-07-06 diff --git a/Sources/IMsgCore/IMsgBridgeProtocol.swift b/Sources/IMsgCore/IMsgBridgeProtocol.swift index 849906b7..3a43b411 100644 --- a/Sources/IMsgCore/IMsgBridgeProtocol.swift +++ b/Sources/IMsgCore/IMsgBridgeProtocol.swift @@ -32,7 +32,7 @@ public enum IMsgBridgeProtocol { public static func defaultResponseTimeout(for action: BridgeAction) -> TimeInterval { switch action { case .sendMessage, .sendMultipart, .sendAttachment, .sendPoll, .sendPollVote, - .sendReaction, .createChat: + .sendPollUnvote, .sendPollAddOption, .sendReaction, .createChat: return defaultSendResponseTimeout default: return defaultResponseTimeout @@ -66,6 +66,8 @@ public enum BridgeAction: String, Sendable, CaseIterable { case sendAttachment = "send-attachment" case sendPoll = "send-poll" case sendPollVote = "send-poll-vote" + case sendPollUnvote = "send-poll-unvote" + case sendPollAddOption = "send-poll-add-option" case sendReaction = "send-reaction" case notifyAnyways = "notify-anyways" diff --git a/Sources/IMsgHelper/IMsgInjected.m b/Sources/IMsgHelper/IMsgInjected.m index 80312309..7ea13e4c 100644 --- a/Sources/IMsgHelper/IMsgInjected.m +++ b/Sources/IMsgHelper/IMsgInjected.m @@ -815,6 +815,7 @@ static id findChat(NSString *identifier) { @"sendMessageReason": @(gHasSendMessageReason), @"pollPayloadMessage": @(pollPayloadMessageInitializerAvailable()), @"pollVoteMessage": @(pollVoteMessageInitializerAvailable()), + @"pollUpdateMessage": @(pollVoteMessageInitializerAvailable()), @"deleteChat": @(hasRegistry && [registryClass instancesRespondToSelector:NSSelectorFromString(@"deleteChat:")]), @"removeChat": @(hasRegistry && @@ -2796,11 +2797,16 @@ static void appendEvent(NSDictionary *evt) { /// creation the data URL carries no `?src=p&c=` suffix. static NSData *buildPollVotePayloadData(NSString *optionIdentifier, NSString *voterHandle, + BOOL removed, NSString **outError) { - NSDictionary *vote = @{ + NSMutableDictionary *vote = [NSMutableDictionary dictionaryWithDictionary:@{ @"voteOptionIdentifier": optionIdentifier, @"participantHandle": pollParticipantHandle(voterHandle) ?: @"" - }; + }]; + if (removed) { + vote[@"eventType"] = @"removed"; + vote[@"removed"] = @YES; + } NSDictionary *root = @{ @"version": @1, @"item": @{ @"votes": @[vote] } @@ -2831,6 +2837,54 @@ static void appendEvent(NSDictionary *evt) { return payload; } +static NSData *buildPollAddOptionPayloadData(NSString *optionText, + NSString *creatorHandle, + NSString **outOptionIdentifier, + NSString **outError) { + NSString *identifier = [[NSUUID UUID] UUIDString]; + NSMutableDictionary *option = [NSMutableDictionary dictionaryWithDictionary:@{ + @"canBeEdited": @NO, + @"attributedText": optionText ?: @"", + @"text": optionText ?: @"", + @"optionIdentifier": identifier + }]; + if (creatorHandle.length) { + option[@"creatorHandle"] = creatorHandle; + } + NSDictionary *root = @{ + @"version": @1, + @"item": @{ @"orderedPollOptions": @[option] } + }; + NSError *jsonError = nil; + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:root options:0 error:&jsonError]; + if (!jsonData) { + if (outError) *outError = jsonError.localizedDescription ?: @"Could not encode poll option payload"; + return nil; + } + if (jsonData.length > 4096) { + if (outError) *outError = @"Poll option payload exceeds 4096 bytes"; + return nil; + } + NSString *encoded = [jsonData base64EncodedStringWithOptions:0]; + NSString *urlString = [NSString stringWithFormat:@"data:,%@", encoded]; + NSURL *url = [NSURL URLWithString:urlString]; + if (!url) { + if (outError) *outError = @"Could not create poll option URL"; + return nil; + } + NSUUID *sessionIdentifier = [NSUUID UUID]; + NSError *archiveError = nil; + NSData *payload = archivePollPayloadEnvelope(url, sessionIdentifier, &archiveError); + if (!payload) { + if (outError) *outError = archiveError.localizedDescription ?: @"Could not archive poll option payload"; + return nil; + } + if (outOptionIdentifier) { + *outOptionIdentifier = identifier; + } + return payload; +} + /// Construct a poll-vote IMMessage: a Polls balloon carrying the vote payload, /// associated to the original poll via a bare poll GUID + associatedMessageType /// 4000. Native votes (verified against chat.db) use a bare poll GUID, not the @@ -2842,7 +2896,8 @@ static void appendEvent(NSDictionary *evt) { static id buildPollVoteIMMessage(NSAttributedString *body, NSData *payloadData, NSDictionary *summaryInfo, - NSString *pollMessageGuid) { + NSString *pollMessageGuid, + long long associatedType) { Class messageClass = NSClassFromString(@"IMMessage"); if (!messageClass) return nil; @@ -2862,7 +2917,6 @@ static id buildPollVoteIMMessage(NSAttributedString *body, NSDate *now = [NSDate date]; NSArray *fileTransferGuids = @[]; unsigned long long flags = flagsForAssociatedMessagePayload(nil, fileTransferGuids, NO); - long long associatedType = 4000; NSRange associatedRange = NSMakeRange(0, 1); [inv setArgument:&nilObj atIndex:2]; // sender @@ -2906,11 +2960,9 @@ static id buildPollVoteIMMessage(NSAttributedString *body, return (balloonStamped && payloadStamped) ? result : nil; } -/// `send-poll-vote`: cast a vote on an existing poll. Builds a Polls balloon -/// carrying the vote payload, associated to the poll message via type 4000. -/// The caller passes the original poll message GUID and the chosen option's -/// UUID (resolved CLI-side from the poll's decoded options). -static NSDictionary *handleSendPollVote(NSInteger requestId, NSDictionary *params) { +static NSDictionary *handleSendPollVoteMutation(NSInteger requestId, + NSDictionary *params, + BOOL removed) { NSString *chatGuid = params[@"chatGuid"]; NSString *pollMessageGuid = trimmedPollString(params[@"pollMessageGuid"]); NSString *optionIdentifier = trimmedPollString(params[@"optionIdentifier"]); @@ -2936,7 +2988,10 @@ static id buildPollVoteIMMessage(NSAttributedString *body, } NSString *payloadError = nil; - NSData *payloadData = buildPollVotePayloadData(optionIdentifier, voterHandle, &payloadError); + NSData *payloadData = buildPollVotePayloadData(optionIdentifier, + voterHandle, + removed, + &payloadError); if (!payloadData) { return errorResponse(requestId, payloadError ?: @"Could not build vote payload"); } @@ -2953,7 +3008,7 @@ static id buildPollVoteIMMessage(NSAttributedString *body, @try { clearThreadContextForChat(chat, nil); - id imMessage = buildPollVoteIMMessage(body, payloadData, summary, pollMessageGuid); + id imMessage = buildPollVoteIMMessage(body, payloadData, summary, pollMessageGuid, 4000); if (!imMessage) { return errorResponse(requestId, @"Could not construct vote IMMessage"); } @@ -2965,6 +3020,7 @@ static id buildPollVoteIMMessage(NSAttributedString *body, @"pollMessageGuid": pollMessageGuid, @"optionIdentifier": optionIdentifier, @"optionText": optionText ?: @"", + @"removed": @(removed), @"balloonBundleID": pollsBalloonBundleIdentifier() }); } @catch (NSException *exception) { @@ -2973,6 +3029,85 @@ static id buildPollVoteIMMessage(NSAttributedString *body, } } +/// `send-poll-vote`: cast a vote on an existing poll. Builds a Polls balloon +/// carrying the vote payload, associated to the poll message via type 4000. +/// The caller passes the original poll message GUID and the chosen option's +/// UUID (resolved CLI-side from the poll's decoded options). +static NSDictionary *handleSendPollVote(NSInteger requestId, NSDictionary *params) { + return handleSendPollVoteMutation(requestId, params, NO); +} + +/// `send-poll-unvote`: remove a vote on an existing poll by sending the same +/// native vote payload with Apple's removal fields populated. +static NSDictionary *handleSendPollUnvote(NSInteger requestId, NSDictionary *params) { + return handleSendPollVoteMutation(requestId, params, YES); +} + +/// `send-poll-add-option`: append a new choice to an existing poll. Native +/// add-choice rows use the poll update associated-message type (2) and carry +/// a Polls payload with only the new orderedPollOptions entry. +static NSDictionary *handleSendPollAddOption(NSInteger requestId, NSDictionary *params) { + NSString *chatGuid = params[@"chatGuid"]; + NSString *pollMessageGuid = trimmedPollString(params[@"pollMessageGuid"]); + NSString *optionText = trimmedPollString(params[@"optionText"]); + NSString *creatorHandle = trimmedPollString(params[@"creatorHandle"]); + + if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid"); + if (!pollMessageGuid.length) return errorResponse(requestId, @"Missing pollMessageGuid"); + if (!optionText.length) return errorResponse(requestId, @"Missing optionText"); + if (!pollVoteMessageInitializerAvailable()) { + return errorResponse(requestId, @"Poll update IMMessage initializer unavailable on this macOS"); + } + + IMChat *chat = resolveChatByGuid(chatGuid); + if (!chat) { + return errorResponse(requestId, + [NSString stringWithFormat:@"Chat not found: %@", chatGuid]); + } + + if (!creatorHandle.length) { + creatorHandle = activeIMessageSenderHandle(); + } + if (!creatorHandle.length) { + return errorResponse(requestId, + @"Could not resolve active iMessage sender handle for option payload"); + } + + NSString *optionIdentifier = nil; + NSString *payloadError = nil; + NSData *payloadData = buildPollAddOptionPayloadData(optionText, + creatorHandle, + &optionIdentifier, + &payloadError); + if (!payloadData) { + return errorResponse(requestId, payloadError ?: @"Could not build poll option payload"); + } + + NSDictionary *summary = @{ @"amc": @0, @"ust": @YES }; + NSAttributedString *body = buildPollBreadcrumbAttributed(); + + @try { + clearThreadContextForChat(chat, nil); + id imMessage = buildPollVoteIMMessage(body, payloadData, summary, pollMessageGuid, 2); + if (!imMessage) { + return errorResponse(requestId, @"Could not construct poll option IMMessage"); + } + [chat performSelector:@selector(sendMessage:) withObject:imMessage]; + NSString *guid = lastSentMessageGuid(chat); + return successResponse(requestId, @{ + @"chatGuid": chatGuid, + @"messageGuid": guid ?: @"", + @"pollMessageGuid": pollMessageGuid, + @"optionIdentifier": optionIdentifier ?: @"", + @"optionText": optionText ?: @"", + @"balloonBundleID": pollsBalloonBundleIdentifier() + }); + } @catch (NSException *exception) { + return errorResponse(requestId, + [NSString stringWithFormat:@"send-poll-add-option failed: %@", exception.reason]); + } +} + /// `send-multipart`: at minimum, sends an attributedBody composed of multiple /// text parts. v1 supports text-only multipart; mention/file parts can land in /// a follow-up. @@ -4304,6 +4439,8 @@ static void retargetPreparedTransfer(id ftc, IMFileTransfer *transfer, if ([action isEqualToString:@"send-attachment"]) return handleSendAttachment(legacyId, params); if ([action isEqualToString:@"send-poll"]) return handleSendPoll(legacyId, params); if ([action isEqualToString:@"send-poll-vote"]) return handleSendPollVote(legacyId, params); + if ([action isEqualToString:@"send-poll-unvote"]) return handleSendPollUnvote(legacyId, params); + if ([action isEqualToString:@"send-poll-add-option"]) return handleSendPollAddOption(legacyId, params); if ([action isEqualToString:@"send-reaction"]) return handleSendReaction(legacyId, params); if ([action isEqualToString:@"notify-anyways"]) return handleNotifyAnyways(legacyId, params); if ([action isEqualToString:@"edit-message"]) return handleEditMessage(legacyId, params); diff --git a/Sources/imsg/Commands/PollCommand.swift b/Sources/imsg/Commands/PollCommand.swift index dcdb8d3b..b38d0ae8 100644 --- a/Sources/imsg/Commands/PollCommand.swift +++ b/Sources/imsg/Commands/PollCommand.swift @@ -8,8 +8,8 @@ enum PollCommand { abstract: "Send a native Apple Messages poll", discussion: """ Requires `imsg launch` (SIP-disabled, dylib injected). Use the `send` - action to create a native Messages Polls extension balloon, or the `vote` - action to cast a vote on an existing poll. + action to create a native Messages Polls extension balloon, `vote`/`unvote` + to select or remove a selection, or `add-option` to append a choice. Messages renders only the options on a poll balloon — the poll title is never shown to recipients. So `send` automatically sends `--question` as a @@ -22,7 +22,7 @@ enum PollCommand { signature: CommandSignatures.withRuntimeFlags( CommandSignature( arguments: [ - .make(label: "action", help: "send|vote", isOptional: false) + .make(label: "action", help: "send|vote|unvote|add-option", isOptional: false) ], options: CommandSignatures.baseOptions() + [ .make(label: "chat", names: [.long("chat")], help: "chat guid or rowid"), @@ -46,13 +46,13 @@ enum PollCommand { help: "poll option text; pass at least twice"), .make( label: "poll", names: [.long("poll")], - help: "vote: guid of the poll message to vote on"), + help: "vote/unvote/add-option: guid of the poll message to update"), .make( label: "optionID", names: [.long("option-id")], - help: "vote: UUID of the option to select"), + help: "vote/unvote: UUID of the option to select"), .make( label: "optionIndex", names: [.long("option-index")], - help: "vote: 1-based option number to select"), + help: "vote/unvote: 1-based option number to select"), ] ) ), @@ -61,6 +61,8 @@ enum PollCommand { "imsg poll send --chat 'iMessage;-;+15551234567' --question 'Dinner?' --comment 'Vote by 5pm 🍽️' --option 'Pizza' --option 'Sushi'", "imsg poll send --chat 'iMessage;-;+15551234567' --reply-to ABCD --question 'Approve?' --option 'Yes' --option 'No'", "imsg poll vote --chat 'iMessage;-;+15551234567' --poll ABCD --option-id 1B2C-...", + "imsg poll unvote --chat 'iMessage;-;+15551234567' --poll ABCD --option-index 1", + "imsg poll add-option --chat 'iMessage;-;+15551234567' --poll ABCD --option 'Tacos'", ] ) { values, runtime in try await run(values: values, runtime: runtime) @@ -81,6 +83,14 @@ enum PollCommand { values: values, runtime: runtime, storeFactory: storeFactory, invokeBridge: invokeBridge) case "vote": try await runVote( + remove: false, + values: values, runtime: runtime, storeFactory: storeFactory, invokeBridge: invokeBridge) + case "unvote": + try await runVote( + remove: true, + values: values, runtime: runtime, storeFactory: storeFactory, invokeBridge: invokeBridge) + case "add-option": + try await runAddOption( values: values, runtime: runtime, storeFactory: storeFactory, invokeBridge: invokeBridge) default: throw ParsedValuesError.invalidOption("action") @@ -155,6 +165,7 @@ enum PollCommand { } private static func runVote( + remove: Bool, values: ParsedValues, runtime: RuntimeOptions, storeFactory: @escaping (String) throws -> MessageStore, @@ -183,7 +194,8 @@ enum PollCommand { } do { - let data = try await invokeBridge(.sendPollVote, params) + let action: BridgeAction = remove ? .sendPollUnvote : .sendPollVote + let data = try await invokeBridge(action, params) // Merge the CLI-resolved option label into the emitted result so callers // get it regardless of the injected dylib version (older bridges don't // echo optionText). OpenClaw's echo guard reads this to drop a redundant @@ -197,7 +209,51 @@ enum PollCommand { let guid = (merged["messageGuid"] as? String) ?? "" BridgeOutput.emit( merged, runtime: runtime, - summary: guid.isEmpty ? "vote: queued" : "vote: sent (guid=\(guid))") + summary: guid.isEmpty + ? "\(remove ? "unvote" : "vote"): queued" + : "\(remove ? "unvote" : "vote"): sent (guid=\(guid))") + } catch { + BridgeOutput.emitError(String(describing: error), runtime: runtime) + throw BridgeOutput.EmittedError() + } + } + + private static func runAddOption( + values: ParsedValues, + runtime: RuntimeOptions, + storeFactory: @escaping (String) throws -> MessageStore, + invokeBridge: @escaping (BridgeAction, [String: Any]) async throws -> [String: Any] + ) async throws { + let chat = try resolveChatGUID(values: values, storeFactory: storeFactory) + guard let pollGuid = values.option("poll")?.trimmingCharacters(in: .whitespacesAndNewlines), + !pollGuid.isEmpty + else { + throw ParsedValuesError.missingOption("poll") + } + guard let option = values.option("option")?.trimmingCharacters(in: .whitespacesAndNewlines), + !option.isEmpty + else { + throw ParsedValuesError.missingOption("option") + } + let dbPath = values.option("db") ?? MessageStore.defaultPath + let store = try storeFactory(dbPath) + let options = try store.pollOptions(guid: pollGuid) + guard !options.isEmpty else { + throw ParsedValuesError.invalidOption("poll (could not decode options for \(pollGuid))") + } + + let params: [String: Any] = [ + "chatGuid": chat, + "pollMessageGuid": barePollGuid(pollGuid), + "optionText": option, + ] + + do { + let data = try await invokeBridge(.sendPollAddOption, params) + let guid = (data["messageGuid"] as? String) ?? "" + BridgeOutput.emit( + data, runtime: runtime, + summary: guid.isEmpty ? "poll option: queued" : "poll option: sent (guid=\(guid))") } catch { BridgeOutput.emitError(String(describing: error), runtime: runtime) throw BridgeOutput.EmittedError() diff --git a/Sources/imsg/RPCServer+BridgeMessageHandlers.swift b/Sources/imsg/RPCServer+BridgeMessageHandlers.swift index 5451a187..5d39bf13 100644 --- a/Sources/imsg/RPCServer+BridgeMessageHandlers.swift +++ b/Sources/imsg/RPCServer+BridgeMessageHandlers.swift @@ -148,6 +148,62 @@ extension RPCServer { } func handlePollVote(params: [String: Any], id: Any?) async throws { + try await handlePollVoteMutation(params: params, id: id, remove: false) + } + + func handlePollUnvote(params: [String: Any], id: Any?) async throws { + try await handlePollVoteMutation(params: params, id: id, remove: true) + } + + func handlePollAddOption(params: [String: Any], id: Any?) async throws { + let chatGUID = try await resolveChatGUIDParam(params) + guard + let pollGUID = stringParam( + params["poll_guid"] ?? params["pollGuid"] ?? params["poll_message_guid"] + ?? params["message_guid"] ?? params["message_id"]), !pollGUID.isEmpty + else { + throw RPCError.invalidParams("poll_guid is required") + } + guard + let optionText = stringParam( + params["option"] ?? params["option_text"] ?? params["optionText"]), + !optionText.isEmpty + else { + throw RPCError.invalidParams("option is required") + } + let pollOptions = try store.pollOptions(guid: pollGUID) + guard !pollOptions.isEmpty else { + throw RPCError.invalidParams("poll \(pollGUID) not found or not decodable") + } + var bridgeParams: [String: Any] = [ + "chatGuid": chatGUID, + "pollMessageGuid": barePollGuid(pollGUID), + "optionText": optionText, + ] + if let creatorHandle = stringParam(params["creator_handle"] ?? params["creatorHandle"]), + !creatorHandle.isEmpty + { + bridgeParams["creatorHandle"] = creatorHandle + } + + let data = try await invokeBridge(action: .sendPollAddOption, params: bridgeParams) + var result: [String: Any] = [ + "ok": true, + "event": "imessage.poll.option_added", + "poll_guid": barePollGuid(pollGUID), + "option_text": optionText, + ] + if let optionID = data["optionIdentifier"] as? String, !optionID.isEmpty { + result["option_id"] = optionID + } + if let guid = data["messageGuid"] as? String, !guid.isEmpty { + result["guid"] = guid + result["message_id"] = guid + } + respond(id: id, result: result) + } + + private func handlePollVoteMutation(params: [String: Any], id: Any?, remove: Bool) async throws { let chatGUID = try await resolveChatGUIDParam(params) guard let pollGUID = stringParam( @@ -182,10 +238,12 @@ extension RPCServer { bridgeParams["optionText"] = matchedOption.text } - let data = try await invokeBridge(action: .sendPollVote, params: bridgeParams) + let data = try await invokeBridge( + action: remove ? .sendPollUnvote : .sendPollVote, + params: bridgeParams) var result: [String: Any] = [ "ok": true, - "event": "imessage.poll.voted", + "event": remove ? "imessage.poll.unvoted" : "imessage.poll.voted", // Callers use the resolved option to suppress a redundant text reply that // just restates the vote, so return it alongside the poll linkage. "poll_guid": barePollGuid(pollGUID), diff --git a/Sources/imsg/RPCServer.swift b/Sources/imsg/RPCServer.swift index a2d053b7..f55f726d 100644 --- a/Sources/imsg/RPCServer.swift +++ b/Sources/imsg/RPCServer.swift @@ -42,6 +42,12 @@ let kSupportedRPCMethods: [String] = [ "messages.poll.send", "poll.vote", "messages.poll.vote", + "poll.unvote", + "polls.unvote", + "messages.poll.unvote", + "poll.addOption", + "polls.addOption", + "messages.poll.addOption", "tapback", "typing", "read", @@ -164,6 +170,10 @@ final class RPCServer { try await handlePollSend(params: params, id: id) case "poll.vote", "messages.poll.vote": try await handlePollVote(params: params, id: id) + case "poll.unvote", "polls.unvote", "messages.poll.unvote": + try await handlePollUnvote(params: params, id: id) + case "poll.addOption", "polls.addOption", "messages.poll.addOption": + try await handlePollAddOption(params: params, id: id) case "tapback": try await handleTapback(params: params, id: id) case "typing": diff --git a/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift b/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift index 3c8f064f..fff36b87 100644 --- a/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift +++ b/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift @@ -9,6 +9,8 @@ struct IMsgBridgeProtocolTests { func actionRawValuesMatchDylibVocabulary() { #expect(BridgeAction.sendMessage.rawValue == "send-message") #expect(BridgeAction.sendPoll.rawValue == "send-poll") + #expect(BridgeAction.sendPollUnvote.rawValue == "send-poll-unvote") + #expect(BridgeAction.sendPollAddOption.rawValue == "send-poll-add-option") #expect(BridgeAction.sendReaction.rawValue == "send-reaction") #expect(BridgeAction.editMessage.rawValue == "edit-message") #expect(BridgeAction.unsendMessage.rawValue == "unsend-message") @@ -94,6 +96,9 @@ struct IMsgBridgeProtocolTests { .sendMultipart, .sendAttachment, .sendPoll, + .sendPollVote, + .sendPollUnvote, + .sendPollAddOption, .sendReaction, .createChat, ] { diff --git a/Tests/imsgTests/BridgeCommandRegistrationTests.swift b/Tests/imsgTests/BridgeCommandRegistrationTests.swift index c4920e15..0b907759 100644 --- a/Tests/imsgTests/BridgeCommandRegistrationTests.swift +++ b/Tests/imsgTests/BridgeCommandRegistrationTests.swift @@ -163,7 +163,10 @@ func injectedHelperBroadcastsFailClosedNativePollVoteMetadata() throws { let sendVoteBody = try #require(functionBody(named: "handleSendPollVote", in: source)) #expect(source.contains("send-poll-vote")) + #expect(source.contains("send-poll-unvote")) + #expect(source.contains("send-poll-add-option")) #expect(source.contains("Poll vote payload exceeds 4096 bytes")) + #expect(source.contains("Poll option payload exceeds 4096 bytes")) #expect(source.contains(#"@"pollVoteMessage": @(pollVoteMessageInitializerAvailable())"#)) #expect(sendVoteBody.contains("pollVoteMessageInitializerAvailable()")) #expect(!sendVoteBody.contains("pollPayloadMessageInitializerAvailable()")) @@ -171,6 +174,11 @@ func injectedHelperBroadcastsFailClosedNativePollVoteMetadata() throws { #expect(source.contains("pollParticipantHandle(voterHandle)")) #expect(source.contains(#"@"ams": @"Sent a vote""#)) #expect(source.contains(#"@"amb": pollsBalloonBundleIdentifier()"#)) + #expect(source.contains(#"vote[@"eventType"] = @"removed""#)) + #expect(source.contains(#"vote[@"removed"] = @YES"#)) + #expect(source.contains("handleSendPollAddOption")) + #expect(source.contains("buildPollAddOptionPayloadData")) + #expect(source.contains(#""send-poll-add-option"]"#)) #expect(voteBody.contains("associatedMessageType")) #expect(voteBody.contains("BOOL balloonStamped = NO;")) #expect(voteBody.contains("BOOL payloadStamped = NO;")) diff --git a/Tests/imsgTests/BridgeRichCommandTests.swift b/Tests/imsgTests/BridgeRichCommandTests.swift index 9ebd886b..09a27c61 100644 --- a/Tests/imsgTests/BridgeRichCommandTests.swift +++ b/Tests/imsgTests/BridgeRichCommandTests.swift @@ -278,6 +278,75 @@ func pollCommandVoteResolvesOptionIndex() async throws { #expect(result["optionText"] as? String == "No") } +@Test +func pollCommandUnvoteResolvesOptionText() async throws { + let values = ParsedValues( + positional: ["unvote"], + options: [ + "chatID": ["1"], + "poll": ["p:0/poll-guid-6"], + "option": ["Yes"], + ], + flags: ["jsonOutput"] + ) + let runtime = RuntimeOptions(parsedValues: values) + let store = try CommandTestDatabase.makeStoreForRPCWithPollVote() + var capturedAction: BridgeAction? + var capturedParams: [String: Any] = [:] + + _ = try await StdoutCapture.capture { + try await PollCommand.run( + values: values, + runtime: runtime, + storeFactory: { _ in store }, + invokeBridge: { action, params in + capturedAction = action + capturedParams = params + return ["messageGuid": "unvote-guid"] + } + ) + } + + #expect(capturedAction == .sendPollUnvote) + #expect(capturedParams["pollMessageGuid"] as? String == "poll-guid-6") + #expect(capturedParams["optionIdentifier"] as? String == "choice-yes") + #expect(capturedParams["optionText"] as? String == "Yes") +} + +@Test +func pollCommandAddOptionSendsUpdatePayload() async throws { + let values = ParsedValues( + positional: ["add-option"], + options: [ + "chatID": ["1"], + "poll": ["p:0/poll-guid-6"], + "option": ["Tacos"], + ], + flags: ["jsonOutput"] + ) + let runtime = RuntimeOptions(parsedValues: values) + let store = try CommandTestDatabase.makeStoreForRPCWithPollVote() + var capturedAction: BridgeAction? + var capturedParams: [String: Any] = [:] + + _ = try await StdoutCapture.capture { + try await PollCommand.run( + values: values, + runtime: runtime, + storeFactory: { _ in store }, + invokeBridge: { action, params in + capturedAction = action + capturedParams = params + return ["messageGuid": "add-guid", "optionIdentifier": "choice-tacos"] + } + ) + } + + #expect(capturedAction == .sendPollAddOption) + #expect(capturedParams["pollMessageGuid"] as? String == "poll-guid-6") + #expect(capturedParams["optionText"] as? String == "Tacos") +} + @Test func pollCommandVoteRejectsConflictingSelectors() async throws { let values = ParsedValues( diff --git a/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift b/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift index 3517fe8b..7c7fd592 100644 --- a/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift +++ b/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift @@ -15,6 +15,8 @@ func rpcStatusAdvertisesBridgeMessageMethods() { "messages.poll.send", "poll.vote", "messages.poll.vote", + "polls.unvote", + "polls.addOption", "tapback", "message.edit", "message.unsend", @@ -26,6 +28,71 @@ func rpcStatusAdvertisesBridgeMessageMethods() { } } +@Test +func rpcPollUnvoteValidatesAndResolvesOption() async throws { + let store = try CommandTestDatabase.makeStoreForRPCWithPollVote() + 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 ["messageGuid": "unvote-guid"] + } + ) + + let request = + #"{"jsonrpc":"2.0","id":"unvote","method":"polls.unvote","params":{"chat_id":1,"# + + #""poll_guid":"p:0/poll-guid-6","option_id":"choice-yes"}}"# + await server.handleLineForTesting(request) + + #expect(capturedAction == .sendPollUnvote) + #expect(capturedParams["chatGuid"] as? String == "iMessage;+;chat123") + #expect(capturedParams["pollMessageGuid"] as? String == "poll-guid-6") + #expect(capturedParams["optionIdentifier"] as? String == "choice-yes") + #expect(capturedParams["optionText"] as? String == "Yes") + let result = output.responses.first?["result"] as? [String: Any] + #expect(result?["event"] as? String == "imessage.poll.unvoted") + #expect(result?["option_text"] as? String == "Yes") + #expect(result?["message_id"] as? String == "unvote-guid") +} + +@Test +func rpcPollAddOptionInvokesBridgeWithValidatedPoll() async throws { + let store = try CommandTestDatabase.makeStoreForRPCWithPollVote() + 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 ["messageGuid": "add-guid", "optionIdentifier": "choice-tacos"] + } + ) + + let request = + #"{"jsonrpc":"2.0","id":"add","method":"polls.addOption","params":{"chat_id":1,"# + + #""poll_guid":"p:0/poll-guid-6","option":"Tacos"}}"# + await server.handleLineForTesting(request) + + #expect(capturedAction == .sendPollAddOption) + #expect(capturedParams["chatGuid"] as? String == "iMessage;+;chat123") + #expect(capturedParams["pollMessageGuid"] as? String == "poll-guid-6") + #expect(capturedParams["optionText"] as? String == "Tacos") + let result = output.responses.first?["result"] as? [String: Any] + #expect(result?["event"] as? String == "imessage.poll.option_added") + #expect(result?["option_text"] as? String == "Tacos") + #expect(result?["option_id"] as? String == "choice-tacos") +} + @Test func rpcPollVoteValidatesAndResolvesOption() async throws { let store = try CommandTestDatabase.makeStoreForRPCWithPollVote() diff --git a/docs/advanced-imcore.md b/docs/advanced-imcore.md index 2067132b..569c87d9 100644 --- a/docs/advanced-imcore.md +++ b/docs/advanced-imcore.md @@ -20,6 +20,8 @@ You almost certainly do not need any of this for normal use. - `imsg send-attachment --chat --file [--reply-to ]` — prefers the bridge for private attachment sends, with AppleScript fallback for normal files when no reply target is requested. +- `imsg poll send|vote|unvote|add-option ...` — create native Polls balloons, + cast or remove selections, and append choices. ## Why they're separate diff --git a/docs/rpc.md b/docs/rpc.md index 39cc5d8c..3b1ab4c0 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -252,6 +252,16 @@ Response: {"ok":true,"event":"imessage.poll.created","guid":"...","message_id":"...","poll":{"kind":"created","event":"imessage.poll.created","question":"Dinner?","options":[{"id":"...","text":"Pizza"},{"id":"...","text":"Sushi"}]}} ``` +`poll.vote` casts a native vote after validating the poll and option against local history. +`polls.unvote` removes a selection with the same poll/option parameters, and +`polls.addOption` appends a new choice: + +```json +{"jsonrpc":"2.0","id":"vote","method":"poll.vote","params":{"chat_id":42,"poll_guid":"POLL-GUID","option_id":"OPTION-UUID"}} +{"jsonrpc":"2.0","id":"unvote","method":"polls.unvote","params":{"chat_id":42,"poll_guid":"POLL-GUID","option_id":"OPTION-UUID"}} +{"jsonrpc":"2.0","id":"add","method":"polls.addOption","params":{"chat_id":42,"poll_guid":"POLL-GUID","option":"Tacos"}} +``` + `messages.poll.send` is accepted as an alias for `poll.send`. The caption echo is deliberately best-effort: if the poll is created but the follow-up caption send fails, the RPC still returns the poll result to avoid retrying and creating a duplicate poll. ## Objects From 6c98cdb4f9fe2329c89c0f8e8c8c988913022a2b Mon Sep 17 00:00:00 2001 From: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:29:09 -0700 Subject: [PATCH 02/12] fix: render poll options in Messages --- Sources/IMsgHelper/IMsgInjected.m | 5 ++++- Tests/imsgTests/BridgeCommandRegistrationTests.swift | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/IMsgHelper/IMsgInjected.m b/Sources/IMsgHelper/IMsgInjected.m index 7ea13e4c..8be1a80c 100644 --- a/Sources/IMsgHelper/IMsgInjected.m +++ b/Sources/IMsgHelper/IMsgInjected.m @@ -1888,8 +1888,11 @@ static BOOL pollVoteMessageInitializerAvailable(void) { [pollOptions addObject:option]; } + // Native Polls payloads keep item.title empty and render the question via + // the separate "comment or Send" caption row. Supplying a non-empty title + // can make Messages recognize the extension but collapse the options UI. NSMutableDictionary *item = [NSMutableDictionary dictionaryWithDictionary:@{ - @"title": question ?: @"", + @"title": @"", @"orderedPollOptions": pollOptions }]; if (creatorHandle.length) { diff --git a/Tests/imsgTests/BridgeCommandRegistrationTests.swift b/Tests/imsgTests/BridgeCommandRegistrationTests.swift index 0b907759..e7ffb282 100644 --- a/Tests/imsgTests/BridgeCommandRegistrationTests.swift +++ b/Tests/imsgTests/BridgeCommandRegistrationTests.swift @@ -128,6 +128,7 @@ func injectedHelperWiresNativePollSend() throws { #expect(sendPollBody.contains("buildPollCreationPayloadData")) #expect(sendPollBody.contains("buildPollIMMessage")) #expect(sendPollBody.contains("pollPayloadMessageInitializerAvailable()")) + #expect(source.contains(#""title": @"""#)) #expect(!sendPollBody.contains(#"selectedMessageGuid.length ? @"" : question"#)) #expect(sendPollBody.contains("buildPollCreationPayloadData(question,")) #expect(sendPollBody.contains(#"@{ @"enc": @YES, @"ust": @YES }"#)) From 80c3f0cb012e3b7ef3701825846546f03710a1f5 Mon Sep 17 00:00:00 2001 From: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:06:54 -0700 Subject: [PATCH 03/12] fix: match native poll vote updates --- Sources/IMsgCore/MessagePolls.swift | 15 ++++++++++- Sources/IMsgHelper/IMsgInjected.m | 15 +++++------ Tests/IMsgCoreTests/MessagePollTests.swift | 27 +++++++++++++++++++ .../BridgeCommandRegistrationTests.swift | 5 ++-- 4 files changed, 51 insertions(+), 11 deletions(-) diff --git a/Sources/IMsgCore/MessagePolls.swift b/Sources/IMsgCore/MessagePolls.swift index bb1b0405..da53d8b1 100644 --- a/Sources/IMsgCore/MessagePolls.swift +++ b/Sources/IMsgCore/MessagePolls.swift @@ -208,7 +208,7 @@ public enum MessagePollDecoder { if isVoteAssociation || !votes.isEmpty { let pollGUID = firstNonEmpty(facts.pollGUID, originalGUID, messageGUID) return MessagePollEvent( - kind: votes.isEmpty ? .unknown : .vote, + kind: votes.isEmpty && !facts.hasEmptyVotes ? .unknown : .vote, pollGUID: pollGUID, question: facts.question, options: facts.options, @@ -351,6 +351,7 @@ private struct PollFacts { var question: String? var options: [MessagePollOption] = [] var votes: [MessagePollVote] = [] + var hasEmptyVotes = false var pollGUID: String? var creator: String? var participants: [String] = [] @@ -363,6 +364,7 @@ private struct PollFacts { self.question = state.question self.options = state.options self.votes = state.votes + self.hasEmptyVotes = state.hasEmptyVotes self.pollGUID = state.pollGUID self.creator = state.creator self.participants = state.participants @@ -415,6 +417,8 @@ private struct PollFacts { if !parsedVotes.isEmpty { state.appendVotes(parsedVotes) state.participants.append(contentsOf: parsedVotes.compactMap { $0.participant }) + } else if hasEmptyVotesArray(in: dict) { + state.hasEmptyVotes = true } for child in dict.values { @@ -483,6 +487,14 @@ private struct PollFacts { return [] } + private static func hasEmptyVotesArray(in dict: [String: Any]) -> Bool { + for key in ["votes", "pollVotes", "responses"] { + guard let array = arrayValue(dict[key]) else { continue } + if array.isEmpty { return true } + } + return false + } + private static func vote(from value: Any) -> MessagePollVote? { guard let dict = stringDictionary(value) else { return nil } guard @@ -562,6 +574,7 @@ private struct PollFactsState { var question: String? var options: [MessagePollOption] = [] var votes: [MessagePollVote] = [] + var hasEmptyVotes = false var pollGUID: String? var creator: String? var participants: [String] = [] diff --git a/Sources/IMsgHelper/IMsgInjected.m b/Sources/IMsgHelper/IMsgInjected.m index 8be1a80c..b7206d38 100644 --- a/Sources/IMsgHelper/IMsgInjected.m +++ b/Sources/IMsgHelper/IMsgInjected.m @@ -2802,17 +2802,16 @@ static void appendEvent(NSDictionary *evt) { NSString *voterHandle, BOOL removed, NSString **outError) { - NSMutableDictionary *vote = [NSMutableDictionary dictionaryWithDictionary:@{ - @"voteOptionIdentifier": optionIdentifier, - @"participantHandle": pollParticipantHandle(voterHandle) ?: @"" - }]; - if (removed) { - vote[@"eventType"] = @"removed"; - vote[@"removed"] = @YES; + NSArray *votes = @[]; + if (!removed) { + votes = @[@{ + @"voteOptionIdentifier": optionIdentifier, + @"participantHandle": pollParticipantHandle(voterHandle) ?: @"" + }]; } NSDictionary *root = @{ @"version": @1, - @"item": @{ @"votes": @[vote] } + @"item": @{ @"votes": votes } }; NSError *jsonError = nil; NSData *jsonData = [NSJSONSerialization dataWithJSONObject:root options:0 error:&jsonError]; diff --git a/Tests/IMsgCoreTests/MessagePollTests.swift b/Tests/IMsgCoreTests/MessagePollTests.swift index 183cf67e..7243a956 100644 --- a/Tests/IMsgCoreTests/MessagePollTests.swift +++ b/Tests/IMsgCoreTests/MessagePollTests.swift @@ -266,6 +266,33 @@ func decodesPollVotePayloadFromAppleDataURLEnvelope() throws { )) } +@Test +func decodesPollUnvotePayloadFromEmptyVotesArray() throws { + let response: [String: Any] = [ + "item": [ + "votes": [] + ], + "version": 1, + ] + let payload = try applePollEnvelopePayload(jsonObject: response) + + let poll = MessagePollDecoder.decode( + balloonBundleID: testPollBundleID, + payloadData: payload, + messageSummaryInfo: Data(), + associatedMessageType: 4000, + associatedMessageGUID: "original-poll-guid", + messageGUID: "vote-row-guid", + sender: "+15550002000" + ) + + #expect(poll?.kind == .vote) + #expect(poll?.event == "imessage.poll.voted") + #expect(poll?.pollGUID == "original-poll-guid") + #expect(poll?.vote == nil) + #expect(poll?.votes == []) +} + @Test func malformedPollPayloadEmitsUnknownWithoutRawPayload() throws { let poll = MessagePollDecoder.decode( diff --git a/Tests/imsgTests/BridgeCommandRegistrationTests.swift b/Tests/imsgTests/BridgeCommandRegistrationTests.swift index e7ffb282..ae8abfa5 100644 --- a/Tests/imsgTests/BridgeCommandRegistrationTests.swift +++ b/Tests/imsgTests/BridgeCommandRegistrationTests.swift @@ -173,10 +173,11 @@ func injectedHelperBroadcastsFailClosedNativePollVoteMetadata() throws { #expect(!sendVoteBody.contains("pollPayloadMessageInitializerAvailable()")) #expect(source.contains("archivePollMutationEnvelope")) #expect(source.contains("pollParticipantHandle(voterHandle)")) + #expect(source.contains(#"votes = @[]"#)) #expect(source.contains(#"@"ams": @"Sent a vote""#)) #expect(source.contains(#"@"amb": pollsBalloonBundleIdentifier()"#)) - #expect(source.contains(#"vote[@"eventType"] = @"removed""#)) - #expect(source.contains(#"vote[@"removed"] = @YES"#)) + #expect(!source.contains(#"vote[@"eventType"] = @"removed""#)) + #expect(!source.contains(#"vote[@"removed"] = @YES"#)) #expect(source.contains("handleSendPollAddOption")) #expect(source.contains("buildPollAddOptionPayloadData")) #expect(source.contains(#""send-poll-add-option"]"#)) From 53c8acce31488661e6174173108a277e54a5cc97 Mon Sep 17 00:00:00 2001 From: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:09:18 -0700 Subject: [PATCH 04/12] test: allow empty poll unvote votes --- Tests/IMsgCoreTests/MessagePollTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/IMsgCoreTests/MessagePollTests.swift b/Tests/IMsgCoreTests/MessagePollTests.swift index 7243a956..4276256a 100644 --- a/Tests/IMsgCoreTests/MessagePollTests.swift +++ b/Tests/IMsgCoreTests/MessagePollTests.swift @@ -290,7 +290,7 @@ func decodesPollUnvotePayloadFromEmptyVotesArray() throws { #expect(poll?.event == "imessage.poll.voted") #expect(poll?.pollGUID == "original-poll-guid") #expect(poll?.vote == nil) - #expect(poll?.votes == []) + #expect(poll?.votes?.isEmpty ?? true) } @Test From deccad607b12d2e1df504a47655ba22e55d92f41 Mon Sep 17 00:00:00 2001 From: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:46:00 -0700 Subject: [PATCH 05/12] fix: narrow poll updates to proven unvote --- .agents/skills/imsg/SKILL.md | 1 - CHANGELOG.md | 2 +- Sources/IMsgCore/IMsgBridgeProtocol.swift | 3 +- Sources/IMsgHelper/IMsgInjected.m | 114 ------------------ Sources/imsg/Commands/PollCommand.swift | 52 +------- .../RPCServer+BridgeMessageHandlers.swift | 48 -------- Sources/imsg/RPCServer.swift | 5 - .../IMsgBridgeProtocolTests.swift | 2 - .../BridgeCommandRegistrationTests.swift | 6 +- Tests/imsgTests/BridgeRichCommandTests.swift | 34 ------ .../RPCBridgeMessageHandlersTests.swift | 33 ----- docs/advanced-imcore.md | 4 +- docs/rpc.md | 4 +- 13 files changed, 9 insertions(+), 299 deletions(-) diff --git a/.agents/skills/imsg/SKILL.md b/.agents/skills/imsg/SKILL.md index 6e67c3b8..9a9de2d8 100644 --- a/.agents/skills/imsg/SKILL.md +++ b/.agents/skills/imsg/SKILL.md @@ -78,7 +78,6 @@ Only after `imsg status` confirms the bridge is loaded (`imsg launch` injects it imsg send-rich --chat 'iMessage;-;+15551234567' --text 'hi' --reply-to MSG_GUID # replies, effects, subjects imsg poll send --chat GUID --question 'Dinner?' --option 'Pizza' --option 'Sushi' --comment 'Vote by 5pm' imsg poll unvote --chat GUID --poll POLL_GUID --option-index 1 -imsg poll add-option --chat GUID --poll POLL_GUID --option 'Tacos' imsg edit --chat GUID --message MSG_GUID --new-text 'updated' # macOS 13+ imsg chat-create --addresses '+15551234567,+15559876543' --name 'Crew' ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index ece3e949..edb9bf40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Native Polls - fix: match native poll vote envelopes, participant handles, and summary metadata so votes render participant markers and correct notifications (#162, thanks @omarshahine). -- feat: add bridge-backed native poll `unvote` and `add-option` actions, exposed through CLI and JSON-RPC. +- feat: add bridge-backed native poll `unvote`, exposed through CLI and JSON-RPC. ## 0.12.3 - 2026-07-06 diff --git a/Sources/IMsgCore/IMsgBridgeProtocol.swift b/Sources/IMsgCore/IMsgBridgeProtocol.swift index 3a43b411..5780dad0 100644 --- a/Sources/IMsgCore/IMsgBridgeProtocol.swift +++ b/Sources/IMsgCore/IMsgBridgeProtocol.swift @@ -32,7 +32,7 @@ public enum IMsgBridgeProtocol { public static func defaultResponseTimeout(for action: BridgeAction) -> TimeInterval { switch action { case .sendMessage, .sendMultipart, .sendAttachment, .sendPoll, .sendPollVote, - .sendPollUnvote, .sendPollAddOption, .sendReaction, .createChat: + .sendPollUnvote, .sendReaction, .createChat: return defaultSendResponseTimeout default: return defaultResponseTimeout @@ -67,7 +67,6 @@ public enum BridgeAction: String, Sendable, CaseIterable { case sendPoll = "send-poll" case sendPollVote = "send-poll-vote" case sendPollUnvote = "send-poll-unvote" - case sendPollAddOption = "send-poll-add-option" case sendReaction = "send-reaction" case notifyAnyways = "notify-anyways" diff --git a/Sources/IMsgHelper/IMsgInjected.m b/Sources/IMsgHelper/IMsgInjected.m index b7206d38..56a776d9 100644 --- a/Sources/IMsgHelper/IMsgInjected.m +++ b/Sources/IMsgHelper/IMsgInjected.m @@ -2839,54 +2839,6 @@ static void appendEvent(NSDictionary *evt) { return payload; } -static NSData *buildPollAddOptionPayloadData(NSString *optionText, - NSString *creatorHandle, - NSString **outOptionIdentifier, - NSString **outError) { - NSString *identifier = [[NSUUID UUID] UUIDString]; - NSMutableDictionary *option = [NSMutableDictionary dictionaryWithDictionary:@{ - @"canBeEdited": @NO, - @"attributedText": optionText ?: @"", - @"text": optionText ?: @"", - @"optionIdentifier": identifier - }]; - if (creatorHandle.length) { - option[@"creatorHandle"] = creatorHandle; - } - NSDictionary *root = @{ - @"version": @1, - @"item": @{ @"orderedPollOptions": @[option] } - }; - NSError *jsonError = nil; - NSData *jsonData = [NSJSONSerialization dataWithJSONObject:root options:0 error:&jsonError]; - if (!jsonData) { - if (outError) *outError = jsonError.localizedDescription ?: @"Could not encode poll option payload"; - return nil; - } - if (jsonData.length > 4096) { - if (outError) *outError = @"Poll option payload exceeds 4096 bytes"; - return nil; - } - NSString *encoded = [jsonData base64EncodedStringWithOptions:0]; - NSString *urlString = [NSString stringWithFormat:@"data:,%@", encoded]; - NSURL *url = [NSURL URLWithString:urlString]; - if (!url) { - if (outError) *outError = @"Could not create poll option URL"; - return nil; - } - NSUUID *sessionIdentifier = [NSUUID UUID]; - NSError *archiveError = nil; - NSData *payload = archivePollPayloadEnvelope(url, sessionIdentifier, &archiveError); - if (!payload) { - if (outError) *outError = archiveError.localizedDescription ?: @"Could not archive poll option payload"; - return nil; - } - if (outOptionIdentifier) { - *outOptionIdentifier = identifier; - } - return payload; -} - /// Construct a poll-vote IMMessage: a Polls balloon carrying the vote payload, /// associated to the original poll via a bare poll GUID + associatedMessageType /// 4000. Native votes (verified against chat.db) use a bare poll GUID, not the @@ -3045,71 +2997,6 @@ static id buildPollVoteIMMessage(NSAttributedString *body, return handleSendPollVoteMutation(requestId, params, YES); } -/// `send-poll-add-option`: append a new choice to an existing poll. Native -/// add-choice rows use the poll update associated-message type (2) and carry -/// a Polls payload with only the new orderedPollOptions entry. -static NSDictionary *handleSendPollAddOption(NSInteger requestId, NSDictionary *params) { - NSString *chatGuid = params[@"chatGuid"]; - NSString *pollMessageGuid = trimmedPollString(params[@"pollMessageGuid"]); - NSString *optionText = trimmedPollString(params[@"optionText"]); - NSString *creatorHandle = trimmedPollString(params[@"creatorHandle"]); - - if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid"); - if (!pollMessageGuid.length) return errorResponse(requestId, @"Missing pollMessageGuid"); - if (!optionText.length) return errorResponse(requestId, @"Missing optionText"); - if (!pollVoteMessageInitializerAvailable()) { - return errorResponse(requestId, @"Poll update IMMessage initializer unavailable on this macOS"); - } - - IMChat *chat = resolveChatByGuid(chatGuid); - if (!chat) { - return errorResponse(requestId, - [NSString stringWithFormat:@"Chat not found: %@", chatGuid]); - } - - if (!creatorHandle.length) { - creatorHandle = activeIMessageSenderHandle(); - } - if (!creatorHandle.length) { - return errorResponse(requestId, - @"Could not resolve active iMessage sender handle for option payload"); - } - - NSString *optionIdentifier = nil; - NSString *payloadError = nil; - NSData *payloadData = buildPollAddOptionPayloadData(optionText, - creatorHandle, - &optionIdentifier, - &payloadError); - if (!payloadData) { - return errorResponse(requestId, payloadError ?: @"Could not build poll option payload"); - } - - NSDictionary *summary = @{ @"amc": @0, @"ust": @YES }; - NSAttributedString *body = buildPollBreadcrumbAttributed(); - - @try { - clearThreadContextForChat(chat, nil); - id imMessage = buildPollVoteIMMessage(body, payloadData, summary, pollMessageGuid, 2); - if (!imMessage) { - return errorResponse(requestId, @"Could not construct poll option IMMessage"); - } - [chat performSelector:@selector(sendMessage:) withObject:imMessage]; - NSString *guid = lastSentMessageGuid(chat); - return successResponse(requestId, @{ - @"chatGuid": chatGuid, - @"messageGuid": guid ?: @"", - @"pollMessageGuid": pollMessageGuid, - @"optionIdentifier": optionIdentifier ?: @"", - @"optionText": optionText ?: @"", - @"balloonBundleID": pollsBalloonBundleIdentifier() - }); - } @catch (NSException *exception) { - return errorResponse(requestId, - [NSString stringWithFormat:@"send-poll-add-option failed: %@", exception.reason]); - } -} - /// `send-multipart`: at minimum, sends an attributedBody composed of multiple /// text parts. v1 supports text-only multipart; mention/file parts can land in /// a follow-up. @@ -4442,7 +4329,6 @@ static void retargetPreparedTransfer(id ftc, IMFileTransfer *transfer, if ([action isEqualToString:@"send-poll"]) return handleSendPoll(legacyId, params); if ([action isEqualToString:@"send-poll-vote"]) return handleSendPollVote(legacyId, params); if ([action isEqualToString:@"send-poll-unvote"]) return handleSendPollUnvote(legacyId, params); - if ([action isEqualToString:@"send-poll-add-option"]) return handleSendPollAddOption(legacyId, params); if ([action isEqualToString:@"send-reaction"]) return handleSendReaction(legacyId, params); if ([action isEqualToString:@"notify-anyways"]) return handleNotifyAnyways(legacyId, params); if ([action isEqualToString:@"edit-message"]) return handleEditMessage(legacyId, params); diff --git a/Sources/imsg/Commands/PollCommand.swift b/Sources/imsg/Commands/PollCommand.swift index b38d0ae8..4ca570ab 100644 --- a/Sources/imsg/Commands/PollCommand.swift +++ b/Sources/imsg/Commands/PollCommand.swift @@ -9,7 +9,7 @@ enum PollCommand { discussion: """ Requires `imsg launch` (SIP-disabled, dylib injected). Use the `send` action to create a native Messages Polls extension balloon, `vote`/`unvote` - to select or remove a selection, or `add-option` to append a choice. + to select or remove a selection. Messages renders only the options on a poll balloon — the poll title is never shown to recipients. So `send` automatically sends `--question` as a @@ -22,7 +22,7 @@ enum PollCommand { signature: CommandSignatures.withRuntimeFlags( CommandSignature( arguments: [ - .make(label: "action", help: "send|vote|unvote|add-option", isOptional: false) + .make(label: "action", help: "send|vote|unvote", isOptional: false) ], options: CommandSignatures.baseOptions() + [ .make(label: "chat", names: [.long("chat")], help: "chat guid or rowid"), @@ -46,7 +46,7 @@ enum PollCommand { help: "poll option text; pass at least twice"), .make( label: "poll", names: [.long("poll")], - help: "vote/unvote/add-option: guid of the poll message to update"), + help: "vote/unvote: guid of the poll message to update"), .make( label: "optionID", names: [.long("option-id")], help: "vote/unvote: UUID of the option to select"), @@ -62,7 +62,6 @@ enum PollCommand { "imsg poll send --chat 'iMessage;-;+15551234567' --reply-to ABCD --question 'Approve?' --option 'Yes' --option 'No'", "imsg poll vote --chat 'iMessage;-;+15551234567' --poll ABCD --option-id 1B2C-...", "imsg poll unvote --chat 'iMessage;-;+15551234567' --poll ABCD --option-index 1", - "imsg poll add-option --chat 'iMessage;-;+15551234567' --poll ABCD --option 'Tacos'", ] ) { values, runtime in try await run(values: values, runtime: runtime) @@ -89,9 +88,6 @@ enum PollCommand { try await runVote( remove: true, values: values, runtime: runtime, storeFactory: storeFactory, invokeBridge: invokeBridge) - case "add-option": - try await runAddOption( - values: values, runtime: runtime, storeFactory: storeFactory, invokeBridge: invokeBridge) default: throw ParsedValuesError.invalidOption("action") } @@ -218,48 +214,6 @@ enum PollCommand { } } - private static func runAddOption( - values: ParsedValues, - runtime: RuntimeOptions, - storeFactory: @escaping (String) throws -> MessageStore, - invokeBridge: @escaping (BridgeAction, [String: Any]) async throws -> [String: Any] - ) async throws { - let chat = try resolveChatGUID(values: values, storeFactory: storeFactory) - guard let pollGuid = values.option("poll")?.trimmingCharacters(in: .whitespacesAndNewlines), - !pollGuid.isEmpty - else { - throw ParsedValuesError.missingOption("poll") - } - guard let option = values.option("option")?.trimmingCharacters(in: .whitespacesAndNewlines), - !option.isEmpty - else { - throw ParsedValuesError.missingOption("option") - } - let dbPath = values.option("db") ?? MessageStore.defaultPath - let store = try storeFactory(dbPath) - let options = try store.pollOptions(guid: pollGuid) - guard !options.isEmpty else { - throw ParsedValuesError.invalidOption("poll (could not decode options for \(pollGuid))") - } - - let params: [String: Any] = [ - "chatGuid": chat, - "pollMessageGuid": barePollGuid(pollGuid), - "optionText": option, - ] - - do { - let data = try await invokeBridge(.sendPollAddOption, params) - let guid = (data["messageGuid"] as? String) ?? "" - BridgeOutput.emit( - data, runtime: runtime, - summary: guid.isEmpty ? "poll option: queued" : "poll option: sent (guid=\(guid))") - } catch { - BridgeOutput.emitError(String(describing: error), runtime: runtime) - throw BridgeOutput.EmittedError() - } - } - /// Resolve exactly one option selector against the poll's stable options. private static func resolveOptionID( values: ParsedValues, diff --git a/Sources/imsg/RPCServer+BridgeMessageHandlers.swift b/Sources/imsg/RPCServer+BridgeMessageHandlers.swift index 5d39bf13..447114cd 100644 --- a/Sources/imsg/RPCServer+BridgeMessageHandlers.swift +++ b/Sources/imsg/RPCServer+BridgeMessageHandlers.swift @@ -155,54 +155,6 @@ extension RPCServer { try await handlePollVoteMutation(params: params, id: id, remove: true) } - func handlePollAddOption(params: [String: Any], id: Any?) async throws { - let chatGUID = try await resolveChatGUIDParam(params) - guard - let pollGUID = stringParam( - params["poll_guid"] ?? params["pollGuid"] ?? params["poll_message_guid"] - ?? params["message_guid"] ?? params["message_id"]), !pollGUID.isEmpty - else { - throw RPCError.invalidParams("poll_guid is required") - } - guard - let optionText = stringParam( - params["option"] ?? params["option_text"] ?? params["optionText"]), - !optionText.isEmpty - else { - throw RPCError.invalidParams("option is required") - } - let pollOptions = try store.pollOptions(guid: pollGUID) - guard !pollOptions.isEmpty else { - throw RPCError.invalidParams("poll \(pollGUID) not found or not decodable") - } - var bridgeParams: [String: Any] = [ - "chatGuid": chatGUID, - "pollMessageGuid": barePollGuid(pollGUID), - "optionText": optionText, - ] - if let creatorHandle = stringParam(params["creator_handle"] ?? params["creatorHandle"]), - !creatorHandle.isEmpty - { - bridgeParams["creatorHandle"] = creatorHandle - } - - let data = try await invokeBridge(action: .sendPollAddOption, params: bridgeParams) - var result: [String: Any] = [ - "ok": true, - "event": "imessage.poll.option_added", - "poll_guid": barePollGuid(pollGUID), - "option_text": optionText, - ] - if let optionID = data["optionIdentifier"] as? String, !optionID.isEmpty { - result["option_id"] = optionID - } - if let guid = data["messageGuid"] as? String, !guid.isEmpty { - result["guid"] = guid - result["message_id"] = guid - } - respond(id: id, result: result) - } - private func handlePollVoteMutation(params: [String: Any], id: Any?, remove: Bool) async throws { let chatGUID = try await resolveChatGUIDParam(params) guard diff --git a/Sources/imsg/RPCServer.swift b/Sources/imsg/RPCServer.swift index f55f726d..1ad18f7a 100644 --- a/Sources/imsg/RPCServer.swift +++ b/Sources/imsg/RPCServer.swift @@ -45,9 +45,6 @@ let kSupportedRPCMethods: [String] = [ "poll.unvote", "polls.unvote", "messages.poll.unvote", - "poll.addOption", - "polls.addOption", - "messages.poll.addOption", "tapback", "typing", "read", @@ -172,8 +169,6 @@ final class RPCServer { try await handlePollVote(params: params, id: id) case "poll.unvote", "polls.unvote", "messages.poll.unvote": try await handlePollUnvote(params: params, id: id) - case "poll.addOption", "polls.addOption", "messages.poll.addOption": - try await handlePollAddOption(params: params, id: id) case "tapback": try await handleTapback(params: params, id: id) case "typing": diff --git a/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift b/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift index fff36b87..901faf0d 100644 --- a/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift +++ b/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift @@ -10,7 +10,6 @@ struct IMsgBridgeProtocolTests { #expect(BridgeAction.sendMessage.rawValue == "send-message") #expect(BridgeAction.sendPoll.rawValue == "send-poll") #expect(BridgeAction.sendPollUnvote.rawValue == "send-poll-unvote") - #expect(BridgeAction.sendPollAddOption.rawValue == "send-poll-add-option") #expect(BridgeAction.sendReaction.rawValue == "send-reaction") #expect(BridgeAction.editMessage.rawValue == "edit-message") #expect(BridgeAction.unsendMessage.rawValue == "unsend-message") @@ -98,7 +97,6 @@ struct IMsgBridgeProtocolTests { .sendPoll, .sendPollVote, .sendPollUnvote, - .sendPollAddOption, .sendReaction, .createChat, ] { diff --git a/Tests/imsgTests/BridgeCommandRegistrationTests.swift b/Tests/imsgTests/BridgeCommandRegistrationTests.swift index ae8abfa5..fb297283 100644 --- a/Tests/imsgTests/BridgeCommandRegistrationTests.swift +++ b/Tests/imsgTests/BridgeCommandRegistrationTests.swift @@ -165,9 +165,7 @@ func injectedHelperBroadcastsFailClosedNativePollVoteMetadata() throws { #expect(source.contains("send-poll-vote")) #expect(source.contains("send-poll-unvote")) - #expect(source.contains("send-poll-add-option")) #expect(source.contains("Poll vote payload exceeds 4096 bytes")) - #expect(source.contains("Poll option payload exceeds 4096 bytes")) #expect(source.contains(#"@"pollVoteMessage": @(pollVoteMessageInitializerAvailable())"#)) #expect(sendVoteBody.contains("pollVoteMessageInitializerAvailable()")) #expect(!sendVoteBody.contains("pollPayloadMessageInitializerAvailable()")) @@ -178,9 +176,7 @@ func injectedHelperBroadcastsFailClosedNativePollVoteMetadata() throws { #expect(source.contains(#"@"amb": pollsBalloonBundleIdentifier()"#)) #expect(!source.contains(#"vote[@"eventType"] = @"removed""#)) #expect(!source.contains(#"vote[@"removed"] = @YES"#)) - #expect(source.contains("handleSendPollAddOption")) - #expect(source.contains("buildPollAddOptionPayloadData")) - #expect(source.contains(#""send-poll-add-option"]"#)) + #expect(!source.contains("send-poll-add-option")) #expect(voteBody.contains("associatedMessageType")) #expect(voteBody.contains("BOOL balloonStamped = NO;")) #expect(voteBody.contains("BOOL payloadStamped = NO;")) diff --git a/Tests/imsgTests/BridgeRichCommandTests.swift b/Tests/imsgTests/BridgeRichCommandTests.swift index 09a27c61..73a94e53 100644 --- a/Tests/imsgTests/BridgeRichCommandTests.swift +++ b/Tests/imsgTests/BridgeRichCommandTests.swift @@ -313,40 +313,6 @@ func pollCommandUnvoteResolvesOptionText() async throws { #expect(capturedParams["optionText"] as? String == "Yes") } -@Test -func pollCommandAddOptionSendsUpdatePayload() async throws { - let values = ParsedValues( - positional: ["add-option"], - options: [ - "chatID": ["1"], - "poll": ["p:0/poll-guid-6"], - "option": ["Tacos"], - ], - flags: ["jsonOutput"] - ) - let runtime = RuntimeOptions(parsedValues: values) - let store = try CommandTestDatabase.makeStoreForRPCWithPollVote() - var capturedAction: BridgeAction? - var capturedParams: [String: Any] = [:] - - _ = try await StdoutCapture.capture { - try await PollCommand.run( - values: values, - runtime: runtime, - storeFactory: { _ in store }, - invokeBridge: { action, params in - capturedAction = action - capturedParams = params - return ["messageGuid": "add-guid", "optionIdentifier": "choice-tacos"] - } - ) - } - - #expect(capturedAction == .sendPollAddOption) - #expect(capturedParams["pollMessageGuid"] as? String == "poll-guid-6") - #expect(capturedParams["optionText"] as? String == "Tacos") -} - @Test func pollCommandVoteRejectsConflictingSelectors() async throws { let values = ParsedValues( diff --git a/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift b/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift index 7c7fd592..e49f8165 100644 --- a/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift +++ b/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift @@ -16,7 +16,6 @@ func rpcStatusAdvertisesBridgeMessageMethods() { "poll.vote", "messages.poll.vote", "polls.unvote", - "polls.addOption", "tapback", "message.edit", "message.unsend", @@ -61,38 +60,6 @@ func rpcPollUnvoteValidatesAndResolvesOption() async throws { #expect(result?["message_id"] as? String == "unvote-guid") } -@Test -func rpcPollAddOptionInvokesBridgeWithValidatedPoll() async throws { - let store = try CommandTestDatabase.makeStoreForRPCWithPollVote() - 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 ["messageGuid": "add-guid", "optionIdentifier": "choice-tacos"] - } - ) - - let request = - #"{"jsonrpc":"2.0","id":"add","method":"polls.addOption","params":{"chat_id":1,"# - + #""poll_guid":"p:0/poll-guid-6","option":"Tacos"}}"# - await server.handleLineForTesting(request) - - #expect(capturedAction == .sendPollAddOption) - #expect(capturedParams["chatGuid"] as? String == "iMessage;+;chat123") - #expect(capturedParams["pollMessageGuid"] as? String == "poll-guid-6") - #expect(capturedParams["optionText"] as? String == "Tacos") - let result = output.responses.first?["result"] as? [String: Any] - #expect(result?["event"] as? String == "imessage.poll.option_added") - #expect(result?["option_text"] as? String == "Tacos") - #expect(result?["option_id"] as? String == "choice-tacos") -} - @Test func rpcPollVoteValidatesAndResolvesOption() async throws { let store = try CommandTestDatabase.makeStoreForRPCWithPollVote() diff --git a/docs/advanced-imcore.md b/docs/advanced-imcore.md index 569c87d9..fe3f079d 100644 --- a/docs/advanced-imcore.md +++ b/docs/advanced-imcore.md @@ -20,8 +20,8 @@ You almost certainly do not need any of this for normal use. - `imsg send-attachment --chat --file [--reply-to ]` — prefers the bridge for private attachment sends, with AppleScript fallback for normal files when no reply target is requested. -- `imsg poll send|vote|unvote|add-option ...` — create native Polls balloons, - cast or remove selections, and append choices. +- `imsg poll send|vote|unvote ...` — create native Polls balloons and cast or + remove selections. ## Why they're separate diff --git a/docs/rpc.md b/docs/rpc.md index 3b1ab4c0..a3a5e9f5 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -253,13 +253,11 @@ Response: ``` `poll.vote` casts a native vote after validating the poll and option against local history. -`polls.unvote` removes a selection with the same poll/option parameters, and -`polls.addOption` appends a new choice: +`polls.unvote` removes a selection with the same poll/option parameters: ```json {"jsonrpc":"2.0","id":"vote","method":"poll.vote","params":{"chat_id":42,"poll_guid":"POLL-GUID","option_id":"OPTION-UUID"}} {"jsonrpc":"2.0","id":"unvote","method":"polls.unvote","params":{"chat_id":42,"poll_guid":"POLL-GUID","option_id":"OPTION-UUID"}} -{"jsonrpc":"2.0","id":"add","method":"polls.addOption","params":{"chat_id":42,"poll_guid":"POLL-GUID","option":"Tacos"}} ``` `messages.poll.send` is accepted as an alias for `poll.send`. The caption echo is deliberately best-effort: if the poll is created but the follow-up caption send fails, the RPC still returns the poll result to avoid retrying and creating a duplicate poll. From 50e3f8a0eb3a648b7015f46cbf324bdad00513a6 Mon Sep 17 00:00:00 2001 From: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:57:46 -0700 Subject: [PATCH 06/12] fix: decode empty poll unvotes --- Sources/IMsgCore/MessagePolls.swift | 2 +- Sources/imsg/Commands/PollCommand.swift | 2 +- Tests/IMsgCoreTests/MessagePollTests.swift | 21 +++++++++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Sources/IMsgCore/MessagePolls.swift b/Sources/IMsgCore/MessagePolls.swift index da53d8b1..e8640a1f 100644 --- a/Sources/IMsgCore/MessagePolls.swift +++ b/Sources/IMsgCore/MessagePolls.swift @@ -177,7 +177,7 @@ public enum MessagePollDecoder { let scan = PayloadScan(payloadData: payloadData, summaryData: messageSummaryInfo) let facts = PollFacts(objects: scan.objects) - let hasPollPayloadEvidence = !facts.votes.isEmpty || scan.hasPollURLHint + let hasPollPayloadEvidence = !facts.votes.isEmpty || facts.hasEmptyVotes || scan.hasPollURLHint guard isPollBundle || hasPollPayloadEvidence else { return nil } let metadata = MessagePollMetadata( diff --git a/Sources/imsg/Commands/PollCommand.swift b/Sources/imsg/Commands/PollCommand.swift index 4ca570ab..30159d5f 100644 --- a/Sources/imsg/Commands/PollCommand.swift +++ b/Sources/imsg/Commands/PollCommand.swift @@ -32,7 +32,7 @@ enum PollCommand { help: "poll question. Messages does not render the poll title on the balloon, so imsg " + "sends this as a plain caption message right after the poll (the visible text) " - + "and also stores it as the payload title for agent readback" + + "and history/watch backfill the poll question from that caption" ), .make( label: "comment", names: [.long("comment")], diff --git a/Tests/IMsgCoreTests/MessagePollTests.swift b/Tests/IMsgCoreTests/MessagePollTests.swift index 4276256a..52de5670 100644 --- a/Tests/IMsgCoreTests/MessagePollTests.swift +++ b/Tests/IMsgCoreTests/MessagePollTests.swift @@ -593,6 +593,8 @@ func messageStoreDecodesPollVoteRowsWithPayloadGate() throws { ] let votePayload = try applePollEnvelopePayload(jsonObject: response) let voteBlob = Blob(bytes: [UInt8](votePayload)) + let unvotePayload = try applePollEnvelopePayload(jsonObject: ["votes": []]) + let unvoteBlob = Blob(bytes: [UInt8](unvotePayload)) let now = Date(timeIntervalSince1970: 1_700_000_000) try db.run( @@ -618,14 +620,28 @@ func messageStoreDecodesPollVoteRowsWithPayloadGate() throws { voteBlob, TestDatabase.appleEpoch(now.addingTimeInterval(1)) ) + try db.run( + """ + INSERT INTO message( + ROWID, handle_id, text, guid, associated_message_guid, associated_message_type, + balloon_bundle_id, payload_data, message_summary_info, date, is_from_me, service + ) + VALUES (3, 1, '', 'unvote-row-guid', 'p/original-poll-guid', 4000, NULL, ?, NULL, ?, 0, 'iMessage') + """, + unvoteBlob, + TestDatabase.appleEpoch(now.addingTimeInterval(2)) + ) try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 1)") try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 2)") + try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 3)") let store = try MessageStore(connection: db, path: ":memory:") let messages = try store.messages(chatID: 1, limit: 10) let voteMessage = try #require(messages.first { $0.guid == "vote-row-guid" }) + let unvoteMessage = try #require(messages.first { $0.guid == "unvote-row-guid" }) let streamedMessages = try store.messagesAfter(afterRowID: 0, chatID: 1, limit: 10) let streamedVote = try #require(streamedMessages.first { $0.guid == "vote-row-guid" }) + let streamedUnvote = try #require(streamedMessages.first { $0.guid == "unvote-row-guid" }) #expect(voteMessage.poll?.kind == .vote) #expect(voteMessage.poll?.originalGUID == "original-poll-guid") @@ -633,8 +649,13 @@ func messageStoreDecodesPollVoteRowsWithPayloadGate() throws { #expect(voteMessage.poll?.vote?.participant == "+15550002000") #expect(voteMessage.poll?.vote?.optionID == "choice-a") #expect(voteMessage.poll?.vote?.optionText == "A") + #expect(unvoteMessage.poll?.kind == .vote) + #expect(unvoteMessage.poll?.originalGUID == "original-poll-guid") + #expect(unvoteMessage.poll?.votes?.isEmpty == true) #expect(streamedVote.poll?.kind == .vote) #expect(streamedVote.poll?.vote?.optionText == "A") + #expect(streamedUnvote.poll?.kind == .vote) + #expect(streamedUnvote.poll?.votes?.isEmpty == true) } @Test From 41b4f54edf069e054f353bbacccc9e64942e5757 Mon Sep 17 00:00:00 2001 From: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:02:21 -0700 Subject: [PATCH 07/12] test: align empty poll unvote assertion --- Tests/IMsgCoreTests/MessagePollTests.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Tests/IMsgCoreTests/MessagePollTests.swift b/Tests/IMsgCoreTests/MessagePollTests.swift index 52de5670..ed4783d9 100644 --- a/Tests/IMsgCoreTests/MessagePollTests.swift +++ b/Tests/IMsgCoreTests/MessagePollTests.swift @@ -651,11 +651,13 @@ func messageStoreDecodesPollVoteRowsWithPayloadGate() throws { #expect(voteMessage.poll?.vote?.optionText == "A") #expect(unvoteMessage.poll?.kind == .vote) #expect(unvoteMessage.poll?.originalGUID == "original-poll-guid") - #expect(unvoteMessage.poll?.votes?.isEmpty == true) + #expect(unvoteMessage.poll?.vote == nil) + #expect(unvoteMessage.poll?.votes?.isEmpty ?? true) #expect(streamedVote.poll?.kind == .vote) #expect(streamedVote.poll?.vote?.optionText == "A") #expect(streamedUnvote.poll?.kind == .vote) - #expect(streamedUnvote.poll?.votes?.isEmpty == true) + #expect(streamedUnvote.poll?.vote == nil) + #expect(streamedUnvote.poll?.votes?.isEmpty ?? true) } @Test From cd4825297f3f5a707e5706dd1663a904e6ed18ea Mon Sep 17 00:00:00 2001 From: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:05:24 -0700 Subject: [PATCH 08/12] Revert "fix: render poll options in Messages" This reverts commit 6c98cdb4f9fe2329c89c0f8e8c8c988913022a2b. --- Sources/IMsgHelper/IMsgInjected.m | 5 +---- Tests/imsgTests/BridgeCommandRegistrationTests.swift | 1 - 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/Sources/IMsgHelper/IMsgInjected.m b/Sources/IMsgHelper/IMsgInjected.m index 56a776d9..8b5f8601 100644 --- a/Sources/IMsgHelper/IMsgInjected.m +++ b/Sources/IMsgHelper/IMsgInjected.m @@ -1888,11 +1888,8 @@ static BOOL pollVoteMessageInitializerAvailable(void) { [pollOptions addObject:option]; } - // Native Polls payloads keep item.title empty and render the question via - // the separate "comment or Send" caption row. Supplying a non-empty title - // can make Messages recognize the extension but collapse the options UI. NSMutableDictionary *item = [NSMutableDictionary dictionaryWithDictionary:@{ - @"title": @"", + @"title": question ?: @"", @"orderedPollOptions": pollOptions }]; if (creatorHandle.length) { diff --git a/Tests/imsgTests/BridgeCommandRegistrationTests.swift b/Tests/imsgTests/BridgeCommandRegistrationTests.swift index fb297283..c945bdee 100644 --- a/Tests/imsgTests/BridgeCommandRegistrationTests.swift +++ b/Tests/imsgTests/BridgeCommandRegistrationTests.swift @@ -128,7 +128,6 @@ func injectedHelperWiresNativePollSend() throws { #expect(sendPollBody.contains("buildPollCreationPayloadData")) #expect(sendPollBody.contains("buildPollIMMessage")) #expect(sendPollBody.contains("pollPayloadMessageInitializerAvailable()")) - #expect(source.contains(#""title": @"""#)) #expect(!sendPollBody.contains(#"selectedMessageGuid.length ? @"" : question"#)) #expect(sendPollBody.contains("buildPollCreationPayloadData(question,")) #expect(sendPollBody.contains(#"@{ @"enc": @YES, @"ust": @YES }"#)) From 34f0b453cf6ffa8cd6c2e0daefd574c9d803bb0d Mon Sep 17 00:00:00 2001 From: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:06:19 -0700 Subject: [PATCH 09/12] chore: keep poll unvote scope focused --- Sources/IMsgHelper/IMsgInjected.m | 2 +- Sources/imsg/Commands/PollCommand.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/IMsgHelper/IMsgInjected.m b/Sources/IMsgHelper/IMsgInjected.m index 8b5f8601..5bb3da14 100644 --- a/Sources/IMsgHelper/IMsgInjected.m +++ b/Sources/IMsgHelper/IMsgInjected.m @@ -2989,7 +2989,7 @@ static id buildPollVoteIMMessage(NSAttributedString *body, } /// `send-poll-unvote`: remove a vote on an existing poll by sending the same -/// native vote payload with Apple's removal fields populated. +/// native vote payload shape with an empty votes array. static NSDictionary *handleSendPollUnvote(NSInteger requestId, NSDictionary *params) { return handleSendPollVoteMutation(requestId, params, YES); } diff --git a/Sources/imsg/Commands/PollCommand.swift b/Sources/imsg/Commands/PollCommand.swift index 30159d5f..4ca570ab 100644 --- a/Sources/imsg/Commands/PollCommand.swift +++ b/Sources/imsg/Commands/PollCommand.swift @@ -32,7 +32,7 @@ enum PollCommand { help: "poll question. Messages does not render the poll title on the balloon, so imsg " + "sends this as a plain caption message right after the poll (the visible text) " - + "and history/watch backfill the poll question from that caption" + + "and also stores it as the payload title for agent readback" ), .make( label: "comment", names: [.long("comment")], From 4d85dc2b1643adcf3c38af52c3a44094d505fddb Mon Sep 17 00:00:00 2001 From: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Date: Fri, 10 Jul 2026 08:18:58 -0700 Subject: [PATCH 10/12] fix: preserve remaining poll votes on unvote --- Sources/IMsgCore/MessageStore+Polls.swift | 41 +++++++++++++++++ Sources/IMsgHelper/IMsgInjected.m | 45 ++++++++++++++++--- Sources/imsg/Commands/PollCommand.swift | 16 +++++-- .../RPCServer+BridgeMessageHandlers.swift | 10 +++++ .../BridgeCommandRegistrationTests.swift | 2 +- Tests/imsgTests/BridgeRichCommandTests.swift | 3 +- Tests/imsgTests/CommandTestDatabase.swift | 28 +++++++++--- .../RPCBridgeMessageHandlersTests.swift | 19 +++++++- 8 files changed, 145 insertions(+), 19 deletions(-) diff --git a/Sources/IMsgCore/MessageStore+Polls.swift b/Sources/IMsgCore/MessageStore+Polls.swift index b3e2c118..0bce8399 100644 --- a/Sources/IMsgCore/MessageStore+Polls.swift +++ b/Sources/IMsgCore/MessageStore+Polls.swift @@ -59,6 +59,18 @@ extension MessageStore { } } + /// Latest outbound vote snapshot for a poll. Native poll vote rows carry the + /// sender's full selected-option set, not a single delta, so selective unvote + /// must remove one option from this current snapshot and resend the remainder. + public func pollSelectedOptionIDs(guid: String) throws -> [String] { + let normalized = normalizeAssociatedGUID(guid) + let target = normalized.isEmpty ? guid : normalized + guard !target.isEmpty else { return [] } + return try withConnection { db in + try latestOutboundPollVoteOptionIDs(guid: target, db: db) + } + } + private func pollOptionTextsByID( pollGUID: String, db: Connection, @@ -133,6 +145,35 @@ extension MessageStore { return options } + private func latestOutboundPollVoteOptionIDs(guid: String, db: Connection) throws -> [String] { + guard schema.hasReactionColumns else { return [] } + let selection = MessageRowSelection(store: self, includeChatID: false) + let rows = try db.prepareRowIterator( + """ + SELECT \(selection.selectList) + FROM message m + LEFT JOIN handle h ON m.handle_id = h.ROWID + WHERE m.is_from_me = 1 + AND m.associated_message_type = ? + AND ( + m.associated_message_guid = ? + OR m.associated_message_guid LIKE '%/' || ? + ) + ORDER BY m.date DESC, m.ROWID DESC + LIMIT 1 + """, + bindings: [MessagePollDecoder.voteAssociatedMessageType, guid, guid] + ) + guard let row = try rows.failableNext() else { return [] } + let decoded = try decodeMessageRow( + row, + columns: selection.columns, + fallbackChatID: nil + ) + guard decoded.poll?.kind == .vote else { return [] } + return decoded.poll?.votes?.map(\.optionID) ?? [] + } + private func sourcePollGUID(forAny candidates: [String], db: Connection) throws -> String? { guard schema.hasReactionColumns else { return nil } for candidate in candidates { diff --git a/Sources/IMsgHelper/IMsgInjected.m b/Sources/IMsgHelper/IMsgInjected.m index 5bb3da14..bd28318e 100644 --- a/Sources/IMsgHelper/IMsgInjected.m +++ b/Sources/IMsgHelper/IMsgInjected.m @@ -2797,13 +2797,23 @@ static void appendEvent(NSDictionary *evt) { /// creation the data URL carries no `?src=p&c=` suffix. static NSData *buildPollVotePayloadData(NSString *optionIdentifier, NSString *voterHandle, + NSArray *remainingOptionIdentifiers, BOOL removed, NSString **outError) { - NSArray *votes = @[]; - if (!removed) { - votes = @[@{ - @"voteOptionIdentifier": optionIdentifier, - @"participantHandle": pollParticipantHandle(voterHandle) ?: @"" + NSArray *voteOptionIdentifiers = removed + ? (remainingOptionIdentifiers ?: @[]) + : @[(optionIdentifier ?: @"")]; + NSMutableArray *votes = [NSMutableArray arrayWithCapacity:voteOptionIdentifiers.count]; + NSString *participantHandle = pollParticipantHandle(voterHandle) ?: @""; + for (NSString *identifierValue in voteOptionIdentifiers) { + NSString *identifier = trimmedPollString(identifierValue); + if (!identifier.length) { + if (outError) *outError = @"Poll vote payload contains an empty option identifier"; + return nil; + } + [votes addObject:@{ + @"voteOptionIdentifier": identifier, + @"participantHandle": participantHandle }]; } NSDictionary *root = @{ @@ -2918,10 +2928,29 @@ static id buildPollVoteIMMessage(NSAttributedString *body, NSString *pollMessageGuid = trimmedPollString(params[@"pollMessageGuid"]); NSString *optionIdentifier = trimmedPollString(params[@"optionIdentifier"]); NSString *optionText = trimmedPollString(params[@"optionText"]); + NSArray *remainingOptionIdentifiers = @[]; if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid"); if (!pollMessageGuid.length) return errorResponse(requestId, @"Missing pollMessageGuid"); if (!optionIdentifier.length) return errorResponse(requestId, @"Missing optionIdentifier"); + if (removed) { + id rawRemaining = params[@"remainingOptionIdentifiers"]; + if (rawRemaining && ![rawRemaining isKindOfClass:[NSArray class]]) { + return errorResponse(requestId, @"remainingOptionIdentifiers must be an array"); + } + NSMutableArray *normalizedRemaining = [NSMutableArray array]; + for (id rawIdentifier in (NSArray *)(rawRemaining ?: @[])) { + if (![rawIdentifier isKindOfClass:[NSString class]]) { + return errorResponse(requestId, @"remainingOptionIdentifiers must contain strings"); + } + NSString *identifier = trimmedPollString(rawIdentifier); + if (!identifier.length) { + return errorResponse(requestId, @"remainingOptionIdentifiers must not contain empty values"); + } + [normalizedRemaining addObject:identifier]; + } + remainingOptionIdentifiers = [normalizedRemaining copy]; + } if (!pollVoteMessageInitializerAvailable()) { return errorResponse(requestId, @"Poll vote IMMessage initializer unavailable on this macOS"); } @@ -2941,6 +2970,7 @@ static id buildPollVoteIMMessage(NSAttributedString *body, NSString *payloadError = nil; NSData *payloadData = buildPollVotePayloadData(optionIdentifier, voterHandle, + remainingOptionIdentifiers, removed, &payloadError); if (!payloadData) { @@ -2971,6 +3001,7 @@ static id buildPollVoteIMMessage(NSAttributedString *body, @"pollMessageGuid": pollMessageGuid, @"optionIdentifier": optionIdentifier, @"optionText": optionText ?: @"", + @"remainingOptionIdentifiers": remainingOptionIdentifiers, @"removed": @(removed), @"balloonBundleID": pollsBalloonBundleIdentifier() }); @@ -2988,8 +3019,8 @@ static id buildPollVoteIMMessage(NSAttributedString *body, return handleSendPollVoteMutation(requestId, params, NO); } -/// `send-poll-unvote`: remove a vote on an existing poll by sending the same -/// native vote payload shape with an empty votes array. +/// `send-poll-unvote`: remove one selected option by sending the same native +/// vote payload shape with the sender's remaining selected options. static NSDictionary *handleSendPollUnvote(NSInteger requestId, NSDictionary *params) { return handleSendPollVoteMutation(requestId, params, YES); } diff --git a/Sources/imsg/Commands/PollCommand.swift b/Sources/imsg/Commands/PollCommand.swift index 4ca570ab..70f1456f 100644 --- a/Sources/imsg/Commands/PollCommand.swift +++ b/Sources/imsg/Commands/PollCommand.swift @@ -173,8 +173,10 @@ enum PollCommand { else { throw ParsedValuesError.missingOption("poll") } + let dbPath = values.option("db") ?? MessageStore.defaultPath + let store = try storeFactory(dbPath) let resolved = try resolveOptionID( - values: values, pollGuid: pollGuid, storeFactory: storeFactory) + values: values, pollGuid: pollGuid, store: store) var params: [String: Any] = [ "chatGuid": chat, @@ -188,6 +190,14 @@ enum PollCommand { if let text = resolved.text, !text.isEmpty { params["optionText"] = text } + if remove { + let selectedOptionIDs = try store.pollSelectedOptionIDs(guid: pollGuid) + guard selectedOptionIDs.contains(resolved.id) else { + throw ParsedValuesError.invalidOption( + "option-id \(resolved.id) is not currently selected") + } + params["remainingOptionIdentifiers"] = selectedOptionIDs.filter { $0 != resolved.id } + } do { let action: BridgeAction = remove ? .sendPollUnvote : .sendPollVote @@ -218,7 +228,7 @@ enum PollCommand { private static func resolveOptionID( values: ParsedValues, pollGuid: String, - storeFactory: (String) throws -> MessageStore + store: MessageStore ) throws -> (id: String, text: String?) { let directID = values.option("optionID")?.trimmingCharacters(in: .whitespacesAndNewlines) let indexValue = values.optionInt64("optionIndex") @@ -231,8 +241,6 @@ enum PollCommand { throw ParsedValuesError.invalidOption( "choose exactly one of --option-id, --option-index, or --option") } - let dbPath = values.option("db") ?? MessageStore.defaultPath - let store = try storeFactory(dbPath) let options = try store.pollOptions(guid: pollGuid) guard !options.isEmpty else { throw ParsedValuesError.invalidOption("poll (could not decode options for \(pollGuid))") diff --git a/Sources/imsg/RPCServer+BridgeMessageHandlers.swift b/Sources/imsg/RPCServer+BridgeMessageHandlers.swift index 447114cd..408c952f 100644 --- a/Sources/imsg/RPCServer+BridgeMessageHandlers.swift +++ b/Sources/imsg/RPCServer+BridgeMessageHandlers.swift @@ -189,6 +189,13 @@ extension RPCServer { if !matchedOption.text.isEmpty { bridgeParams["optionText"] = matchedOption.text } + if remove { + let selectedOptionIDs = try store.pollSelectedOptionIDs(guid: pollGUID) + guard selectedOptionIDs.contains(optionID) else { + throw RPCError.invalidParams("option_id \(optionID) is not currently selected") + } + bridgeParams["remainingOptionIdentifiers"] = selectedOptionIDs.filter { $0 != optionID } + } let data = try await invokeBridge( action: remove ? .sendPollUnvote : .sendPollVote, @@ -202,6 +209,9 @@ extension RPCServer { "option_id": matchedOption.id, "option_text": matchedOption.text, ] + if let remaining = bridgeParams["remainingOptionIdentifiers"] as? [String] { + result["remaining_option_ids"] = remaining + } if let guid = data["messageGuid"] as? String, !guid.isEmpty { result["guid"] = guid result["message_id"] = guid diff --git a/Tests/imsgTests/BridgeCommandRegistrationTests.swift b/Tests/imsgTests/BridgeCommandRegistrationTests.swift index c945bdee..2793b5a6 100644 --- a/Tests/imsgTests/BridgeCommandRegistrationTests.swift +++ b/Tests/imsgTests/BridgeCommandRegistrationTests.swift @@ -170,7 +170,7 @@ func injectedHelperBroadcastsFailClosedNativePollVoteMetadata() throws { #expect(!sendVoteBody.contains("pollPayloadMessageInitializerAvailable()")) #expect(source.contains("archivePollMutationEnvelope")) #expect(source.contains("pollParticipantHandle(voterHandle)")) - #expect(source.contains(#"votes = @[]"#)) + #expect(source.contains("remainingOptionIdentifiers")) #expect(source.contains(#"@"ams": @"Sent a vote""#)) #expect(source.contains(#"@"amb": pollsBalloonBundleIdentifier()"#)) #expect(!source.contains(#"vote[@"eventType"] = @"removed""#)) diff --git a/Tests/imsgTests/BridgeRichCommandTests.swift b/Tests/imsgTests/BridgeRichCommandTests.swift index 73a94e53..12204ba8 100644 --- a/Tests/imsgTests/BridgeRichCommandTests.swift +++ b/Tests/imsgTests/BridgeRichCommandTests.swift @@ -290,7 +290,7 @@ func pollCommandUnvoteResolvesOptionText() async throws { flags: ["jsonOutput"] ) let runtime = RuntimeOptions(parsedValues: values) - let store = try CommandTestDatabase.makeStoreForRPCWithPollVote() + let store = try CommandTestDatabase.makeStoreForRPCWithOwnPollVoteSnapshot() var capturedAction: BridgeAction? var capturedParams: [String: Any] = [:] @@ -311,6 +311,7 @@ func pollCommandUnvoteResolvesOptionText() async throws { #expect(capturedParams["pollMessageGuid"] as? String == "poll-guid-6") #expect(capturedParams["optionIdentifier"] as? String == "choice-yes") #expect(capturedParams["optionText"] as? String == "Yes") + #expect(capturedParams["remainingOptionIdentifiers"] as? [String] == ["choice-no"]) } @Test diff --git a/Tests/imsgTests/CommandTestDatabase.swift b/Tests/imsgTests/CommandTestDatabase.swift index dc98caa5..80cd7852 100644 --- a/Tests/imsgTests/CommandTestDatabase.swift +++ b/Tests/imsgTests/CommandTestDatabase.swift @@ -131,6 +131,23 @@ enum CommandTestDatabase { } static func makeStoreForRPCWithPollVote() throws -> MessageStore { + try makeStoreForRPCWithPollVoteSnapshot( + isFromMe: false, + selectedOptionIDs: ["choice-yes"] + ) + } + + static func makeStoreForRPCWithOwnPollVoteSnapshot() throws -> MessageStore { + try makeStoreForRPCWithPollVoteSnapshot( + isFromMe: true, + selectedOptionIDs: ["choice-yes", "choice-no"] + ) + } + + private static func makeStoreForRPCWithPollVoteSnapshot( + isFromMe: Bool, + selectedOptionIDs: [String] + ) throws -> MessageStore { let db = try Connection(.inMemory) try createSchema( db, @@ -150,13 +167,13 @@ enum CommandTestDatabase { ]) let votePayload = try pollPayload( jsonObject: [ - "votes": [ + "votes": selectedOptionIDs.map { optionID in [ - "voteOptionIdentifier": "choice-yes", + "voteOptionIdentifier": optionID, "participantHandle": "+123", "eventType": "selected", ] - ] + } ]) try db.run( """ @@ -176,10 +193,11 @@ enum CommandTestDatabase { ROWID, handle_id, text, guid, associated_message_guid, associated_message_type, balloon_bundle_id, payload_data, message_summary_info, date, is_from_me, service ) - VALUES (7, 1, '', 'poll-vote-guid-7', 'p:0/poll-guid-6', 4000, NULL, ?, NULL, ?, 0, 'iMessage') + VALUES (7, 1, '', 'poll-vote-guid-7', 'p:0/poll-guid-6', 4000, NULL, ?, NULL, ?, ?, 'iMessage') """, Blob(bytes: [UInt8](votePayload)), - appleEpoch(now.addingTimeInterval(2)) + appleEpoch(now.addingTimeInterval(2)), + isFromMe ? 1 : 0 ) try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 6)") try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 7)") diff --git a/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift b/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift index e49f8165..ddb7ddf8 100644 --- a/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift +++ b/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift @@ -29,7 +29,7 @@ func rpcStatusAdvertisesBridgeMessageMethods() { @Test func rpcPollUnvoteValidatesAndResolvesOption() async throws { - let store = try CommandTestDatabase.makeStoreForRPCWithPollVote() + let store = try CommandTestDatabase.makeStoreForRPCWithOwnPollVoteSnapshot() let output = TestRPCOutput() var capturedAction: BridgeAction? var capturedParams: [String: Any] = [:] @@ -54,12 +54,29 @@ func rpcPollUnvoteValidatesAndResolvesOption() async throws { #expect(capturedParams["pollMessageGuid"] as? String == "poll-guid-6") #expect(capturedParams["optionIdentifier"] as? String == "choice-yes") #expect(capturedParams["optionText"] as? String == "Yes") + #expect(capturedParams["remainingOptionIdentifiers"] as? [String] == ["choice-no"]) let result = output.responses.first?["result"] as? [String: Any] #expect(result?["event"] as? String == "imessage.poll.unvoted") #expect(result?["option_text"] as? String == "Yes") + #expect(result?["remaining_option_ids"] as? [String] == ["choice-no"]) #expect(result?["message_id"] as? String == "unvote-guid") } +@Test +func rpcPollUnvoteRejectsUnselectedOption() async throws { + let store = try CommandTestDatabase.makeStoreForRPCWithPollVote() + let output = TestRPCOutput() + let server = RPCServer(store: store, verbose: false, output: output) + + await server.handleLineForTesting( + #"{"jsonrpc":"2.0","id":"unvote","method":"polls.unvote","params":{"chat_id":1,"poll_guid":"poll-guid-6","option_id":"choice-no"}}"# + ) + + let error = output.errors.first?["error"] as? [String: Any] + #expect((error?["code"] as? Int) == -32602) + #expect((error?["data"] as? String)?.contains("not currently selected") == true) +} + @Test func rpcPollVoteValidatesAndResolvesOption() async throws { let store = try CommandTestDatabase.makeStoreForRPCWithPollVote() From 1d38dc1cc61afe4ab43be74b18b450af3e4814ed Mon Sep 17 00:00:00 2001 From: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:52:34 -0700 Subject: [PATCH 11/12] chore: remove poll unvote changelog entry --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edb9bf40..ad98b064 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,6 @@ ### Native Polls - fix: match native poll vote envelopes, participant handles, and summary metadata so votes render participant markers and correct notifications (#162, thanks @omarshahine). -- feat: add bridge-backed native poll `unvote`, exposed through CLI and JSON-RPC. ## 0.12.3 - 2026-07-06 From cbd76a31cc66d4e28cbd8e94c6c7af16e70d026d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 11 Jul 2026 18:17:48 +0100 Subject: [PATCH 12/12] docs: note selective native poll unvoting --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2201dc4..88c841e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ ### Native Polls - fix: match native poll vote envelopes, participant handles, and summary metadata so votes render participant markers and correct notifications (#162, thanks @omarshahine). +- feat: add selective native poll unvoting through CLI and JSON-RPC while preserving the sender's other selected options (#162, thanks @omarshahine). ## 0.12.3 - 2026-07-06