From ef994a8c9e52b488bf0170b509a0c364c0ee278e Mon Sep 17 00:00:00 2001 From: Ortes Date: Wed, 6 May 2026 12:58:58 -0400 Subject: [PATCH 1/7] feat: add send_poll tool and /api/send/poll endpoint Exposes WhatsApp polls (single- and multi-select) via a new MCP tool and bridge endpoint, using whatsmeow's BuildPollCreation. Vote collection is intentionally out of scope for this PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 15 +++ whatsapp-bridge/main.go | 120 ++++++++++++++++++++ whatsapp-mcp-server/main.py | 30 +++++ whatsapp-mcp-server/tests/test_send_poll.py | 56 +++++++++ whatsapp-mcp-server/whatsapp.py | 49 ++++++++ 5 files changed, 270 insertions(+) create mode 100644 whatsapp-mcp-server/tests/test_send_poll.py diff --git a/README.md b/README.md index b5747ee..c74eda2 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,21 @@ Send a text message to a contact or group. - "Send 'Hello!' to +1234567890" - "Message the team group saying 'Meeting at 3pm'" +#### `send_poll` + +Send a poll to a contact or group. + +**Parameters:** + +- `recipient` (required): Phone number or group JID +- `name` (required): Poll question / title +- `options` (required): List of 2-12 answer options +- `selectable_option_count` (optional): Max options each voter can pick. `1` (default) for single-choice, `len(options)` for multi-select. + +**Natural Language Examples:** + +- "Send a poll to the team asking 'Lunch?' with options pizza, salad, sushi" + #### `send_file` Send a media file (image, video, document). diff --git a/whatsapp-bridge/main.go b/whatsapp-bridge/main.go index 81f43a6..2768489 100644 --- a/whatsapp-bridge/main.go +++ b/whatsapp-bridge/main.go @@ -1140,6 +1140,74 @@ func sendWhatsAppMessage(client *whatsmeow.Client, messageStore *MessageStore, r return true, fmt.Sprintf("Message sent to %s", recipient) } +// SendPollRequest represents the request body for the send poll API +type SendPollRequest struct { + Recipient string `json:"recipient"` + Name string `json:"name"` + Options []string `json:"options"` + SelectableOptionCount int `json:"selectable_option_count,omitempty"` +} + +// resolveRecipientJID parses a phone number or JID string and resolves PN -> LID +// for personal chats, matching the behavior used when sending text/media messages. +func resolveRecipientJID(client *whatsmeow.Client, recipient string) (types.JID, error) { + var recipientJID types.JID + var err error + + if strings.Contains(recipient, "@") { + recipientJID, err = types.ParseJID(recipient) + if err != nil { + return types.JID{}, fmt.Errorf("error parsing JID: %w", err) + } + } else { + recipientJID = types.JID{ + User: recipient, + Server: "s.whatsapp.net", + } + } + + if recipientJID.Server == types.DefaultUserServer { + ctx := context.Background() + lid, lidErr := client.Store.LIDs.GetLIDForPN(ctx, recipientJID) + if lidErr == nil && !lid.IsEmpty() { + fmt.Printf("Resolved %s -> %s (LID)\n", recipientJID, lid) + recipientJID = lid + } else { + if lidErr != nil { + fmt.Printf("Warning: LID cache lookup failed for %s: %v, falling back to server\n", recipientJID, lidErr) + } + info, infoErr := client.GetUserInfo(ctx, []types.JID{recipientJID}) + if infoErr != nil { + fmt.Printf("Warning: server LID lookup failed for %s: %v\n", recipientJID, infoErr) + } else if userInfo, ok := info[recipientJID]; ok && !userInfo.LID.IsEmpty() { + fmt.Printf("Resolved %s -> %s (LID via server)\n", recipientJID, userInfo.LID) + recipientJID = userInfo.LID + } + } + } + + return recipientJID, nil +} + +// sendWhatsAppPoll builds and sends a poll creation message. +func sendWhatsAppPoll(client *whatsmeow.Client, recipient, name string, options []string, selectableOptionCount int) (bool, string) { + if !client.IsConnected() { + return false, "Not connected to WhatsApp" + } + + recipientJID, err := resolveRecipientJID(client, recipient) + if err != nil { + return false, err.Error() + } + + msg := client.BuildPollCreation(name, options, selectableOptionCount) + if _, err := client.SendMessage(context.Background(), recipientJID, msg); err != nil { + return false, fmt.Sprintf("Error sending poll: %v", err) + } + + return true, fmt.Sprintf("Poll sent to %s", recipient) +} + // Extract quoted message info from ContextInfo func extractQuotedMessageInfo(msg *waProto.Message) (quotedMessageId string, quotedSender string, quotedContent string) { if msg == nil { @@ -1747,6 +1815,58 @@ func startRESTServer(client *whatsmeow.Client, messageStore *MessageStore, port }) }) + // Handler for sending polls + http.HandleFunc("/api/send/poll", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req SendPollRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request format", http.StatusBadRequest) + return + } + + if req.Recipient == "" { + http.Error(w, "Recipient is required", http.StatusBadRequest) + return + } + if strings.TrimSpace(req.Name) == "" { + http.Error(w, "Poll name is required", http.StatusBadRequest) + return + } + if len(req.Options) < 2 { + http.Error(w, "At least two options are required", http.StatusBadRequest) + return + } + if len(req.Options) > 12 { + http.Error(w, "WhatsApp supports at most 12 poll options", http.StatusBadRequest) + return + } + for _, opt := range req.Options { + if strings.TrimSpace(opt) == "" { + http.Error(w, "Poll options must not be empty", http.StatusBadRequest) + return + } + } + if req.SelectableOptionCount < 0 || req.SelectableOptionCount > len(req.Options) { + http.Error(w, "selectable_option_count must be between 0 and len(options)", http.StatusBadRequest) + return + } + + success, message := sendWhatsAppPoll(client, req.Recipient, req.Name, req.Options, req.SelectableOptionCount) + + w.Header().Set("Content-Type", "application/json") + if !success { + w.WriteHeader(http.StatusInternalServerError) + } + _ = json.NewEncoder(w).Encode(SendMessageResponse{ + Success: success, + Message: message, + }) + }) + // Handler for downloading media http.HandleFunc("/api/download", func(w http.ResponseWriter, r *http.Request) { // Only allow POST requests diff --git a/whatsapp-mcp-server/main.py b/whatsapp-mcp-server/main.py index ea6f7e5..120bfc9 100644 --- a/whatsapp-mcp-server/main.py +++ b/whatsapp-mcp-server/main.py @@ -43,6 +43,9 @@ from whatsapp import ( send_message as whatsapp_send_message, ) +from whatsapp import ( + send_poll as whatsapp_send_poll, +) # Initialize FastMCP server mcp = FastMCP("whatsapp") @@ -313,6 +316,33 @@ def send_message(recipient: str, message: str) -> dict[str, Any]: return {"success": success, "message": status_message} +@mcp.tool() +def send_poll( + recipient: str, + name: str, + options: list[str], + selectable_option_count: int = 1, +) -> dict[str, Any]: + """Send a WhatsApp poll to a person or group. For group chats use the JID. + + Args: + recipient: The recipient - either a phone number with country code but no + or other symbols, + or a JID (e.g., "123456789@s.whatsapp.net" or a group JID like "123456789@g.us") + name: The poll question / title + options: List of answer options (2-12 entries, each non-empty) + selectable_option_count: Maximum number of options a voter may select. + Use 1 for a single-choice poll (default), or len(options) for multi-select. + + Returns: + A dictionary containing success status and a status message + """ + if not recipient: + return {"success": False, "message": "Recipient must be provided"} + + success, status_message = whatsapp_send_poll(recipient, name, options, selectable_option_count) + return {"success": success, "message": status_message} + + @mcp.tool() def send_file(recipient: str, media_path: str) -> dict[str, Any]: """Send a file such as a picture, raw audio, video or document via WhatsApp to the specified recipient. For group messages use the JID. diff --git a/whatsapp-mcp-server/tests/test_send_poll.py b/whatsapp-mcp-server/tests/test_send_poll.py new file mode 100644 index 0000000..159e46d --- /dev/null +++ b/whatsapp-mcp-server/tests/test_send_poll.py @@ -0,0 +1,56 @@ +"""Tests for send_poll input validation.""" + +from unittest.mock import patch + +from whatsapp import send_poll + + +class TestSendPollValidation: + def test_missing_recipient(self): + ok, msg = send_poll("", "Q?", ["a", "b"]) + assert not ok + assert "Recipient" in msg + + def test_missing_name(self): + ok, msg = send_poll("123@s.whatsapp.net", " ", ["a", "b"]) + assert not ok + assert "Poll name" in msg + + def test_too_few_options(self): + ok, msg = send_poll("123@s.whatsapp.net", "Q?", ["only"]) + assert not ok + assert "two poll options" in msg + + def test_too_many_options(self): + ok, msg = send_poll("123@s.whatsapp.net", "Q?", [str(i) for i in range(13)]) + assert not ok + assert "12" in msg + + def test_empty_option(self): + ok, msg = send_poll("123@s.whatsapp.net", "Q?", ["a", " "]) + assert not ok + assert "must not be empty" in msg + + def test_selectable_count_out_of_range(self): + ok, msg = send_poll("123@s.whatsapp.net", "Q?", ["a", "b"], selectable_option_count=5) + assert not ok + assert "selectable_option_count" in msg + + def test_valid_request_calls_bridge(self): + with patch("whatsapp.requests.post") as mock_post: + mock_post.return_value.status_code = 200 + mock_post.return_value.json.return_value = {"success": True, "message": "Poll sent to 123"} + + ok, msg = send_poll("123@s.whatsapp.net", "Lunch?", ["pizza", "salad"], selectable_option_count=1) + + assert ok + assert msg == "Poll sent to 123" + mock_post.assert_called_once() + call = mock_post.call_args + assert call.kwargs["json"] == { + "recipient": "123@s.whatsapp.net", + "name": "Lunch?", + "options": ["pizza", "salad"], + "selectable_option_count": 1, + } + assert call.args[0].endswith("/send/poll") diff --git a/whatsapp-mcp-server/whatsapp.py b/whatsapp-mcp-server/whatsapp.py index a628d81..ab144ba 100644 --- a/whatsapp-mcp-server/whatsapp.py +++ b/whatsapp-mcp-server/whatsapp.py @@ -974,6 +974,55 @@ def send_message(recipient: str, message: str) -> tuple[bool, str]: return False, f"Unexpected error: {str(e)}" +def send_poll( + recipient: str, + name: str, + options: list[str], + selectable_option_count: int = 1, +) -> tuple[bool, str]: + try: + if not recipient: + return False, "Recipient must be provided" + + if not name or not name.strip(): + return False, "Poll name must be provided" + + if not options or len(options) < 2: + return False, "At least two poll options are required" + + if len(options) > 12: + return False, "WhatsApp supports at most 12 poll options" + + if any(not opt or not opt.strip() for opt in options): + return False, "Poll options must not be empty" + + if selectable_option_count < 0 or selectable_option_count > len(options): + return False, "selectable_option_count must be between 0 and len(options)" + + url = f"{WHATSAPP_API_BASE_URL}/send/poll" + payload = { + "recipient": recipient, + "name": name, + "options": options, + "selectable_option_count": selectable_option_count, + } + + response = requests.post(url, json=payload) + + if response.status_code == 200: + result = response.json() + return result.get("success", False), result.get("message", "Unknown response") + else: + return False, f"Error: HTTP {response.status_code} - {response.text}" + + except requests.RequestException as e: + return False, f"Request error: {str(e)}" + except json.JSONDecodeError: + return False, f"Error parsing response: {response.text}" + except Exception as e: + return False, f"Unexpected error: {str(e)}" + + def send_file(recipient: str, media_path: str) -> tuple[bool, str]: try: # Validate input From 72b60f8b8eaa16e0270f85fbb3b93db5c9f67321 Mon Sep 17 00:00:00 2001 From: Ortes Date: Sat, 9 May 2026 10:46:08 -0400 Subject: [PATCH 2/7] fix(send-poll): address PR #83 review comments - Validate selectable_option_count >= 1 in both Python client and Go bridge (was allowing 0, which is invalid for WhatsApp polls). - Make SendPollRequest.SelectableOptionCount a *int so omitted requests default to 1 instead of decoding to 0. - De-duplicate recipient JID resolution: sendWhatsAppMessage now calls the shared resolveRecipientJID helper used by the poll handler. - Add test for selectable_option_count=0 rejection. Co-Authored-By: Claude Opus 4.7 (1M context) --- whatsapp-bridge/main.go | 80 +++++++-------------- whatsapp-mcp-server/tests/test_send_poll.py | 5 ++ whatsapp-mcp-server/whatsapp.py | 4 +- 3 files changed, 33 insertions(+), 56 deletions(-) diff --git a/whatsapp-bridge/main.go b/whatsapp-bridge/main.go index 2768489..d58d6c6 100644 --- a/whatsapp-bridge/main.go +++ b/whatsapp-bridge/main.go @@ -927,57 +927,23 @@ func sendWhatsAppMessage(client *whatsmeow.Client, messageStore *MessageStore, r return false, "Not connected to WhatsApp" } - // Create JID for recipient - var recipientJID types.JID - var settingsLookupJID types.JID - var err error - - // Check if recipient is a JID - isJID := strings.Contains(recipient, "@") - - if isJID { - // Parse the JID string - recipientJID, err = types.ParseJID(recipient) - if err != nil { - return false, fmt.Sprintf("Error parsing JID: %v", err) + // Parse the raw JID (pre-LID resolution) for settings lookup and SQLite + // storage: rows are keyed by @s.whatsapp.net, not @lid. + var storageJID types.JID + if strings.Contains(recipient, "@") { + var parseErr error + storageJID, parseErr = types.ParseJID(recipient) + if parseErr != nil { + return false, fmt.Sprintf("Error parsing JID: %v", parseErr) } } else { - // Create JID from phone number - recipientJID = types.JID{ - User: recipient, - Server: "s.whatsapp.net", // For personal chats - } + storageJID = types.JID{User: recipient, Server: "s.whatsapp.net"} } - settingsLookupJID = recipientJID - - // Capture pre-LID-resolution JID for SQLite storage. - // handleMessage uses resolveLIDChat to map LID→phone for incoming events; - // for outbound we keep the pre-resolution form so the chat stays unified - // under @s.whatsapp.net (matches what list_chats / list_messages expect). - storageJID := recipientJID + settingsLookupJID := storageJID - // For personal chats, resolve phone number JID to LID (Linked Identity). - // WhatsApp is migrating to LID-based addressing; messages sent to the - // phone JID silently fail for migrated contacts. - if recipientJID.Server == types.DefaultUserServer { - ctx := context.Background() - lid, lidErr := client.Store.LIDs.GetLIDForPN(ctx, recipientJID) - if lidErr == nil && !lid.IsEmpty() { - fmt.Printf("Resolved %s -> %s (LID)\n", recipientJID, lid) - recipientJID = lid - } else { - // Cache miss or cache error — ask the WhatsApp server. - if lidErr != nil { - fmt.Printf("Warning: LID cache lookup failed for %s: %v, falling back to server\n", recipientJID, lidErr) - } - info, infoErr := client.GetUserInfo(ctx, []types.JID{recipientJID}) - if infoErr != nil { - fmt.Printf("Warning: server LID lookup failed for %s: %v\n", recipientJID, infoErr) - } else if userInfo, ok := info[recipientJID]; ok && !userInfo.LID.IsEmpty() { - fmt.Printf("Resolved %s -> %s (LID via server)\n", recipientJID, userInfo.LID) - recipientJID = userInfo.LID - } - } + recipientJID, err := resolveRecipientJID(client, recipient) + if err != nil { + return false, err.Error() } msg := &waProto.Message{} @@ -1142,10 +1108,12 @@ func sendWhatsAppMessage(client *whatsmeow.Client, messageStore *MessageStore, r // SendPollRequest represents the request body for the send poll API type SendPollRequest struct { - Recipient string `json:"recipient"` - Name string `json:"name"` - Options []string `json:"options"` - SelectableOptionCount int `json:"selectable_option_count,omitempty"` + Recipient string `json:"recipient"` + Name string `json:"name"` + Options []string `json:"options"` + // Pointer so we can distinguish "omitted" (nil → default to 1) + // from "explicitly set to 0" (rejected by validation). + SelectableOptionCount *int `json:"selectable_option_count,omitempty"` } // resolveRecipientJID parses a phone number or JID string and resolves PN -> LID @@ -1850,12 +1818,16 @@ func startRESTServer(client *whatsmeow.Client, messageStore *MessageStore, port return } } - if req.SelectableOptionCount < 0 || req.SelectableOptionCount > len(req.Options) { - http.Error(w, "selectable_option_count must be between 0 and len(options)", http.StatusBadRequest) + selectable := 1 + if req.SelectableOptionCount != nil { + selectable = *req.SelectableOptionCount + } + if selectable < 1 || selectable > len(req.Options) { + http.Error(w, "selectable_option_count must be between 1 and len(options)", http.StatusBadRequest) return } - success, message := sendWhatsAppPoll(client, req.Recipient, req.Name, req.Options, req.SelectableOptionCount) + success, message := sendWhatsAppPoll(client, req.Recipient, req.Name, req.Options, selectable) w.Header().Set("Content-Type", "application/json") if !success { diff --git a/whatsapp-mcp-server/tests/test_send_poll.py b/whatsapp-mcp-server/tests/test_send_poll.py index 159e46d..8f05275 100644 --- a/whatsapp-mcp-server/tests/test_send_poll.py +++ b/whatsapp-mcp-server/tests/test_send_poll.py @@ -36,6 +36,11 @@ def test_selectable_count_out_of_range(self): assert not ok assert "selectable_option_count" in msg + def test_selectable_count_zero_rejected(self): + ok, msg = send_poll("123@s.whatsapp.net", "Q?", ["a", "b"], selectable_option_count=0) + assert not ok + assert "selectable_option_count" in msg + def test_valid_request_calls_bridge(self): with patch("whatsapp.requests.post") as mock_post: mock_post.return_value.status_code = 200 diff --git a/whatsapp-mcp-server/whatsapp.py b/whatsapp-mcp-server/whatsapp.py index ab144ba..7c32262 100644 --- a/whatsapp-mcp-server/whatsapp.py +++ b/whatsapp-mcp-server/whatsapp.py @@ -996,8 +996,8 @@ def send_poll( if any(not opt or not opt.strip() for opt in options): return False, "Poll options must not be empty" - if selectable_option_count < 0 or selectable_option_count > len(options): - return False, "selectable_option_count must be between 0 and len(options)" + if selectable_option_count < 1 or selectable_option_count > len(options): + return False, "selectable_option_count must be between 1 and len(options)" url = f"{WHATSAPP_API_BASE_URL}/send/poll" payload = { From e799f17001fa9b326670155c27ce36d41bcbc33d Mon Sep 17 00:00:00 2001 From: Ortes Date: Sat, 9 May 2026 10:49:46 -0400 Subject: [PATCH 3/7] chore(send-poll): polish review nits - Align bridge validation wording with the Python layer ("At least two poll options are required"). - Note in whatsapp.py that the validation rules are intentionally duplicated in main.go and must stay in sync. - Soften test_too_few_options assertion to tolerate wording changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- whatsapp-bridge/main.go | 2 +- whatsapp-mcp-server/tests/test_send_poll.py | 2 +- whatsapp-mcp-server/whatsapp.py | 10 ++++++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/whatsapp-bridge/main.go b/whatsapp-bridge/main.go index d58d6c6..5d0ff18 100644 --- a/whatsapp-bridge/main.go +++ b/whatsapp-bridge/main.go @@ -1805,7 +1805,7 @@ func startRESTServer(client *whatsmeow.Client, messageStore *MessageStore, port return } if len(req.Options) < 2 { - http.Error(w, "At least two options are required", http.StatusBadRequest) + http.Error(w, "At least two poll options are required", http.StatusBadRequest) return } if len(req.Options) > 12 { diff --git a/whatsapp-mcp-server/tests/test_send_poll.py b/whatsapp-mcp-server/tests/test_send_poll.py index 8f05275..77efb0e 100644 --- a/whatsapp-mcp-server/tests/test_send_poll.py +++ b/whatsapp-mcp-server/tests/test_send_poll.py @@ -19,7 +19,7 @@ def test_missing_name(self): def test_too_few_options(self): ok, msg = send_poll("123@s.whatsapp.net", "Q?", ["only"]) assert not ok - assert "two poll options" in msg + assert "two" in msg.lower() def test_too_many_options(self): ok, msg = send_poll("123@s.whatsapp.net", "Q?", [str(i) for i in range(13)]) diff --git a/whatsapp-mcp-server/whatsapp.py b/whatsapp-mcp-server/whatsapp.py index 7c32262..7762411 100644 --- a/whatsapp-mcp-server/whatsapp.py +++ b/whatsapp-mcp-server/whatsapp.py @@ -980,6 +980,9 @@ def send_poll( options: list[str], selectable_option_count: int = 1, ) -> tuple[bool, str]: + # Validation is intentionally duplicated in whatsapp-bridge/main.go's + # /api/send/poll handler so each layer is safe at its boundary. Keep the + # two in sync if you change the rules here. try: if not recipient: return False, "Recipient must be provided" @@ -996,8 +999,11 @@ def send_poll( if any(not opt or not opt.strip() for opt in options): return False, "Poll options must not be empty" - if selectable_option_count < 1 or selectable_option_count > len(options): - return False, "selectable_option_count must be between 1 and len(options)" + # whatsmeow semantics: 0 = multi-select with no limit, 1 = single-select, + # N (1 < N <= len(options)) = multi-select up to N. Out-of-range values + # are silently coerced to 0 by the library, so reject them upfront. + if selectable_option_count < 0 or selectable_option_count > len(options): + return False, "selectable_option_count must be 0 (unlimited) or between 1 and len(options)" url = f"{WHATSAPP_API_BASE_URL}/send/poll" payload = { From 81a6aa066dc031d4df5268288853b4c281af36ce Mon Sep 17 00:00:00 2001 From: Ortes Date: Sat, 9 May 2026 10:50:38 -0400 Subject: [PATCH 4/7] fix(send-poll): preserve whatsmeow's 0=unlimited semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the over-restrictive validation introduced in 9b8e7f0: whatsmeow's BuildPollCreation treats selectableOptionCount=0 as "multi-select with no limit" (msgsecret.go:300-308) and silently coerces out-of-range values to 0. Forcing a minimum of 1 and defaulting omitted requests to 1 silently removed access to the unlimited-select mode and rewrote caller intent. - Allow 0 (unlimited) again in both Python and Go validation; reject only negative or > len(options). - Drop the *int pointer indirection on SendPollRequest — int's zero-value already maps cleanly to whatsmeow's "unlimited" sentinel. - Document the three-way semantics on the request type and update error messages. - Replace the zero-rejection test with one asserting 0 is accepted and forwarded as-is, plus a negative-value rejection test. Co-Authored-By: Claude Opus 4.7 (1M context) --- whatsapp-bridge/main.go | 29 +++++++++++---------- whatsapp-mcp-server/tests/test_send_poll.py | 15 +++++++++-- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/whatsapp-bridge/main.go b/whatsapp-bridge/main.go index 5d0ff18..a8bf068 100644 --- a/whatsapp-bridge/main.go +++ b/whatsapp-bridge/main.go @@ -1106,14 +1106,17 @@ func sendWhatsAppMessage(client *whatsmeow.Client, messageStore *MessageStore, r return true, fmt.Sprintf("Message sent to %s", recipient) } -// SendPollRequest represents the request body for the send poll API +// SendPollRequest represents the request body for the send poll API. +// SelectableOptionCount follows whatsmeow semantics: +// +// 0 = multi-select with no limit (also the zero-value default when omitted) +// 1 = single-select +// N = multi-select up to N (1 < N <= len(Options)) type SendPollRequest struct { - Recipient string `json:"recipient"` - Name string `json:"name"` - Options []string `json:"options"` - // Pointer so we can distinguish "omitted" (nil → default to 1) - // from "explicitly set to 0" (rejected by validation). - SelectableOptionCount *int `json:"selectable_option_count,omitempty"` + Recipient string `json:"recipient"` + Name string `json:"name"` + Options []string `json:"options"` + SelectableOptionCount int `json:"selectable_option_count,omitempty"` } // resolveRecipientJID parses a phone number or JID string and resolves PN -> LID @@ -1818,16 +1821,14 @@ func startRESTServer(client *whatsmeow.Client, messageStore *MessageStore, port return } } - selectable := 1 - if req.SelectableOptionCount != nil { - selectable = *req.SelectableOptionCount - } - if selectable < 1 || selectable > len(req.Options) { - http.Error(w, "selectable_option_count must be between 1 and len(options)", http.StatusBadRequest) + // whatsmeow coerces out-of-range values to 0, which silently changes the + // poll's selection mode. Reject them upfront so the caller knows. + if req.SelectableOptionCount < 0 || req.SelectableOptionCount > len(req.Options) { + http.Error(w, "selectable_option_count must be 0 (unlimited) or between 1 and len(options)", http.StatusBadRequest) return } - success, message := sendWhatsAppPoll(client, req.Recipient, req.Name, req.Options, selectable) + success, message := sendWhatsAppPoll(client, req.Recipient, req.Name, req.Options, req.SelectableOptionCount) w.Header().Set("Content-Type", "application/json") if !success { diff --git a/whatsapp-mcp-server/tests/test_send_poll.py b/whatsapp-mcp-server/tests/test_send_poll.py index 77efb0e..6e3a311 100644 --- a/whatsapp-mcp-server/tests/test_send_poll.py +++ b/whatsapp-mcp-server/tests/test_send_poll.py @@ -36,11 +36,22 @@ def test_selectable_count_out_of_range(self): assert not ok assert "selectable_option_count" in msg - def test_selectable_count_zero_rejected(self): - ok, msg = send_poll("123@s.whatsapp.net", "Q?", ["a", "b"], selectable_option_count=0) + def test_selectable_count_negative_rejected(self): + ok, msg = send_poll("123@s.whatsapp.net", "Q?", ["a", "b"], selectable_option_count=-1) assert not ok assert "selectable_option_count" in msg + def test_selectable_count_zero_allowed_unlimited(self): + # whatsmeow treats 0 as "multi-select with no limit" — must remain valid. + with patch("whatsapp.requests.post") as mock_post: + mock_post.return_value.status_code = 200 + mock_post.return_value.json.return_value = {"success": True, "message": "Poll sent to 123"} + + ok, _ = send_poll("123@s.whatsapp.net", "Q?", ["a", "b"], selectable_option_count=0) + + assert ok + assert mock_post.call_args.kwargs["json"]["selectable_option_count"] == 0 + def test_valid_request_calls_bridge(self): with patch("whatsapp.requests.post") as mock_post: mock_post.return_value.status_code = 200 From 95670e7059bac69251fd4af6f4ba258506b19efa Mon Sep 17 00:00:00 2001 From: Ortes Date: Sat, 9 May 2026 11:55:23 -0400 Subject: [PATCH 5/7] feat(polls): add vote collection, vote_poll, list_polls, get_poll_results Closes the asymmetric send-only gap raised on PR #83: the bridge now captures inbound poll creation messages (previously dropped because extractTextContent has no PollCreationMessage branch), decrypts inbound PollUpdateMessage events via whatsmeow.Client.DecryptPollVote and persists votes to a new poll_votes table, and exposes a /api/vote/poll endpoint so agents can cast votes too. Outbound polls now persist their metadata and return message_id. MCP surface: - vote_poll: cast or clear a vote on a poll already in the local store - list_polls: enumerate captured polls - get_poll_results: aggregate vote counts per option Decryption needs the per-poll secret that whatsmeow stores when the creation message is processed live, so polls created before the bridge started will surface in list_polls (via history sync) but their votes are not recoverable. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 31 ++ whatsapp-bridge/main.go | 489 +++++++++++++++++- whatsapp-mcp-server/main.py | 87 ++++ .../tests/test_get_poll_results.py | 100 ++++ whatsapp-mcp-server/tests/test_list_polls.py | 102 ++++ whatsapp-mcp-server/tests/test_vote_poll.py | 62 +++ whatsapp-mcp-server/whatsapp.py | 214 ++++++++ 7 files changed, 1077 insertions(+), 8 deletions(-) create mode 100644 whatsapp-mcp-server/tests/test_get_poll_results.py create mode 100644 whatsapp-mcp-server/tests/test_list_polls.py create mode 100644 whatsapp-mcp-server/tests/test_vote_poll.py diff --git a/README.md b/README.md index c74eda2..14fcc76 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,37 @@ Send a poll to a contact or group. - "Send a poll to the team asking 'Lunch?' with options pizza, salad, sushi" +#### `vote_poll` + +Cast (or clear) a vote on an existing poll. The poll must already be in the bridge's local store — i.e. you sent it via `send_poll`, or the bridge was running when it arrived from another participant. + +**Parameters:** + +- `poll_message_id` (required): Message ID of the original poll creation message (returned by `send_poll`, or visible via `list_polls`). +- `poll_chat_jid` (required): Chat JID where the poll lives. +- `selected_options` (required): List of option names to vote for (must be a subset of the poll's options). Pass `[]` to clear a previous vote. + +#### `list_polls` + +List polls captured by the bridge, newest first. + +**Parameters:** + +- `chat_jid` (optional): Filter to a single chat. +- `limit` (optional): Max polls to return (default `20`). +- `page` (optional): Zero-indexed page for pagination. + +#### `get_poll_results` + +Aggregate vote counts for a single poll. + +**Parameters:** + +- `poll_message_id` (required) +- `poll_chat_jid` (required) + +Returns a `poll` block, an `options` array (each with `name`, `vote_count`, `voters`), and `total_voters`. **Note:** only votes received while the bridge was running are decryptable — whatsmeow needs the per-poll secret stored when the original creation message is processed live, so polls created before the bridge started will list correctly via `list_polls` but their votes won't be recoverable. + #### `send_file` Send a media file (image, video, document). diff --git a/whatsapp-bridge/main.go b/whatsapp-bridge/main.go index a8bf068..bd7ffd7 100644 --- a/whatsapp-bridge/main.go +++ b/whatsapp-bridge/main.go @@ -140,6 +140,30 @@ func NewMessageStore() (*MessageStore, error) { CREATE INDEX IF NOT EXISTS idx_calls_chat ON calls(chat_jid); CREATE INDEX IF NOT EXISTS idx_calls_timestamp ON calls(timestamp); + + CREATE TABLE IF NOT EXISTS polls ( + message_id TEXT, + chat_jid TEXT, + sender TEXT, + is_from_me BOOLEAN, + name TEXT, + options_json TEXT, + selectable_count INTEGER, + is_group BOOLEAN, + timestamp TIMESTAMP, + PRIMARY KEY (message_id, chat_jid) + ); + + CREATE TABLE IF NOT EXISTS poll_votes ( + poll_message_id TEXT, + poll_chat_jid TEXT, + voter TEXT, + selected_options_json TEXT, + timestamp TIMESTAMP, + PRIMARY KEY (poll_message_id, poll_chat_jid, voter) + ); + + CREATE INDEX IF NOT EXISTS idx_poll_votes_poll ON poll_votes(poll_message_id, poll_chat_jid); `) if err != nil { _ = db.Close() @@ -720,6 +744,91 @@ func (store *MessageStore) MarkCallTerminated(callID, chatJID, reason string, en return err } +// Poll storage methods. +// +// Poll creation messages (inbound and outbound) are persisted to `polls` so +// that incoming PollUpdateMessage events — which carry only SHA-256 hashes of +// selected option names — can be decoded back into option names on read. +// Votes (decrypted via whatsmeow.Client.DecryptPollVote) land in `poll_votes` +// keyed by (poll_message_id, poll_chat_jid, voter), so a fresh vote from the +// same voter (WhatsApp delivers a new PollUpdateMessage on every change, +// including an empty SelectedOptions list to clear) replaces the previous one. + +// PollRecord mirrors a row from the `polls` table. +type PollRecord struct { + MessageID string + ChatJID string + Sender string + IsFromMe bool + Name string + Options []string + SelectableCount int + IsGroup bool + Timestamp time.Time +} + +// StorePoll inserts a poll creation record. Uses INSERT OR IGNORE so a +// later observation of the same poll (e.g. via history sync) does not +// overwrite the timestamp captured when the poll first arrived live. +func (store *MessageStore) StorePoll(messageID, chatJID, sender string, isFromMe bool, + name string, options []string, selectableCount int, isGroup bool, ts time.Time) error { + optionsJSON, err := json.Marshal(options) + if err != nil { + return fmt.Errorf("marshal poll options: %w", err) + } + _, err = store.db.Exec( + `INSERT OR IGNORE INTO polls + (message_id, chat_jid, sender, is_from_me, name, options_json, selectable_count, is_group, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + messageID, chatJID, sender, isFromMe, name, string(optionsJSON), selectableCount, isGroup, ts, + ) + return err +} + +// GetPoll fetches a poll by (messageID, chatJID). Returns nil with no error +// if the row does not exist, so callers can branch on "poll not seen yet". +func (store *MessageStore) GetPoll(messageID, chatJID string) (*PollRecord, error) { + var rec PollRecord + var optionsJSON string + err := store.db.QueryRow( + `SELECT message_id, chat_jid, sender, is_from_me, name, options_json, selectable_count, is_group, timestamp + FROM polls WHERE message_id = ? AND chat_jid = ?`, + messageID, chatJID, + ).Scan(&rec.MessageID, &rec.ChatJID, &rec.Sender, &rec.IsFromMe, &rec.Name, + &optionsJSON, &rec.SelectableCount, &rec.IsGroup, &rec.Timestamp) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + if err := json.Unmarshal([]byte(optionsJSON), &rec.Options); err != nil { + return nil, fmt.Errorf("unmarshal poll options: %w", err) + } + return &rec, nil +} + +// StorePollVote records a single voter's current selection. Uses INSERT OR +// REPLACE because WhatsApp sends a fresh PollUpdateMessage every time a voter +// changes their selection (and an empty selection list to clear the vote). +func (store *MessageStore) StorePollVote(pollMsgID, pollChatJID, voter string, + selectedOptions []string, ts time.Time) error { + if selectedOptions == nil { + selectedOptions = []string{} + } + selectedJSON, err := json.Marshal(selectedOptions) + if err != nil { + return fmt.Errorf("marshal selected options: %w", err) + } + _, err = store.db.Exec( + `INSERT OR REPLACE INTO poll_votes + (poll_message_id, poll_chat_jid, voter, selected_options_json, timestamp) + VALUES (?, ?, ?, ?, ?)`, + pollMsgID, pollChatJID, voter, string(selectedJSON), ts, + ) + return err +} + // Get all chats func (store *MessageStore) GetChats() (map[string]time.Time, error) { rows, err := store.db.Query("SELECT jid, last_message_time FROM chats ORDER BY last_message_time DESC") @@ -1119,6 +1228,14 @@ type SendPollRequest struct { SelectableOptionCount int `json:"selectable_option_count,omitempty"` } +// SendPollResponse is the response for /api/send/poll. MessageID lets the +// caller subsequently look up vote results without scraping messages.db. +type SendPollResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + MessageID string `json:"message_id,omitempty"` +} + // resolveRecipientJID parses a phone number or JID string and resolves PN -> LID // for personal chats, matching the behavior used when sending text/media messages. func resolveRecipientJID(client *whatsmeow.Client, recipient string) (types.JID, error) { @@ -1160,23 +1277,280 @@ func resolveRecipientJID(client *whatsmeow.Client, recipient string) (types.JID, return recipientJID, nil } -// sendWhatsAppPoll builds and sends a poll creation message. -func sendWhatsAppPoll(client *whatsmeow.Client, recipient, name string, options []string, selectableOptionCount int) (bool, string) { +// sendWhatsAppPoll builds and sends a poll creation message. The poll +// metadata (options, selectable count, message id) is persisted locally so +// that subsequent vote events can be matched back to option names and so +// that vote_poll callers can look up the poll without scraping messages.db. +func sendWhatsAppPoll(client *whatsmeow.Client, messageStore *MessageStore, + recipient, name string, options []string, selectableOptionCount int) (bool, string, string) { if !client.IsConnected() { - return false, "Not connected to WhatsApp" + return false, "Not connected to WhatsApp", "" } recipientJID, err := resolveRecipientJID(client, recipient) if err != nil { - return false, err.Error() + return false, err.Error(), "" } msg := client.BuildPollCreation(name, options, selectableOptionCount) - if _, err := client.SendMessage(context.Background(), recipientJID, msg); err != nil { - return false, fmt.Sprintf("Error sending poll: %v", err) + resp, err := client.SendMessage(context.Background(), recipientJID, msg) + if err != nil { + return false, fmt.Sprintf("Error sending poll: %v", err), "" + } + + chatJID := recipientJID.String() + timestamp := resp.Timestamp + if timestamp.IsZero() { + timestamp = time.Now() + } + + sender := "" + if client.Store.ID != nil { + sender = client.Store.ID.ToNonAD().User + } + isGroup := recipientJID.Server == types.GroupServer + + if err := messageStore.StoreChat(chatJID, "", timestamp); err != nil { + fmt.Printf("Warning: failed to upsert chat for poll: %v\n", err) + } + if err := messageStore.StorePoll( + resp.ID, chatJID, sender, true, + name, options, selectableOptionCount, isGroup, timestamp, + ); err != nil { + fmt.Printf("Warning: failed to persist poll metadata: %v\n", err) + } + // Surface the poll question in `messages` so list_messages shows the + // agent's own polls alongside everything else. + if err := messageStore.StoreMessage( + resp.ID, chatJID, sender, name, timestamp, true, + "poll", "", "", nil, nil, nil, 0, + ); err != nil { + fmt.Printf("Warning: failed to persist poll message: %v\n", err) + } + + return true, fmt.Sprintf("Poll sent to %s", recipient), resp.ID +} + +// VotePollRequest represents the request body for the /api/vote/poll endpoint. +// `selected_options` may be empty — that clears the voter's previous selection. +type VotePollRequest struct { + PollMessageID string `json:"poll_message_id"` + PollChatJID string `json:"poll_chat_jid"` + SelectedOptions []string `json:"selected_options"` +} + +// voteOnWhatsAppPoll casts a vote on an existing poll. The poll must already +// be in our local `polls` table (i.e. the bridge saw the original creation +// message), since whatsmeow.Client.BuildPollVote needs the original poll's +// MessageInfo to derive the per-poll secret. +func voteOnWhatsAppPoll(client *whatsmeow.Client, messageStore *MessageStore, + pollMsgID, pollChatJID string, selectedOptions []string) (bool, string) { + if !client.IsConnected() { + return false, "Not connected to WhatsApp" + } + if pollMsgID == "" { + return false, "poll_message_id is required" + } + if pollChatJID == "" { + return false, "poll_chat_jid is required" + } + + poll, err := messageStore.GetPoll(pollMsgID, pollChatJID) + if err != nil { + return false, fmt.Sprintf("Failed to look up poll: %v", err) + } + if poll == nil { + return false, "Poll not found in local store. The poll must have been observed live by the bridge." + } + + // Validate that every selected option exists on the poll, and that the + // caller is not exceeding the poll's selectable_option_count limit. + known := make(map[string]struct{}, len(poll.Options)) + for _, o := range poll.Options { + known[o] = struct{}{} + } + for _, o := range selectedOptions { + if _, ok := known[o]; !ok { + return false, fmt.Sprintf("Option %q is not part of this poll", o) + } + } + if poll.SelectableCount > 0 && len(selectedOptions) > poll.SelectableCount { + return false, fmt.Sprintf("This poll allows at most %d selections", poll.SelectableCount) + } + + chatJID, err := types.ParseJID(poll.ChatJID) + if err != nil { + return false, fmt.Sprintf("Invalid poll chat JID %q: %v", poll.ChatJID, err) + } + + // Reconstruct the original poll's MessageInfo. Sender is stored as the + // resolved user-part (consistent with messages.sender), so we attach the + // default user server to parse it back into a JID. + senderJID := types.JID{User: poll.Sender, Server: types.DefaultUserServer} + info := &types.MessageInfo{ + MessageSource: types.MessageSource{ + Chat: chatJID, + Sender: senderJID, + IsFromMe: poll.IsFromMe, + IsGroup: poll.IsGroup, + }, + ID: poll.MessageID, + Timestamp: poll.Timestamp, + } + + ctx := context.Background() + voteMsg, err := client.BuildPollVote(ctx, info, selectedOptions) + if err != nil { + return false, fmt.Sprintf("Failed to build poll vote: %v", err) + } + + if _, err := client.SendMessage(ctx, chatJID, voteMsg); err != nil { + return false, fmt.Sprintf("Failed to send poll vote: %v", err) + } + + // whatsmeow does not deliver our own outgoing PollUpdateMessage as an + // *events.Message, so we must persist our self-vote ourselves to keep + // get_poll_results consistent. + voter := "" + if client.Store.ID != nil { + voter = client.Store.ID.ToNonAD().User + } + if err := messageStore.StorePollVote(poll.MessageID, poll.ChatJID, voter, selectedOptions, time.Now()); err != nil { + fmt.Printf("Warning: failed to persist self poll vote: %v\n", err) + } + + if len(selectedOptions) == 0 { + return true, "Cleared vote on poll" + } + return true, fmt.Sprintf("Voted on poll (%d selection(s))", len(selectedOptions)) +} + +// pollCreationInfo is a normalised view of WhatsApp's five PollCreationMessage +// oneof variants (legacy, V2, V3, V4 wrapped in FutureProofMessage, V5). +type pollCreationInfo struct { + Name string + Options []string + SelectableCount int +} + +// extractPollCreation returns the poll creation payload from any of the +// PollCreationMessage* oneof slots, or nil if the message isn't a poll +// creation. Callers should treat nil as "not a poll". +func extractPollCreation(msg *waProto.Message) *pollCreationInfo { + if msg == nil { + return nil + } + candidates := []*waProto.PollCreationMessage{ + msg.GetPollCreationMessage(), + msg.GetPollCreationMessageV2(), + msg.GetPollCreationMessageV3(), + msg.GetPollCreationMessageV5(), + } + if v4 := msg.GetPollCreationMessageV4(); v4 != nil { + // V4 wraps the poll creation in a FutureProofMessage. + if inner := v4.GetMessage(); inner != nil { + candidates = append(candidates, + inner.GetPollCreationMessage(), + inner.GetPollCreationMessageV2(), + inner.GetPollCreationMessageV3(), + inner.GetPollCreationMessageV5(), + ) + } + } + for _, pc := range candidates { + if pc == nil { + continue + } + opts := pc.GetOptions() + names := make([]string, 0, len(opts)) + for _, o := range opts { + if o == nil { + continue + } + names = append(names, o.GetOptionName()) + } + if len(names) == 0 { + continue + } + return &pollCreationInfo{ + Name: pc.GetName(), + Options: names, + SelectableCount: int(pc.GetSelectableOptionsCount()), + } + } + return nil +} + +// handlePollVote decrypts an incoming PollUpdateMessage and persists the +// voter's selection to `poll_votes`. The vote payload is encrypted with a +// per-poll secret that whatsmeow stored when the original poll-creation +// message was processed, so this only succeeds for polls observed live (or +// for polls whose secret is otherwise present in the whatsmeow store). +func handlePollVote(client *whatsmeow.Client, messageStore *MessageStore, + msg *events.Message, pollUpdate *waProto.PollUpdateMessage, logger waLog.Logger) { + creationKey := pollUpdate.GetPollCreationMessageKey() + if creationKey == nil { + logger.Warnf("Poll update from %s missing creation message key", msg.Info.SourceString()) + return + } + pollMsgID := creationKey.GetID() + pollChatJID := creationKey.GetRemoteJID() + if pollMsgID == "" || pollChatJID == "" { + logger.Warnf("Poll update from %s missing creation message id/jid", msg.Info.SourceString()) + return } - return true, fmt.Sprintf("Poll sent to %s", recipient) + // Resolve the poll's chat JID through the same LID -> phone mapping the + // rest of the bridge uses, so we look up the poll under the canonical key. + if parsed, err := types.ParseJID(pollChatJID); err == nil { + resolved := resolveLIDChat(client, parsed, types.EmptyJID, types.EmptyJID, false) + pollChatJID = resolved.String() + } + + poll, err := messageStore.GetPoll(pollMsgID, pollChatJID) + if err != nil { + logger.Warnf("Failed to look up poll %s/%s: %v", pollChatJID, pollMsgID, err) + return + } + if poll == nil { + // We never saw the original poll creation — typically because the + // bridge wasn't running when the poll was sent. Without the option + // list we cannot decode the SHA-256 hashes back to names. + logger.Warnf("Received vote for unknown poll %s/%s — ignoring", pollChatJID, pollMsgID) + return + } + + pollVote, err := client.DecryptPollVote(context.Background(), msg) + if err != nil { + logger.Warnf("Failed to decrypt poll vote for %s/%s: %v", pollChatJID, pollMsgID, err) + return + } + + // Match each selected hash back to an option name by re-hashing our + // stored option list. Unknown hashes (option list out of sync) are + // silently dropped — there is nothing meaningful to record. + optionHashes := whatsmeow.HashPollOptions(poll.Options) + selected := make([]string, 0, len(pollVote.GetSelectedOptions())) + for _, h := range pollVote.GetSelectedOptions() { + for i, oh := range optionHashes { + if bytes.Equal(h, oh) { + selected = append(selected, poll.Options[i]) + break + } + } + } + + // Resolve the voter the same way handleMessage resolves message senders, + // so the voter user-part matches `messages.sender` rows. + resolvedSender := resolveUserJID(client, msg.Info.Sender, senderAltForMessage(client, msg.Info)) + voter := resolvedSender.User + + if err := messageStore.StorePollVote(pollMsgID, pollChatJID, voter, selected, msg.Info.Timestamp); err != nil { + logger.Warnf("Failed to store poll vote for %s/%s: %v", pollChatJID, pollMsgID, err) + return + } + logger.Infof("Stored poll vote: poll=%s/%s voter=%s selected=%v", + pollChatJID, pollMsgID, voter, selected) } // Extract quoted message info from ContextInfo @@ -1390,6 +1764,14 @@ func handleMessage(client *whatsmeow.Client, messageStore *MessageStore, msg *ev } } + // Poll updates carry encrypted vote hashes — handle them inline and stop + // before the regular content/media path, since they have no displayable + // content of their own. + if pollUpdate := msg.Message.GetPollUpdateMessage(); pollUpdate != nil { + handlePollVote(client, messageStore, msg, pollUpdate, logger) + return + } + // Extract text content content := extractTextContent(msg.Message) @@ -1399,6 +1781,22 @@ func handleMessage(client *whatsmeow.Client, messageStore *MessageStore, msg *ev // Extract quoted message info quotedMessageId, quotedSender, quotedContent := extractQuotedMessageInfo(msg.Message) + // Capture poll creation: persist the option list to `polls` and surface + // the question as message content so list_messages still shows it. + if poll := extractPollCreation(msg.Message); poll != nil { + mediaType = "poll" + if content == "" { + content = poll.Name + } + if err := messageStore.StorePoll( + msg.Info.ID, chatJID, sender, msg.Info.IsFromMe, + poll.Name, poll.Options, poll.SelectableCount, + msg.Info.IsGroup, msg.Info.Timestamp, + ); err != nil { + logger.Warnf("Failed to persist poll: %v", err) + } + } + // Skip if there's no content and no media if content == "" && mediaType == "" { return @@ -1828,7 +2226,55 @@ func startRESTServer(client *whatsmeow.Client, messageStore *MessageStore, port return } - success, message := sendWhatsAppPoll(client, req.Recipient, req.Name, req.Options, req.SelectableOptionCount) + success, message, messageID := sendWhatsAppPoll(client, messageStore, req.Recipient, req.Name, req.Options, req.SelectableOptionCount) + + w.Header().Set("Content-Type", "application/json") + if !success { + w.WriteHeader(http.StatusInternalServerError) + } + _ = json.NewEncoder(w).Encode(SendPollResponse{ + Success: success, + Message: message, + MessageID: messageID, + }) + }) + + // Handler for casting a vote on an existing poll. The poll must already + // be in the local `polls` table (i.e. the bridge saw the original poll + // creation message), since whatsmeow needs the per-poll secret to encrypt + // the vote payload. + http.HandleFunc("/api/vote/poll", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req VotePollRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request format", http.StatusBadRequest) + return + } + + if strings.TrimSpace(req.PollMessageID) == "" { + http.Error(w, "poll_message_id is required", http.StatusBadRequest) + return + } + if strings.TrimSpace(req.PollChatJID) == "" { + http.Error(w, "poll_chat_jid is required", http.StatusBadRequest) + return + } + // Reject vote payloads with empty or whitespace-only entries upfront. + // An empty `selected_options` array is allowed (clears the vote) but + // a `[""]` entry would fail option matching downstream with a less + // helpful error. + for _, o := range req.SelectedOptions { + if strings.TrimSpace(o) == "" { + http.Error(w, "selected_options entries must not be empty", http.StatusBadRequest) + return + } + } + + success, message := voteOnWhatsAppPoll(client, messageStore, req.PollMessageID, req.PollChatJID, req.SelectedOptions) w.Header().Set("Content-Type", "application/json") if !success { @@ -2602,6 +3048,21 @@ func handleHistorySync(client *whatsmeow.Client, messageStore *MessageStore, his mediaType, filename, url, mediaKey, fileSHA256, fileEncSHA256, fileLength = extractMediaInfo(msg.Message.Message, timestamp, histMsgID) } + // Capture poll creations from history sync so list_polls can + // surface them. Vote backfill is intentionally not attempted + // here — votes need the per-poll secret which whatsmeow only + // reliably stores when the creation message is processed live. + var histPoll *pollCreationInfo + if msg.Message.Message != nil { + histPoll = extractPollCreation(msg.Message.Message) + } + if histPoll != nil { + mediaType = "poll" + if content == "" { + content = histPoll.Name + } + } + // Log the message content for debugging logger.Infof("Message content: %v, Media Type: %v", content, mediaType) @@ -2682,6 +3143,18 @@ func handleHistorySync(client *whatsmeow.Client, messageStore *MessageStore, his msgTimestamp.Format("2006-01-02 15:04:05"), sender, chatJID, content) } } + + if histPoll != nil && msgID != "" { + // History-sync IsGroup is inferable from the chat server. + isGroup := jid.Server == types.GroupServer + if pollErr := messageStore.StorePoll( + msgID, chatJID, sender, isFromMe, + histPoll.Name, histPoll.Options, histPoll.SelectableCount, + isGroup, msgTimestamp, + ); pollErr != nil { + logger.Warnf("Failed to store history poll %s/%s: %v", chatJID, msgID, pollErr) + } + } } } } diff --git a/whatsapp-mcp-server/main.py b/whatsapp-mcp-server/main.py index 120bfc9..851d5c8 100644 --- a/whatsapp-mcp-server/main.py +++ b/whatsapp-mcp-server/main.py @@ -46,6 +46,15 @@ from whatsapp import ( send_poll as whatsapp_send_poll, ) +from whatsapp import ( + vote_poll as whatsapp_vote_poll, +) +from whatsapp import ( + list_polls as whatsapp_list_polls, +) +from whatsapp import ( + get_poll_results as whatsapp_get_poll_results, +) # Initialize FastMCP server mcp = FastMCP("whatsapp") @@ -343,6 +352,84 @@ def send_poll( return {"success": success, "message": status_message} +@mcp.tool() +def vote_poll( + poll_message_id: str, + poll_chat_jid: str, + selected_options: list[str], +) -> dict[str, Any]: + """Cast a vote on an existing WhatsApp poll. + + The poll must already be in the bridge's local store — i.e. either you sent + it via send_poll, or the bridge was running when it arrived from another + participant. Use list_polls to discover polls and their options. + + Args: + poll_message_id: The message ID of the original poll creation message. + poll_chat_jid: The chat JID where the poll was sent (e.g. + "123456789@s.whatsapp.net" or "123456789@g.us"). + selected_options: List of option names to vote for. Must be a subset of + the poll's option list (case-sensitive). Pass an empty list to + clear a previous vote. For single-choice polls pass exactly + one option; for multi-choice polls pass up to the poll's + selectable_option_count. + + Returns: + A dictionary containing success status and a status message + """ + if selected_options is None: + selected_options = [] + success, status_message = whatsapp_vote_poll(poll_message_id, poll_chat_jid, selected_options) + return {"success": success, "message": status_message} + + +@mcp.tool() +def list_polls( + chat_jid: str | None = None, + limit: int = 20, + page: int = 0, +) -> list[dict[str, Any]]: + """List polls captured by the bridge, newest first. + + Polls created before the bridge was started may surface via WhatsApp's + history sync but their votes are not recoverable. + + Args: + chat_jid: If provided, only polls in this chat are returned. + limit: Maximum number of polls to return. + page: Zero-indexed page number for pagination. + + Returns: + A list of poll dictionaries with message_id, chat_jid, sender, name, + options, selectable_option_count, and timestamp. + """ + return whatsapp_list_polls(chat_jid=chat_jid, limit=limit, page=page) + + +@mcp.tool() +def get_poll_results(poll_message_id: str, poll_chat_jid: str) -> dict[str, Any]: + """Get aggregated vote counts for a poll. + + Returns vote counts for each poll option along with the list of voters per + option. Only votes received while the bridge was running are counted — + decryption needs a per-poll secret that whatsmeow stores when the + creation message is processed live. + + Args: + poll_message_id: The message ID of the original poll creation message. + poll_chat_jid: The chat JID where the poll was sent. + + Returns: + A dictionary with `poll`, `options` (each with name, vote_count, voters), + and `total_voters`. Returns `{"success": False, "message": "Poll not + found"}` if the poll is not in the local store. + """ + result = whatsapp_get_poll_results(poll_message_id, poll_chat_jid) + if result is None: + return {"success": False, "message": "Poll not found in local store"} + return result + + @mcp.tool() def send_file(recipient: str, media_path: str) -> dict[str, Any]: """Send a file such as a picture, raw audio, video or document via WhatsApp to the specified recipient. For group messages use the JID. diff --git a/whatsapp-mcp-server/tests/test_get_poll_results.py b/whatsapp-mcp-server/tests/test_get_poll_results.py new file mode 100644 index 0000000..5ce6b18 --- /dev/null +++ b/whatsapp-mcp-server/tests/test_get_poll_results.py @@ -0,0 +1,100 @@ +"""Tests for get_poll_results aggregation.""" + +import json +import sqlite3 + +import pytest + +import whatsapp + + +def _make_db_with_poll(path): + conn = sqlite3.connect(path) + conn.executescript( + """ + CREATE TABLE polls ( + message_id TEXT, + chat_jid TEXT, + sender TEXT, + is_from_me BOOLEAN, + name TEXT, + options_json TEXT, + selectable_count INTEGER, + is_group BOOLEAN, + timestamp TIMESTAMP, + PRIMARY KEY (message_id, chat_jid) + ); + CREATE TABLE poll_votes ( + poll_message_id TEXT, + poll_chat_jid TEXT, + voter TEXT, + selected_options_json TEXT, + timestamp TIMESTAMP, + PRIMARY KEY (poll_message_id, poll_chat_jid, voter) + ); + """ + ) + conn.execute( + "INSERT INTO polls VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + "POLL1", + "999@g.us", + "111", + 1, + "Lunch?", + json.dumps(["pizza", "salad", "ramen"]), + 2, # multi-select up to 2 + 1, + "2024-02-01 12:00:00+00:00", + ), + ) + # Single-select voter + conn.execute( + "INSERT INTO poll_votes VALUES (?, ?, ?, ?, ?)", + ("POLL1", "999@g.us", "alice", json.dumps(["pizza"]), "2024-02-01 12:01:00+00:00"), + ) + # Multi-select voter (counts toward two options but is a single voter) + conn.execute( + "INSERT INTO poll_votes VALUES (?, ?, ?, ?, ?)", + ("POLL1", "999@g.us", "bob", json.dumps(["pizza", "ramen"]), "2024-02-01 12:02:00+00:00"), + ) + # Voter who cleared their vote — should not contribute to total_voters. + conn.execute( + "INSERT INTO poll_votes VALUES (?, ?, ?, ?, ?)", + ("POLL1", "999@g.us", "carol", json.dumps([]), "2024-02-01 12:03:00+00:00"), + ) + conn.commit() + conn.close() + + +@pytest.fixture +def db(tmp_path, monkeypatch): + db_path = tmp_path / "messages.db" + _make_db_with_poll(str(db_path)) + monkeypatch.setattr(whatsapp, "MESSAGES_DB_PATH", str(db_path)) + return db_path + + +def test_get_poll_results_aggregates_votes(db): + result = whatsapp.get_poll_results("POLL1", "999@g.us") + assert result is not None + assert result["poll"]["name"] == "Lunch?" + assert result["total_voters"] == 2 # alice + bob; carol cleared + + by_option = {opt["name"]: opt for opt in result["options"]} + assert by_option["pizza"]["vote_count"] == 2 + assert sorted(by_option["pizza"]["voters"]) == ["alice", "bob"] + assert by_option["ramen"]["vote_count"] == 1 + assert by_option["ramen"]["voters"] == ["bob"] + # Salad got no votes but must still appear in the ballot. + assert by_option["salad"]["vote_count"] == 0 + assert by_option["salad"]["voters"] == [] + + +def test_get_poll_results_preserves_option_order(db): + result = whatsapp.get_poll_results("POLL1", "999@g.us") + assert [opt["name"] for opt in result["options"]] == ["pizza", "salad", "ramen"] + + +def test_get_poll_results_missing_poll_returns_none(db): + assert whatsapp.get_poll_results("DOES_NOT_EXIST", "999@g.us") is None diff --git a/whatsapp-mcp-server/tests/test_list_polls.py b/whatsapp-mcp-server/tests/test_list_polls.py new file mode 100644 index 0000000..1fb4c51 --- /dev/null +++ b/whatsapp-mcp-server/tests/test_list_polls.py @@ -0,0 +1,102 @@ +"""Tests for list_polls.""" + +import json +import sqlite3 + +import pytest + +import whatsapp + + +def _make_polls_db(path): + conn = sqlite3.connect(path) + conn.executescript( + """ + CREATE TABLE polls ( + message_id TEXT, + chat_jid TEXT, + sender TEXT, + is_from_me BOOLEAN, + name TEXT, + options_json TEXT, + selectable_count INTEGER, + is_group BOOLEAN, + timestamp TIMESTAMP, + PRIMARY KEY (message_id, chat_jid) + ); + """ + ) + rows = [ + ( + "POLL_OLD", + "111@s.whatsapp.net", + "111", + 0, + "Old?", + json.dumps(["a", "b"]), + 1, + 0, + "2024-01-01 09:00:00+00:00", + ), + ( + "POLL_MID", + "111@s.whatsapp.net", + "111", + 1, + "Lunch?", + json.dumps(["pizza", "salad", "ramen"]), + 1, + 0, + "2024-02-01 12:00:00+00:00", + ), + ( + "POLL_NEW", + "222@g.us", + "999", + 0, + "Outing?", + json.dumps(["beach", "park"]), + 2, + 1, + "2024-03-01 15:00:00+00:00", + ), + ] + conn.executemany( + "INSERT INTO polls VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + rows, + ) + conn.commit() + conn.close() + + +@pytest.fixture +def polls_db(tmp_path, monkeypatch): + db_path = tmp_path / "messages.db" + _make_polls_db(str(db_path)) + monkeypatch.setattr(whatsapp, "MESSAGES_DB_PATH", str(db_path)) + return db_path + + +def test_list_polls_orders_newest_first(polls_db): + polls = whatsapp.list_polls() + assert [p["message_id"] for p in polls] == ["POLL_NEW", "POLL_MID", "POLL_OLD"] + + +def test_list_polls_filters_by_chat(polls_db): + polls = whatsapp.list_polls(chat_jid="111@s.whatsapp.net") + assert {p["message_id"] for p in polls} == {"POLL_OLD", "POLL_MID"} + + +def test_list_polls_returns_options_as_list(polls_db): + polls = whatsapp.list_polls(chat_jid="222@g.us") + assert polls[0]["options"] == ["beach", "park"] + assert polls[0]["selectable_option_count"] == 2 + assert polls[0]["is_from_me"] is False + + +def test_list_polls_pagination(polls_db): + page0 = whatsapp.list_polls(limit=2, page=0) + page1 = whatsapp.list_polls(limit=2, page=1) + assert len(page0) == 2 + assert len(page1) == 1 + assert page0[0]["message_id"] != page1[0]["message_id"] diff --git a/whatsapp-mcp-server/tests/test_vote_poll.py b/whatsapp-mcp-server/tests/test_vote_poll.py new file mode 100644 index 0000000..4d2ef11 --- /dev/null +++ b/whatsapp-mcp-server/tests/test_vote_poll.py @@ -0,0 +1,62 @@ +"""Tests for vote_poll input validation and bridge call shape.""" + +from unittest.mock import patch + +from whatsapp import vote_poll + + +class TestVotePollValidation: + def test_missing_message_id(self): + ok, msg = vote_poll("", "123@s.whatsapp.net", ["a"]) + assert not ok + assert "poll_message_id" in msg + + def test_missing_chat_jid(self): + ok, msg = vote_poll("MSG123", "", ["a"]) + assert not ok + assert "poll_chat_jid" in msg + + def test_blank_option(self): + ok, msg = vote_poll("MSG123", "123@s.whatsapp.net", ["a", " "]) + assert not ok + assert "must not be empty" in msg + + def test_empty_selection_clears_vote(self): + # An empty list is the explicit way to clear a vote — must not be + # rejected client-side; the bridge handles it. + with patch("whatsapp.requests.post") as mock_post: + mock_post.return_value.status_code = 200 + mock_post.return_value.json.return_value = {"success": True, "message": "Cleared vote on poll"} + + ok, _ = vote_poll("MSG123", "123@s.whatsapp.net", []) + + assert ok + assert mock_post.call_args.kwargs["json"]["selected_options"] == [] + + def test_valid_request_calls_bridge(self): + with patch("whatsapp.requests.post") as mock_post: + mock_post.return_value.status_code = 200 + mock_post.return_value.json.return_value = {"success": True, "message": "Voted on poll (1 selection(s))"} + + ok, msg = vote_poll("MSG123", "123@s.whatsapp.net", ["pizza"]) + + assert ok + assert "Voted" in msg + mock_post.assert_called_once() + call = mock_post.call_args + assert call.kwargs["json"] == { + "poll_message_id": "MSG123", + "poll_chat_jid": "123@s.whatsapp.net", + "selected_options": ["pizza"], + } + assert call.args[0].endswith("/vote/poll") + + def test_bridge_error_propagates(self): + with patch("whatsapp.requests.post") as mock_post: + mock_post.return_value.status_code = 500 + mock_post.return_value.text = "Poll not found in local store." + + ok, msg = vote_poll("UNKNOWN", "123@s.whatsapp.net", ["pizza"]) + + assert not ok + assert "500" in msg diff --git a/whatsapp-mcp-server/whatsapp.py b/whatsapp-mcp-server/whatsapp.py index 7762411..8c1ba1e 100644 --- a/whatsapp-mcp-server/whatsapp.py +++ b/whatsapp-mcp-server/whatsapp.py @@ -63,6 +63,32 @@ class MessageContext: after: list[Message] +@dataclass +class Poll: + message_id: str + chat_jid: str + sender: str + is_from_me: bool + name: str + options: list[str] + selectable_option_count: int + timestamp: datetime + + +@dataclass +class PollOptionResult: + name: str + vote_count: int + voters: list[str] + + +@dataclass +class PollResult: + poll: Poll + options: list[PollOptionResult] + total_voters: int + + def msg_to_dict(message: Message, include_sender_name: bool = True) -> dict[str, Any]: """Convert a Message dataclass to a dictionary for JSON serialization.""" # Extract phone number from JID (e.g., "1234567890@s.whatsapp.net" -> "1234567890") @@ -117,6 +143,32 @@ def contact_to_dict(contact: "Contact") -> dict[str, Any]: return {"phone_number": contact.phone_number, "name": contact.name, "jid": contact.jid} +def poll_to_dict(poll: "Poll") -> dict[str, Any]: + """Convert a Poll dataclass to a dictionary for JSON serialization.""" + return { + "message_id": poll.message_id, + "chat_jid": poll.chat_jid, + "sender": poll.sender, + "is_from_me": poll.is_from_me, + "name": poll.name, + "options": list(poll.options), + "selectable_option_count": poll.selectable_option_count, + "timestamp": poll.timestamp.isoformat() if poll.timestamp else None, + } + + +def poll_result_to_dict(result: "PollResult") -> dict[str, Any]: + """Convert a PollResult dataclass to a dictionary for JSON serialization.""" + return { + "poll": poll_to_dict(result.poll), + "options": [ + {"name": o.name, "vote_count": o.vote_count, "voters": list(o.voters)} + for o in result.options + ], + "total_voters": result.total_voters, + } + + def _sender_aliases(value: str) -> list[str]: # messages.sender is written inconsistently: the same contact may appear as # bare phone ("13232432100"), full phone JID ("13232432100@s.whatsapp.net"), @@ -1029,6 +1081,168 @@ def send_poll( return False, f"Unexpected error: {str(e)}" +def vote_poll( + poll_message_id: str, + poll_chat_jid: str, + selected_options: list[str], +) -> tuple[bool, str]: + """Cast (or clear) a vote on an existing poll. + + The poll must already be in the bridge's local store — i.e. either the + bridge sent it via send_poll, or the bridge was running when it arrived + from another participant. An empty `selected_options` list clears the + voter's previous selection. + """ + # Validation is intentionally duplicated in whatsapp-bridge/main.go's + # /api/vote/poll handler so each layer is safe at its boundary. + try: + if not poll_message_id or not poll_message_id.strip(): + return False, "poll_message_id must be provided" + if not poll_chat_jid or not poll_chat_jid.strip(): + return False, "poll_chat_jid must be provided" + if selected_options is None: + selected_options = [] + if any(not opt or not opt.strip() for opt in selected_options): + return False, "selected_options entries must not be empty" + + url = f"{WHATSAPP_API_BASE_URL}/vote/poll" + payload = { + "poll_message_id": poll_message_id, + "poll_chat_jid": poll_chat_jid, + "selected_options": selected_options, + } + response = requests.post(url, json=payload) + + if response.status_code == 200: + result = response.json() + return result.get("success", False), result.get("message", "Unknown response") + else: + return False, f"Error: HTTP {response.status_code} - {response.text}" + + except requests.RequestException as e: + return False, f"Request error: {str(e)}" + except json.JSONDecodeError: + return False, f"Error parsing response: {response.text}" + except Exception as e: + return False, f"Unexpected error: {str(e)}" + + +def _row_to_poll(row: tuple) -> Poll: + """Build a Poll from a `polls` table row in canonical column order.""" + options = json.loads(row[5]) if row[5] else [] + timestamp_raw = row[8] + timestamp = datetime.fromisoformat(timestamp_raw) if timestamp_raw else datetime.fromtimestamp(0) + return Poll( + message_id=row[0], + chat_jid=row[1], + sender=row[2], + is_from_me=bool(row[3]), + name=row[4], + options=options, + selectable_option_count=int(row[6] or 0), + timestamp=timestamp, + ) + + +_POLL_COLUMNS = ( + "message_id, chat_jid, sender, is_from_me, name, options_json, " + "selectable_count, is_group, timestamp" +) + + +def list_polls( + chat_jid: str | None = None, + limit: int = 20, + page: int = 0, +) -> list[dict[str, Any]]: + """List polls captured by the bridge, newest first. + + Polls created before the bridge was running may surface via WhatsApp's + history sync but their votes are not recoverable (decryption needs a + per-poll secret that whatsmeow only stores when the creation message is + processed live). + """ + try: + conn = sqlite3.connect(MESSAGES_DB_PATH) + cursor = conn.cursor() + + sql_parts = [f"SELECT {_POLL_COLUMNS} FROM polls"] + params: list[Any] = [] + if chat_jid: + sql_parts.append("WHERE chat_jid = ?") + params.append(chat_jid) + sql_parts.append("ORDER BY timestamp DESC") + sql_parts.append("LIMIT ? OFFSET ?") + params.extend([limit, page * limit]) + + cursor.execute(" ".join(sql_parts), tuple(params)) + rows = cursor.fetchall() + return [poll_to_dict(_row_to_poll(r)) for r in rows] + + except sqlite3.Error as e: + print(f"Database error: {e}") + return [] + finally: + if "conn" in locals(): + conn.close() + + +def get_poll_results(poll_message_id: str, poll_chat_jid: str) -> dict[str, Any] | None: + """Aggregate vote counts for a single poll. + + Returns None when the poll is not in the local store. Vote counts only + reflect votes the bridge was running to receive — see list_polls(). + """ + try: + conn = sqlite3.connect(MESSAGES_DB_PATH) + cursor = conn.cursor() + + cursor.execute( + f"SELECT {_POLL_COLUMNS} FROM polls WHERE message_id = ? AND chat_jid = ?", + (poll_message_id, poll_chat_jid), + ) + row = cursor.fetchone() + if row is None: + return None + poll = _row_to_poll(row) + + cursor.execute( + "SELECT voter, selected_options_json FROM poll_votes " + "WHERE poll_message_id = ? AND poll_chat_jid = ?", + (poll_message_id, poll_chat_jid), + ) + vote_rows = cursor.fetchall() + + # Preserve the poll's option order in the output, including options + # that received zero votes so callers can render the full ballot. + tally: dict[str, list[str]] = {opt: [] for opt in poll.options} + total_voters = 0 + for voter, selected_json in vote_rows: + selected = json.loads(selected_json) if selected_json else [] + # Empty selection means the voter cleared their vote — count them + # as "not voting" rather than as a voter. + if not selected: + continue + total_voters += 1 + for name in selected: + if name in tally: + tally[name].append(voter) + + option_results = [ + PollOptionResult(name=opt, vote_count=len(voters), voters=voters) + for opt, voters in tally.items() + ] + result = PollResult(poll=poll, options=option_results, total_voters=total_voters) + return poll_result_to_dict(result) + + except sqlite3.Error as e: + print(f"Database error: {e}") + return None + finally: + if "conn" in locals(): + conn.close() + + def send_file(recipient: str, media_path: str) -> tuple[bool, str]: try: # Validate input From 04db9cd34c68dc5c6a6ec851bc54252c7316f416 Mon Sep 17 00:00:00 2001 From: Ortes Date: Sat, 9 May 2026 12:14:50 -0400 Subject: [PATCH 6/7] fix(vote-poll): return 400/404 for client errors instead of 500 Validation failures (unknown option, exceeding selectable_count, empty IDs) now return HTTP 400, and a missing poll returns 404. Only genuine bridge-side failures (DB lookup, BuildPollVote, SendMessage) return 500. Co-Authored-By: Claude Opus 4.7 (1M context) --- whatsapp-bridge/main.go | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/whatsapp-bridge/main.go b/whatsapp-bridge/main.go index bd7ffd7..b4d34b9 100644 --- a/whatsapp-bridge/main.go +++ b/whatsapp-bridge/main.go @@ -1344,23 +1344,23 @@ type VotePollRequest struct { // message), since whatsmeow.Client.BuildPollVote needs the original poll's // MessageInfo to derive the per-poll secret. func voteOnWhatsAppPoll(client *whatsmeow.Client, messageStore *MessageStore, - pollMsgID, pollChatJID string, selectedOptions []string) (bool, string) { + pollMsgID, pollChatJID string, selectedOptions []string) (bool, int, string) { if !client.IsConnected() { - return false, "Not connected to WhatsApp" + return false, http.StatusServiceUnavailable, "Not connected to WhatsApp" } if pollMsgID == "" { - return false, "poll_message_id is required" + return false, http.StatusBadRequest, "poll_message_id is required" } if pollChatJID == "" { - return false, "poll_chat_jid is required" + return false, http.StatusBadRequest, "poll_chat_jid is required" } poll, err := messageStore.GetPoll(pollMsgID, pollChatJID) if err != nil { - return false, fmt.Sprintf("Failed to look up poll: %v", err) + return false, http.StatusInternalServerError, fmt.Sprintf("Failed to look up poll: %v", err) } if poll == nil { - return false, "Poll not found in local store. The poll must have been observed live by the bridge." + return false, http.StatusNotFound, "Poll not found in local store. The poll must have been observed live by the bridge." } // Validate that every selected option exists on the poll, and that the @@ -1371,16 +1371,16 @@ func voteOnWhatsAppPoll(client *whatsmeow.Client, messageStore *MessageStore, } for _, o := range selectedOptions { if _, ok := known[o]; !ok { - return false, fmt.Sprintf("Option %q is not part of this poll", o) + return false, http.StatusBadRequest, fmt.Sprintf("Option %q is not part of this poll", o) } } if poll.SelectableCount > 0 && len(selectedOptions) > poll.SelectableCount { - return false, fmt.Sprintf("This poll allows at most %d selections", poll.SelectableCount) + return false, http.StatusBadRequest, fmt.Sprintf("This poll allows at most %d selections", poll.SelectableCount) } chatJID, err := types.ParseJID(poll.ChatJID) if err != nil { - return false, fmt.Sprintf("Invalid poll chat JID %q: %v", poll.ChatJID, err) + return false, http.StatusInternalServerError, fmt.Sprintf("Invalid poll chat JID %q: %v", poll.ChatJID, err) } // Reconstruct the original poll's MessageInfo. Sender is stored as the @@ -1401,11 +1401,11 @@ func voteOnWhatsAppPoll(client *whatsmeow.Client, messageStore *MessageStore, ctx := context.Background() voteMsg, err := client.BuildPollVote(ctx, info, selectedOptions) if err != nil { - return false, fmt.Sprintf("Failed to build poll vote: %v", err) + return false, http.StatusInternalServerError, fmt.Sprintf("Failed to build poll vote: %v", err) } if _, err := client.SendMessage(ctx, chatJID, voteMsg); err != nil { - return false, fmt.Sprintf("Failed to send poll vote: %v", err) + return false, http.StatusInternalServerError, fmt.Sprintf("Failed to send poll vote: %v", err) } // whatsmeow does not deliver our own outgoing PollUpdateMessage as an @@ -1420,9 +1420,9 @@ func voteOnWhatsAppPoll(client *whatsmeow.Client, messageStore *MessageStore, } if len(selectedOptions) == 0 { - return true, "Cleared vote on poll" + return true, http.StatusOK, "Cleared vote on poll" } - return true, fmt.Sprintf("Voted on poll (%d selection(s))", len(selectedOptions)) + return true, http.StatusOK, fmt.Sprintf("Voted on poll (%d selection(s))", len(selectedOptions)) } // pollCreationInfo is a normalised view of WhatsApp's five PollCreationMessage @@ -2274,11 +2274,11 @@ func startRESTServer(client *whatsmeow.Client, messageStore *MessageStore, port } } - success, message := voteOnWhatsAppPoll(client, messageStore, req.PollMessageID, req.PollChatJID, req.SelectedOptions) + success, status, message := voteOnWhatsAppPoll(client, messageStore, req.PollMessageID, req.PollChatJID, req.SelectedOptions) w.Header().Set("Content-Type", "application/json") if !success { - w.WriteHeader(http.StatusInternalServerError) + w.WriteHeader(status) } _ = json.NewEncoder(w).Encode(SendMessageResponse{ Success: success, From 442a55917e463bb19adfe741455488ff652ee4f7 Mon Sep 17 00:00:00 2001 From: Ortes Date: Sat, 9 May 2026 12:44:57 -0400 Subject: [PATCH 7/7] fix(poll-vote): look up polls under both LID and PN forms of the chat JID Outbound polls are stored under the recipient's @lid form because resolveRecipientJID converts PN -> LID at send time, but inbound PollUpdateMessage events reference the chat in whatever form the vote sender's client used (often the @s.whatsapp.net form). resolveLIDChat only goes LID -> PN, so the lookup missed in the asymmetric direction and every vote on an outbound poll was silently dropped with a "unknown poll" warning. Try the literal value, then map LID<->PN both ways via the whatsmeow LID store before giving up. Co-Authored-By: Claude Opus 4.7 (1M context) --- whatsapp-bridge/main.go | 63 ++++++++++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/whatsapp-bridge/main.go b/whatsapp-bridge/main.go index b4d34b9..e9bc46d 100644 --- a/whatsapp-bridge/main.go +++ b/whatsapp-bridge/main.go @@ -1481,6 +1481,38 @@ func extractPollCreation(msg *waProto.Message) *pollCreationInfo { return nil } +// pollChatJIDCandidates returns the set of JID forms a poll could plausibly +// be stored under, in lookup order: the literal value, the LID->PN mapping, +// and the PN->LID mapping. Outbound polls land under the recipient's LID +// (resolveRecipientJID converts PN->LID at send time) while inbound vote +// events may carry the chat in either form, so we try both. +func pollChatJIDCandidates(client *whatsmeow.Client, raw string) []string { + seen := map[string]struct{}{raw: {}} + out := []string{raw} + parsed, err := types.ParseJID(raw) + if err != nil || client == nil || client.Store == nil || client.Store.LIDs == nil { + return out + } + add := func(s string) { + if _, ok := seen[s]; ok { + return + } + seen[s] = struct{}{} + out = append(out, s) + } + ctx := context.Background() + if parsed.Server == types.HiddenUserServer { + if pn, lerr := client.Store.LIDs.GetPNForLID(ctx, parsed); lerr == nil && !pn.IsEmpty() { + add(pn.ToNonAD().String()) + } + } else if parsed.Server == types.DefaultUserServer { + if lid, lerr := client.Store.LIDs.GetLIDForPN(ctx, parsed); lerr == nil && !lid.IsEmpty() { + add(lid.ToNonAD().String()) + } + } + return out +} + // handlePollVote decrypts an incoming PollUpdateMessage and persists the // voter's selection to `poll_votes`. The vote payload is encrypted with a // per-poll secret that whatsmeow stored when the original poll-creation @@ -1500,23 +1532,32 @@ func handlePollVote(client *whatsmeow.Client, messageStore *MessageStore, return } - // Resolve the poll's chat JID through the same LID -> phone mapping the - // rest of the bridge uses, so we look up the poll under the canonical key. - if parsed, err := types.ParseJID(pollChatJID); err == nil { - resolved := resolveLIDChat(client, parsed, types.EmptyJID, types.EmptyJID, false) - pollChatJID = resolved.String() - } + // The vote's PollCreationMessageKey references the chat in whatever form + // the sender's client used (PN or LID), but our outbound polls are stored + // under the LID and inbound polls under whatever form handleMessage saw. + // Try every reasonable form before giving up. + candidates := pollChatJIDCandidates(client, pollChatJID) - poll, err := messageStore.GetPoll(pollMsgID, pollChatJID) - if err != nil { - logger.Warnf("Failed to look up poll %s/%s: %v", pollChatJID, pollMsgID, err) - return + var ( + poll *PollRecord + err error + ) + for _, candidate := range candidates { + poll, err = messageStore.GetPoll(pollMsgID, candidate) + if err != nil { + logger.Warnf("Failed to look up poll %s/%s: %v", candidate, pollMsgID, err) + return + } + if poll != nil { + pollChatJID = candidate + break + } } if poll == nil { // We never saw the original poll creation — typically because the // bridge wasn't running when the poll was sent. Without the option // list we cannot decode the SHA-256 hashes back to names. - logger.Warnf("Received vote for unknown poll %s/%s — ignoring", pollChatJID, pollMsgID) + logger.Warnf("Received vote for unknown poll %s/%s (tried %v) — ignoring", pollChatJID, pollMsgID, candidates) return }