diff --git a/.agents/skills/imsg/SKILL.md b/.agents/skills/imsg/SKILL.md index 72d886af..a64e66be 100644 --- a/.agents/skills/imsg/SKILL.md +++ b/.agents/skills/imsg/SKILL.md @@ -79,6 +79,7 @@ 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 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 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 diff --git a/Sources/IMsgCore/IMsgBridgeProtocol.swift b/Sources/IMsgCore/IMsgBridgeProtocol.swift index 849906b7..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, - .sendReaction, .createChat: + .sendPollUnvote, .sendReaction, .createChat: return defaultSendResponseTimeout default: return defaultResponseTimeout @@ -66,6 +66,7 @@ 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 sendReaction = "send-reaction" case notifyAnyways = "notify-anyways" diff --git a/Sources/IMsgCore/MessagePolls.swift b/Sources/IMsgCore/MessagePolls.swift index bb1b0405..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( @@ -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/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 9c6d64d8..af23988a 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 && @@ -2971,14 +2972,28 @@ 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) { - NSDictionary *vote = @{ - @"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 = @{ @"version": @1, - @"item": @{ @"votes": @[vote] } + @"item": @{ @"votes": votes } }; NSError *jsonError = nil; NSData *jsonData = [NSJSONSerialization dataWithJSONObject:root options:0 error:&jsonError]; @@ -3017,7 +3032,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; @@ -3037,7 +3053,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 @@ -3081,19 +3096,36 @@ 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"]); 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"); } @@ -3111,7 +3143,11 @@ static id buildPollVoteIMMessage(NSAttributedString *body, } NSString *payloadError = nil; - NSData *payloadData = buildPollVotePayloadData(optionIdentifier, voterHandle, &payloadError); + NSData *payloadData = buildPollVotePayloadData(optionIdentifier, + voterHandle, + remainingOptionIdentifiers, + removed, + &payloadError); if (!payloadData) { return errorResponse(requestId, payloadError ?: @"Could not build vote payload"); } @@ -3128,7 +3164,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"); } @@ -3140,6 +3176,8 @@ static id buildPollVoteIMMessage(NSAttributedString *body, @"pollMessageGuid": pollMessageGuid, @"optionIdentifier": optionIdentifier, @"optionText": optionText ?: @"", + @"remainingOptionIdentifiers": remainingOptionIdentifiers, + @"removed": @(removed), @"balloonBundleID": pollsBalloonBundleIdentifier() }); } @catch (NSException *exception) { @@ -3148,6 +3186,20 @@ 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 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); +} + /// `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. @@ -4438,6 +4490,7 @@ 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-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..70f1456f 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. 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", 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: 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,7 @@ 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", ] ) { values, runtime in try await run(values: values, runtime: runtime) @@ -81,6 +82,11 @@ 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) default: throw ParsedValuesError.invalidOption("action") @@ -155,6 +161,7 @@ enum PollCommand { } private static func runVote( + remove: Bool, values: ParsedValues, runtime: RuntimeOptions, storeFactory: @escaping (String) throws -> MessageStore, @@ -166,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, @@ -181,9 +190,18 @@ 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 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 +215,9 @@ 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() @@ -208,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") @@ -221,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 5451a187..408c952f 100644 --- a/Sources/imsg/RPCServer+BridgeMessageHandlers.swift +++ b/Sources/imsg/RPCServer+BridgeMessageHandlers.swift @@ -148,6 +148,14 @@ 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) + } + + private func handlePollVoteMutation(params: [String: Any], id: Any?, remove: Bool) async throws { let chatGUID = try await resolveChatGUIDParam(params) guard let pollGUID = stringParam( @@ -181,17 +189,29 @@ 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: .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), "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/Sources/imsg/RPCServer.swift b/Sources/imsg/RPCServer.swift index 00730f29..bda8847d 100644 --- a/Sources/imsg/RPCServer.swift +++ b/Sources/imsg/RPCServer.swift @@ -43,6 +43,9 @@ let kSupportedRPCMethods: [String] = [ "messages.poll.send", "poll.vote", "messages.poll.vote", + "poll.unvote", + "polls.unvote", + "messages.poll.unvote", "tapback", "typing", "read", @@ -170,6 +173,8 @@ 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 "tapback": try await handleTapback(params: params, id: id) case "typing": diff --git a/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift b/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift index 3c8f064f..901faf0d 100644 --- a/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift +++ b/Tests/IMsgCoreTests/IMsgBridgeProtocolTests.swift @@ -9,6 +9,7 @@ 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.sendReaction.rawValue == "send-reaction") #expect(BridgeAction.editMessage.rawValue == "edit-message") #expect(BridgeAction.unsendMessage.rawValue == "unsend-message") @@ -94,6 +95,8 @@ struct IMsgBridgeProtocolTests { .sendMultipart, .sendAttachment, .sendPoll, + .sendPollVote, + .sendPollUnvote, .sendReaction, .createChat, ] { diff --git a/Tests/IMsgCoreTests/MessagePollTests.swift b/Tests/IMsgCoreTests/MessagePollTests.swift index 183cf67e..ed4783d9 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?.isEmpty ?? true) +} + @Test func malformedPollPayloadEmitsUnknownWithoutRawPayload() throws { let poll = MessagePollDecoder.decode( @@ -566,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( @@ -591,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") @@ -606,8 +649,15 @@ 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?.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?.vote == nil) + #expect(streamedUnvote.poll?.votes?.isEmpty ?? true) } @Test diff --git a/Tests/imsgTests/BridgeCommandRegistrationTests.swift b/Tests/imsgTests/BridgeCommandRegistrationTests.swift index 776ede93..b3abbddf 100644 --- a/Tests/imsgTests/BridgeCommandRegistrationTests.swift +++ b/Tests/imsgTests/BridgeCommandRegistrationTests.swift @@ -211,14 +211,19 @@ 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("Poll vote payload exceeds 4096 bytes")) #expect(source.contains(#"@"pollVoteMessage": @(pollVoteMessageInitializerAvailable())"#)) #expect(sendVoteBody.contains("pollVoteMessageInitializerAvailable()")) #expect(!sendVoteBody.contains("pollPayloadMessageInitializerAvailable()")) #expect(source.contains("archivePollMutationEnvelope")) #expect(source.contains("pollParticipantHandle(voterHandle)")) + #expect(source.contains("remainingOptionIdentifiers")) #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("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..12204ba8 100644 --- a/Tests/imsgTests/BridgeRichCommandTests.swift +++ b/Tests/imsgTests/BridgeRichCommandTests.swift @@ -278,6 +278,42 @@ 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.makeStoreForRPCWithOwnPollVoteSnapshot() + 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") + #expect(capturedParams["remainingOptionIdentifiers"] as? [String] == ["choice-no"]) +} + @Test func pollCommandVoteRejectsConflictingSelectors() async throws { let values = ParsedValues( 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 3517fe8b..ddb7ddf8 100644 --- a/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift +++ b/Tests/imsgTests/RPCBridgeMessageHandlersTests.swift @@ -15,6 +15,7 @@ func rpcStatusAdvertisesBridgeMessageMethods() { "messages.poll.send", "poll.vote", "messages.poll.vote", + "polls.unvote", "tapback", "message.edit", "message.unsend", @@ -26,6 +27,56 @@ func rpcStatusAdvertisesBridgeMessageMethods() { } } +@Test +func rpcPollUnvoteValidatesAndResolvesOption() async throws { + let store = try CommandTestDatabase.makeStoreForRPCWithOwnPollVoteSnapshot() + 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") + #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() diff --git a/docs/advanced-imcore.md b/docs/advanced-imcore.md index 2067132b..fe3f079d 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 ...` — create native Polls balloons and cast or + remove selections. ## Why they're separate diff --git a/docs/rpc.md b/docs/rpc.md index 5a22e2e1..f6c69c09 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -268,6 +268,14 @@ 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: + +```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"}} +``` + `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