From bad5ea72abcbb96b5459e9df0c27e94f139699af Mon Sep 17 00:00:00 2001 From: retoor Date: Tue, 27 Oct 2026 21:37:48 +0100 Subject: [PATCH] whatsapp: one encrypted copy per device, treat an ack with error as a rejection A message to the user's own number listed the phone twice (as recipient device and as own device), so the server accepted it but nothing arrived. The device list is now deduplicated; devices of the sender's user get the DeviceSentMessage wrapper, all others the plain message (whatsmeow encryptMessageForDevices). The participant hash is computed and compared with the server's (they match: 2:TKwG4B1n), and an ack carrying error= is reported as a failed send instead of success. Co-Authored-By: Claude Sonnet 5.5 --- taicpp/src/whatsapp/wa_client.cpp | 54 +++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/taicpp/src/whatsapp/wa_client.cpp b/taicpp/src/whatsapp/wa_client.cpp index 1edea76..5d94f4a 100644 --- a/taicpp/src/whatsapp/wa_client.cpp +++ b/taicpp/src/whatsapp/wa_client.cpp @@ -620,24 +620,52 @@ std::optional WaClient::send_message(const std::string& to_jid, con log("send: recipient not on WhatsApp or no devices"); return std::nullopt; } - // our other devices (the phone) get the DeviceSentMessage copy so they show the message as sent - auto own = own_other_devices(); + // One deduplicated device list (whatsmeow encryptMessageForDevices): the recipient's devices plus our own other + // devices. Devices of OUR user get the DeviceSentMessage wrapper, every other device the plain message. A message to + // ourselves lists the phone once, as an own device; sending it twice made the server drop the message silently. + std::string own_user = device_.jid ? device_.jid->substr(0, device_.jid->find_first_of(":@")) : std::string(); + auto user_of = [](const std::string& jid) { return jid.substr(0, jid.find_first_of(":@")); }; auto targets = devices; - targets.insert(targets.end(), own.begin(), own.end()); + for (const auto& d : own_other_devices()) { + if (std::find_if(targets.begin(), targets.end(), [&](const std::string& t) { return addr_of(t) == addr_of(d); }) == targets.end()) targets.push_back(d); + } if (!ensure_sessions(targets)) { log("send: could not establish sessions"); return std::nullopt; } - auto part_nodes = encrypt_for_devices(devices, WaCrypto::pkcs7_pad(message_plaintext), media_type); - if (!own.empty()) { + std::vector plain_targets, own_targets; + for (const auto& d : targets) (user_of(d) == own_user ? own_targets : plain_targets).push_back(d); + auto part_nodes = encrypt_for_devices(plain_targets, WaCrypto::pkcs7_pad(message_plaintext), media_type); + if (!own_targets.empty()) { WaMessage::DeviceSent ds; ds.destination_jid = to_jid; ds.inner = message_plaintext; WaMessage wrapped; wrapped.device_sent = ds; - auto own_nodes = encrypt_for_devices(own, WaCrypto::pkcs7_pad(wrapped.encode()), media_type); + auto own_nodes = encrypt_for_devices(own_targets, WaCrypto::pkcs7_pad(wrapped.encode()), media_type); part_nodes.insert(part_nodes.end(), own_nodes.begin(), own_nodes.end()); } + // participant hash, whatsmeow participantListHashV2 over every device including this one; the server answers with + // its own in the ack, so a difference means our device list is stale + std::string our_phash; + { + std::vector ad; + for (const auto& d : targets) { + size_t at = d.find('@'), colon = d.find(':'); + std::string user = d.substr(0, std::min(at, colon)); + std::string dev = colon != std::string::npos && colon < at ? d.substr(colon + 1, at - colon - 1) : "0"; + ad.push_back(user + ".0:" + dev + "@" + (at == std::string::npos ? "s.whatsapp.net" : d.substr(at + 1))); + } + if (device_.jid) { + size_t at = device_.jid->find('@'), colon = device_.jid->find(':'); + ad.push_back(device_.jid->substr(0, std::min(at, colon)) + ".0:" + (colon != std::string::npos ? device_.jid->substr(colon + 1, at - colon - 1) : "0") + "@" + device_.jid->substr(at + 1)); + } + std::sort(ad.begin(), ad.end()); + std::string joined; + for (const auto& a : ad) joined += a; + Bytes h = WaCrypto::sha256(Bytes(joined.begin(), joined.end())); + our_phash = "2:" + b64(Bytes(h.begin(), h.begin() + 6)); + } if (part_nodes.empty()) { log("send: no recipients encrypted"); return std::nullopt; @@ -672,7 +700,19 @@ std::optional WaClient::send_message(const std::string& to_jid, con if (!socket_open()) break; auto node = recv_node(2); if (!node) continue; - if (node->tag == "ack" && node->attr("id") == msg_id) return msg_id; + if (node->tag == "ack" && node->attr("id") == msg_id) { + // an ack can carry an error (error="479" ...): that is a rejection, not a delivery + std::string line = "send: server ack"; + for (const auto& kv : node->attrs) line += " " + kv.first + "=" + kv.second; + log(line); + if (!our_phash.empty() && node->has_attr("phash") && node->attr("phash") != our_phash) + log("send: device list differs from the server's (ours " + our_phash + ", server " + node->attr("phash") + ")"); + if (node->has_attr("error")) { + log("send: the server rejected the message (error " + node->attr("error") + ")"); + return std::nullopt; + } + return msg_id; + } if (node->tag == "message") dispatch(*node); // acks and handles key shares else if (node->tag == "receipt") send_ack(*node); else dispatch(*node);