From 9d243d01a92be73c839b88a6490377f88c1185c5 Mon Sep 17 00:00:00 2001 From: Muhammet Enes Senovali Date: Tue, 13 Oct 2026 22:54:47 +0300 Subject: [PATCH 1/4] Handle missing reply quotes before sending and preserve recovery text --- build.py | 1 + .../src/controller/messageController.ts | 20 + .../src/util/quotedMessagePreflight.ts | 33 ++ client/changelog_en-US.txt | 2 + client/changelog_es-ES.txt | 2 + client/changelog_pl.txt | 2 + client/changelog_pt-BR.txt | 2 + client/changelog_pt-PT.txt | 2 + client/changelog_ro.txt | 2 + client/changelog_tr-TR.txt | 2 + client/core/message_edit.py | 3 + client/core/message_queue.py | 17 +- client/core/reply_failure.py | 38 ++ client/main_window/sending.py | 29 ++ client/ui/conversation_panel/bulk_messages.py | 5 +- .../ui/conversation_panel/message_actions.py | 17 +- client/ui/conversation_panel/message_menu.py | 9 + client/ui/conversation_panel/text_sending.py | 31 +- client/ui/dialogs/api_setup.py | 1 + docs/traps/send-contract.md | 14 + setup_api.py | 1 + tests/test_api_patches_in_sync.py | 1 + tests/test_missing_reply_quote.py | 379 ++++++++++++++++++ tests/test_reply_quote_preflight_runtime.py | 64 +++ translations/en-US/LC_MESSAGES/winzapp.po | 32 ++ translations/es-ES/LC_MESSAGES/winzapp.po | 32 ++ translations/pl/LC_MESSAGES/winzapp.po | 33 ++ translations/pt-BR/LC_MESSAGES/winzapp.po | 32 ++ translations/pt-PT/LC_MESSAGES/winzapp.po | 32 ++ translations/ro/LC_MESSAGES/winzapp.po | 33 ++ translations/tr-TR/LC_MESSAGES/winzapp.po | 34 ++ translations/winzapp.pot | 27 ++ 32 files changed, 922 insertions(+), 10 deletions(-) create mode 100644 client/api_patches/src/util/quotedMessagePreflight.ts create mode 100644 client/core/reply_failure.py create mode 100644 tests/test_missing_reply_quote.py create mode 100644 tests/test_reply_quote_preflight_runtime.py diff --git a/build.py b/build.py index 9220e34bb..d1e14a127 100644 --- a/build.py +++ b/build.py @@ -358,6 +358,7 @@ def _prepare_ffmpeg(): "src/util/callMediaBridge.ts", "src/util/forwardRuntime.ts", "src/util/statusReactionRuntime.ts", + "src/util/quotedMessagePreflight.ts", "src/util/contactPresenceRuntime.ts", "src/util/listChatsDiag.ts", "src/util/createSessionUtil.ts", diff --git a/client/api_patches/src/controller/messageController.ts b/client/api_patches/src/controller/messageController.ts index 0ad169c86..da1a36029 100644 --- a/client/api_patches/src/controller/messageController.ts +++ b/client/api_patches/src/controller/messageController.ts @@ -18,6 +18,10 @@ import { Request, Response } from 'express'; import { unlinkAsync } from '../util/functions'; import { writePinnedMessage } from '../util/pinnedMessagesRuntime'; +import { + missingReplyQuoteResponse, + replyQuoteIsMissing, +} from '../util/quotedMessagePreflight'; function returnError(req: Request, res: Response, error: any) { req.logger.error(error); @@ -1546,6 +1550,14 @@ export async function replyMessage(req: Request, res: Response) { const { phone, message, messageId } = req.body; try { + if ( + typeof messageId === 'string' && + messageId && + !messageId.includes('status@broadcast') && + (await replyQuoteIsMissing(req.client, messageId)) + ) { + return missingReplyQuoteResponse(res); + } const results: any = []; for (const contato of phone) { if ( @@ -1653,6 +1665,14 @@ export async function sendMentioned(req: Request, res: Response) { const { phone, message, mentioned, messageId } = req.body; try { + if ( + typeof messageId === 'string' && + messageId && + !messageId.includes('status@broadcast') && + (await replyQuoteIsMissing(req.client, messageId)) + ) { + return missingReplyQuoteResponse(res); + } let response; for (const contato of phone) { response = auditSendResult( diff --git a/client/api_patches/src/util/quotedMessagePreflight.ts b/client/api_patches/src/util/quotedMessagePreflight.ts new file mode 100644 index 000000000..5d2c730ee --- /dev/null +++ b/client/api_patches/src/util/quotedMessagePreflight.ts @@ -0,0 +1,33 @@ +// Resolve the quote before ANY recipient is sent to. Keep the verdict in the +// page: Puppeteer does not reliably preserve a WPPError's code across a throw. +export const QUOTED_MESSAGE_PREFLIGHT = String.raw` +async function(messageId) { + try { + const message = await window.WPP.chat.getMessageById(messageId); + if (!message) throw new Error('quote lookup returned no verdict'); + return false; + } catch (error) { + if (error && error.code === 'msg_not_found') return true; + throw error; + } +}`; + +export async function replyQuoteIsMissing(client, messageId) { + return ( + (await client.page.evaluate( + `(${QUOTED_MESSAGE_PREFLIGHT})(${JSON.stringify(messageId)})` + )) === true + ); +} + +export function missingReplyQuoteResponse(res) { + // Deliberately 500: older WinZapp clients strip quotes and resend on 4xx. + // Only clients understanding this complete pre-send verdict may call it a + // definite refusal. Never add this marker to a catch around a SEND. + return res.status(500).json({ + status: 'error', + code: 'quoted_message_not_found', + stage: 'quote_resolution', + sendAttempted: false, + }); +} diff --git a/client/changelog_en-US.txt b/client/changelog_en-US.txt index 6eb7c38c5..b18f7ce79 100644 --- a/client/changelog_en-US.txt +++ b/client/changelog_en-US.txt @@ -84,6 +84,8 @@ WHAT'S NEW FIXES +- Replies: when the original message cannot be found before sending, WinZapp reports the missing quote and keeps your text for editing from the message menu. It does not silently send without the quote or report a connection loss. Uncertain sends still are not retried automatically. + - Synchronization: replayed history messages keep their chronological position instead of appearing as new arrivals. Temporary failures when checking an alternate chat address remain queued for retry. - Forwarding no longer opens a box when it worked. "Message forwarded." (or how many messages were forwarded) is now said by the screen reader, with no window to close and without cutting off what is being read; it works with WinZapp in the tray too. diff --git a/client/changelog_es-ES.txt b/client/changelog_es-ES.txt index 07a3a3389..00a75f098 100644 --- a/client/changelog_es-ES.txt +++ b/client/changelog_es-ES.txt @@ -84,6 +84,8 @@ NOVEDADES CORRECCIONES +- Respuestas: si no se encuentra el mensaje original antes de enviar, WinZapp informa de la cita ausente y conserva el texto para editarlo desde el menú del mensaje. No lo envía silenciosamente sin la cita ni anuncia una pérdida de conexión. Los envíos inciertos siguen sin reintentarse automáticamente. + - Sincronización: los mensajes antiguos recibidos de nuevo mantienen su orden cronológico en lugar de aparecer como nuevos. Los fallos temporales al consultar una dirección alternativa del chat quedan pendientes para volver a intentarlo. - Reenviar ya no abre un cuadro cuando sale bien. "Mensaje reenviado." (o cuántos mensajes se han reenviado) ahora lo dice el lector de pantalla, sin ventana que cerrar y sin interrumpir lo que se está leyendo; también funciona con WinZapp en la bandeja. diff --git a/client/changelog_pl.txt b/client/changelog_pl.txt index bccbc79f1..817694c6c 100644 --- a/client/changelog_pl.txt +++ b/client/changelog_pl.txt @@ -84,6 +84,8 @@ NOWOŚCI POPRAWKI +- Odpowiedzi: jeśli przed wysłaniem nie można znaleźć oryginalnej wiadomości, WinZapp informuje o brakującym cytacie i zachowuje tekst do edycji z menu wiadomości. Nie wysyła go po cichu bez cytatu ani nie zgłasza utraty połączenia. Wysyłki o niepewnym wyniku nadal nie są automatycznie ponawiane. + - Synchronizacja: ponownie otrzymane starsze wiadomości zachowują kolejność chronologiczną, zamiast pojawiać się jako nowe. Tymczasowe błędy sprawdzania alternatywnego adresu czatu pozostają w kolejce do ponowienia. - Przekazywanie dalej nie otwiera już okna, gdy się uda. „Wiadomość przekazana.” (albo liczbę przekazanych wiadomości) mówi teraz czytnik ekranu, bez okna do zamknięcia i bez przerywania tego, co jest czytane; działa też, gdy WinZapp jest w zasobniku. diff --git a/client/changelog_pt-BR.txt b/client/changelog_pt-BR.txt index 7e0a22168..9c6c9097b 100644 --- a/client/changelog_pt-BR.txt +++ b/client/changelog_pt-BR.txt @@ -84,6 +84,8 @@ NOVIDADES CORREÇÕES +- Respostas: quando a mensagem original não é encontrada antes do envio, o WinZapp informa a citação ausente e mantém seu texto para edição pelo menu da mensagem. Não envia silenciosamente sem a citação nem informa perda de conexão. Envios incertos continuam sem repetição automática. + - Sincronização: mensagens antigas recebidas novamente mantêm a ordem cronológica, em vez de aparecerem como novas. Falhas temporárias ao consultar um endereço alternativo da conversa continuam na fila para nova tentativa. - Encaminhar não abre mais uma caixa quando dá certo. "Mensagem encaminhada." (ou quantas mensagens foram encaminhadas) agora é falado pelo leitor de tela, sem janela para fechar e sem interromper o que está sendo lido; funciona também com o WinZapp na bandeja. diff --git a/client/changelog_pt-PT.txt b/client/changelog_pt-PT.txt index 1b3e1a35d..00638e467 100644 --- a/client/changelog_pt-PT.txt +++ b/client/changelog_pt-PT.txt @@ -84,6 +84,8 @@ NOVIDADES CORREÇÕES +- Respostas: quando a mensagem original não é encontrada antes do envio, o WinZapp informa a citação em falta e mantém o texto para edição no menu da mensagem. Não envia silenciosamente sem a citação nem anuncia perda de ligação. Os envios incertos continuam sem repetição automática. + - Sincronização: mensagens antigas recebidas novamente mantêm a ordem cronológica, em vez de aparecerem como novas. Falhas temporárias ao consultar um endereço alternativo da conversa continuam na fila para nova tentativa. - Reencaminhar deixa de abrir uma caixa quando corre bem. "Mensagem reencaminhada." (ou quantas mensagens foram reencaminhadas) passa a ser dito pelo leitor de ecrã, sem janela para fechar e sem interromper o que está a ser lido; funciona também com o WinZapp na área de notificação. diff --git a/client/changelog_ro.txt b/client/changelog_ro.txt index b9cedbc65..11f4531aa 100644 --- a/client/changelog_ro.txt +++ b/client/changelog_ro.txt @@ -84,6 +84,8 @@ NOUTĂȚI CORECTĂRI +- Răspunsuri: dacă mesajul original nu este găsit înainte de trimitere, WinZapp anunță citatul lipsă și păstrează textul pentru editare din meniul mesajului. Nu trimite în tăcere fără citat și nu anunță pierderea conexiunii. Trimiterile cu rezultat incert nu sunt reîncercate automat. + - Sincronizare: mesajele vechi primite din nou își păstrează ordinea cronologică, în loc să apară ca mesaje noi. Erorile temporare la verificarea unei adrese alternative a conversației rămân în coadă pentru o nouă încercare. - Redirecționarea nu mai deschide o casetă când a reușit. „Mesajul a fost redirecționat.” (sau câte mesaje au fost redirecționate) este acum rostit de cititorul de ecran, fără o fereastră de închis și fără a întrerupe ce se citește; funcționează și cu WinZapp în bara de sistem. diff --git a/client/changelog_tr-TR.txt b/client/changelog_tr-TR.txt index af0e43725..5aed41781 100644 --- a/client/changelog_tr-TR.txt +++ b/client/changelog_tr-TR.txt @@ -84,6 +84,8 @@ YENİLİKLER DÜZELTMELER +- Yanıtlar: gönderimden önce özgün mesaj bulunamazsa WinZapp eksik alıntıyı bildirir ve metninizi mesaj menüsünden düzenleyebilmeniz için korur. Alıntıyı sessizce kaldırıp göndermez veya bağlantı kesildiğini söylemez. Sonucu belirsiz gönderimler otomatik olarak tekrar denenmez. + - Eşitleme: Tekrar gelen geçmiş mesajlar yeni gelmiş gibi sona eklenmez; tarih sırası korunur. Sohbetin alternatif adresini sorgularken oluşan geçici hatalar yeniden denemek üzere kuyrukta tutulur. - İletme başarılı olduğunda artık bir kutu açmıyor. "Mesaj iletildi." (ya da kaç mesajın iletildiği) artık ekran okuyucu tarafından söyleniyor; kapatılacak bir pencere yok ve okunan şey kesilmiyor. WinZapp sistem tepsisindeyken de çalışır. diff --git a/client/core/message_edit.py b/client/core/message_edit.py index 5a86274e0..775a64892 100644 --- a/client/core/message_edit.py +++ b/client/core/message_edit.py @@ -43,6 +43,7 @@ from __future__ import annotations import copy +from core.reply_failure import is_missing_quote_failure import json import time @@ -270,6 +271,8 @@ def edit_kind(msg): """ if not isinstance(msg, dict) or not (msg.get("key") or {}).get("fromMe"): return None + if is_missing_quote_failure(msg): + return None # A definite pre-send refusal has no WhatsApp ID to edit. mtype = msg.get("messageType") if mtype in _TEXT_TYPES: return "text" diff --git a/client/core/message_queue.py b/client/core/message_queue.py index 41d2df940..afd6e07e1 100644 --- a/client/core/message_queue.py +++ b/client/core/message_queue.py @@ -429,6 +429,7 @@ def _media_progress(progress, pending=msg): ) retryable_failure = False disconnected = False + failure_reason = "" if isinstance(real_id, dict): if real_id.get("ok"): quote_lost = bool(real_id.get("quote_lost", False)) @@ -438,6 +439,7 @@ def _media_progress(progress, pending=msg): retryable_failure = bool(real_id.get("retry", True)) disconnected = bool(real_id.get("disconnected")) ambiguous = bool(real_id.get("ambiguous")) + failure_reason = real_id.get("reason") or "" real_id = False if msg.cancel_event.is_set(): @@ -535,12 +537,15 @@ def _media_progress(progress, pending=msg): msg.local_id, msg.jid, msg.fail_count, msg.last_error) with self._lock: self._pending.pop(msg.local_id, None) - wx.CallAfter( - self.main_window._on_message_failed, - msg.local_id, - msg.last_error, - bool(msg.media_path), # show dialog for media failures - ) + if failure_reason == "quoted_message_not_found": + wx.CallAfter(self.main_window._on_reply_quote_missing, msg.local_id) + else: + wx.CallAfter( + self.main_window._on_message_failed, + msg.local_id, + msg.last_error, + bool(msg.media_path), # show dialog for media failures + ) # Same as the ambiguous branch: _on_message_failed() # is cancel-aware, so this is this message's report. reported = True diff --git a/client/core/reply_failure.py b/client/core/reply_failure.py new file mode 100644 index 000000000..cd61ed412 --- /dev/null +++ b/client/core/reply_failure.py @@ -0,0 +1,38 @@ +"""A missing quote is definite only with an explicit pre-send API verdict.""" + + +def is_missing_quote_failure(message) -> bool: + return isinstance(message, dict) and bool( + message.get("_reply_quote_missing") and message.get("_send_failed") + ) + + +def quote_missing_before_send(response) -> bool: + if response.status_code != 500: + return False + try: + body = response.json() + except (ValueError, TypeError): + return False + return ( + isinstance(body, dict) + and body.get("status") == "error" + and body.get("code") == "quoted_message_not_found" + and body.get("stage") == "quote_resolution" + and body.get("sendAttempted") is False + ) + + +def mark_missing_reply_quote(chats, local_id): + """Keep the failed text in its own chat even after the panel moved away.""" + if not local_id: + return None + for jid, chat in chats.items(): + records = chat.get("messages", {}).get("messages", {}).get("records", []) + for record in records: + if record.get("_local_id") == local_id: + record["_local_pending"] = False + record["_send_failed"] = True + record["_reply_quote_missing"] = True + return jid, record + return None diff --git a/client/main_window/sending.py b/client/main_window/sending.py index 7761b48f2..15ef76854 100644 --- a/client/main_window/sending.py +++ b/client/main_window/sending.py @@ -18,6 +18,7 @@ import time import wx from core.message_queue import MessageCancelled +from core.reply_failure import mark_missing_reply_quote, quote_missing_before_send from core.connection_lifecycle import schedule_connection_check from core.meta_ai import ( STATE_ACCEPTED, @@ -556,6 +557,9 @@ def send_text_message(self, remote_jid, text, quoted=None, mentioned_jids=None, # reduces how often that false-timeout/duplicate-send scenario happens. response = api_post(url, json=payload, headers=headers, timeout=25) active_dest = phone_net + if quoted_id and not is_status_reply and quote_missing_before_send(response): + logging.warning("[send] Reply quote lookup refused before any send; keeping text") + return {"ok": False, "retry": False, "reason": "quoted_message_not_found"} if response.status_code not in (200, 201): # 1. The @lid destination was refused: fall back to the legacy # @c.us address. Historically this fired only on the 400 @@ -609,6 +613,8 @@ def send_text_message(self, remote_jid, text, quoted=None, mentioned_jids=None, } active_dest = fb_phone response = api_post(retry_url, json=retry_payload, headers=headers, timeout=25) + if quoted_id and not is_status_reply and quote_missing_before_send(response): + return {"ok": False, "retry": False, "reason": "quoted_message_not_found"} if response.status_code in (200, 201): logging.info("[send_text_message] legacy retry with %s succeeded", fb_phone) @@ -1294,6 +1300,29 @@ def _on_message_unconfirmed(self, local_id: str): if not self.background_mode: self.output(self.i18n.t("message_send_unconfirmed"), interrupt=False) + def _on_reply_quote_missing(self, local_id: str): + """Report a pre-send quote refusal without replacing the current draft.""" + if self._is_cancelled_send(local_id): + self._on_cancelled_message_dropped(local_id) + return + marked = mark_missing_reply_quote(self.chats, local_id) + if marked is None: + return # Chat/account was removed while the request was in flight. + jid, record = marked + saved = True + try: + # _schedule_save writes chat metadata, never the message records. + self.db.insert_message(jid, record) + except Exception as exc: + saved = False + logging.warning("[send] Could not save refused reply (%s)", type(exc).__name__) + self._schedule_save(dirty_jid=jid) + self._on_message_failed(local_id) + if not self.background_mode: + self.output(self.i18n.t("reply_quote_missing_error"), interrupt=False) + if not saved: + self.output(self.i18n.t("reply_quote_save_failed"), interrupt=False) + def _on_message_failed(self, local_id: str, error: str = "", show_dialog: bool = False): """ Called on the main thread after a queued message exhausts all retries. diff --git a/client/ui/conversation_panel/bulk_messages.py b/client/ui/conversation_panel/bulk_messages.py index 900d96e39..53ca404c0 100644 --- a/client/ui/conversation_panel/bulk_messages.py +++ b/client/ui/conversation_panel/bulk_messages.py @@ -13,6 +13,7 @@ from ui.conversation_panel.media_paths import _SAVEABLE_MESSAGE_TYPES from core.message_copy_format import format_copied_message from core.call_log import is_call_log +from core.reply_failure import is_missing_quote_failure from core.save_location import resolve_save_dialog_folder @@ -311,7 +312,7 @@ def _on_mass_delete_messages(self, event): admin_override = self._group_admin_delete_override() def _can_delete_for_all(msg): - if self._is_system_event(msg): + if self._is_system_event(msg) or is_missing_quote_failure(msg): return False return admin_override or msg.get("key", {}).get("fromMe", False) @@ -381,6 +382,8 @@ def _can_delete_for_all(msg): def _delete_bg(): failed = 0 for msg in msgs_to_delete: + if is_missing_quote_failure(msg): + continue # Local UUID only: there is no WhatsApp message to delete. msg_key = dict(msg.get("key", {})) jid = self._delete_target_jid(msg_key) if not jid: diff --git a/client/ui/conversation_panel/message_actions.py b/client/ui/conversation_panel/message_actions.py index fe09e0af8..a962676d1 100644 --- a/client/ui/conversation_panel/message_actions.py +++ b/client/ui/conversation_panel/message_actions.py @@ -17,6 +17,7 @@ edit_window_open, ) from core.message_queue import PendingMessage +from core.reply_failure import is_missing_quote_failure from ui.conversation_panel.typing_row import append_message_row, message_row_count from app_paths import data_path from ui.conversation_panel.media_paths import discard_local_media_cache @@ -208,7 +209,10 @@ def _on_menu_delete_message(self, index: int): # would be misleading anyway (see the comment above). if is_self_chat and not is_system: if self._confirm_local_only_delete(1): - self._delete_message_for_me_only(msg, msg_id, index) + if is_missing_quote_failure(msg): + self._cancel_pending_message(msg, str(msg.get("_local_id") or msg_id), hold_for_echo=False) + else: + self._delete_message_for_me_only(msg, msg_id, index) return if not can_delete_for_all and not is_system and self.conversation: @@ -296,7 +300,10 @@ def _phone_part(j: str) -> str: # path below (which would build a revoke request around the local # UUID this row's key.id still holds and could only fail). cancelled_pending = bool( - pending_local_id and (msg.get("_local_pending") or msg.get("_send_unconfirmed")) + pending_local_id and ( + msg.get("_local_pending") or msg.get("_send_unconfirmed") + or is_missing_quote_failure(msg) + ) ) if cancelled_pending: # An unconfirmed send shares the "nothing to revoke, local delete @@ -671,6 +678,9 @@ def _on_accel_edit_message(self, event): return if not msg.get("key", {}).get("fromMe", False): return + if is_missing_quote_failure(msg): + self._on_restore_unsent_reply(msg) + return if edit_kind(msg) is None: return if not edit_window_open(msg.get("messageTimestamp")): @@ -687,6 +697,9 @@ def _on_accel_edit_message(self, event): def _on_menu_edit_message(self, index: int, msg: dict): """Enter edit mode: pre-fill message field with message text.""" + if is_missing_quote_failure(msg): + self._on_restore_unsent_reply(msg) + return if edit_kind(msg) == "caption": # The raw caption, not _get_message_content(): for media that is # the row as the list reads it (type, file name, size), none of diff --git a/client/ui/conversation_panel/message_menu.py b/client/ui/conversation_panel/message_menu.py index 3180c667f..712016710 100644 --- a/client/ui/conversation_panel/message_menu.py +++ b/client/ui/conversation_panel/message_menu.py @@ -14,6 +14,7 @@ import threading import wx from core.quote_recovery import RECOVERED_FROM_QUOTE +from core.reply_failure import is_missing_quote_failure from ui.conversation_panel.media_paths import ( _SAVEABLE_MESSAGE_TYPES, cached_media_path, @@ -497,6 +498,14 @@ def on_messages_context_menu(self, event): resend_item, ) + if _is_text and is_missing_quote_failure(msg): + restore_item = menu.Append(wx.ID_ANY, f"{i18n.t('restore_unsent_reply')}\tAlt+E") + self.Bind( + wx.EVT_MENU, + lambda e, m=msg: self._on_restore_unsent_reply(m), + restore_item, + ) + menu.AppendSeparator() # Select / Unselect message (Ctrl+Space) — mirrors the label to diff --git a/client/ui/conversation_panel/text_sending.py b/client/ui/conversation_panel/text_sending.py index de8fbfe2e..b49775c0a 100644 --- a/client/ui/conversation_panel/text_sending.py +++ b/client/ui/conversation_panel/text_sending.py @@ -12,6 +12,7 @@ import uuid import wx from core.message_queue import PendingMessage +from core.reply_failure import is_missing_quote_failure from ui.conversation_panel.typing_row import append_message_row, message_row_count from app_paths import data_path from ui.conversation_panel.media_paths import ( @@ -24,7 +25,7 @@ restore_edit_state, snapshot_edit_state, ) -from core.utils import normalize_line_separators +from core.utils import normalize_line_separators, to_editor_line_endings class TextSendingMixin: @@ -304,6 +305,11 @@ def _send_new_text_message(self, text: str, remote_jid: str): "pushName": "", } if self._quoted_message: + # Keep the composed text (including display names), not a rendered + # quote/preview or a wire body with mention phone numbers. + virtual_msg["_reply_draft_text"] = text + virtual_msg["_reply_draft_mentions"] = list(self._pending_mentions) + virtual_msg["_reply_draft_mention_names"] = dict(self._pending_mention_display_names) _qk = self._quoted_message.get("key", {}) virtual_msg["contextInfo"] = { "stanzaId": _qk.get("id", ""), @@ -571,6 +577,29 @@ def _mark_message_sent(self, local_id: str, real_id: str = None, quote_lost: boo self.main_window._schedule_set_chats() + def _on_restore_unsent_reply(self, msg: dict): + """Explicit recovery only: never overwrite another draft or send here.""" + if not is_missing_quote_failure(msg): + return + if not self.conversation or ( + msg.get("key", {}).get("remoteJid") != self.conversation.get("remoteJid") + ): + return + if (self.message_field.GetValue() or self._quoted_message + or self._editing_message_id is not None or self._pending_mentions): + self.main_window.output(self.main_window.i18n.t("reply_restore_draft_busy"), interrupt=False) + return + text = msg.get("_reply_draft_text") + if not isinstance(text, str) or not text: + return + self._pending_mention_display_names = dict(msg.get("_reply_draft_mention_names") or {}) + self._pending_mentions = list(msg.get("_reply_draft_mentions") or []) + self._last_sent_signature = None # This explicit retry is a new user action. + self._rebuild_mention_pills() + self.message_field.SetValue(to_editor_line_endings(text)) + self.message_field.SetInsertionPointEnd() + self.message_field.SetFocus() + def _mark_message_failed(self, local_id: str): """Mark a virtual pending message as permanently failed (exhausted retries).""" self._hide_media_transfer_gauge() diff --git a/client/ui/dialogs/api_setup.py b/client/ui/dialogs/api_setup.py index 85fa4f24e..864908f3a 100644 --- a/client/ui/dialogs/api_setup.py +++ b/client/ui/dialogs/api_setup.py @@ -224,6 +224,7 @@ def fetch_latest_wpp_tag(timeout: float = 15) -> str: "src/tests/controller/pinnedMessagesController.test.ts", "src/util/forwardRuntime.ts", "src/util/statusReactionRuntime.ts", + "src/util/quotedMessagePreflight.ts", "src/util/contactPresenceRuntime.ts", "src/util/listChatsDiag.ts", "src/util/createSessionUtil.ts", diff --git a/docs/traps/send-contract.md b/docs/traps/send-contract.md index a6471d2d6..8b271f393 100644 --- a/docs/traps/send-contract.md +++ b/docs/traps/send-contract.md @@ -1,5 +1,19 @@ # Send compatibility contract +- **A missing ordinary reply quote has a pre-send verdict (2026-10-13).** + `replyMessage` and quoted `sendMentioned` resolve the quote before the first + recipient's send. Only the in-page `msg_not_found` code emits + `{status:'error', code:'quoted_message_not_found', stage:'quote_resolution', + sendAttempted:false}`. It deliberately stays HTTP 500: old Python clients + strip quotes and resend on 4xx. New clients recognize the complete verdict + before any fallback, keep the failed row/text in its original chat and offer + an explicit edit action. No automatic plain resend or offline transition. + Unknown preflight errors and EVERY exception during/after a send still use + the old ambiguous path. Never infer this verdict from an error message or add + it to a catch around a send. Status replies retain their separate model path. + `quotedMessagePreflight.ts` is shipped through all patch lists; without a + rebuilt API an older Node still returns the existing ambiguous response. + > Exact wppconnect/wa-js pins, `auditSendResult()`, and why HTTP 201 without a message id is a failure. > > Moved verbatim out of `CLAUDE.md` so it is read when the area is touched, not on every session. Keep it here: this is measured history, not a summary. diff --git a/setup_api.py b/setup_api.py index 02acdfaac..361186597 100644 --- a/setup_api.py +++ b/setup_api.py @@ -131,6 +131,7 @@ def _homologated_major() -> str: "src/tests/controller/pinnedMessagesController.test.ts", "src/util/forwardRuntime.ts", "src/util/statusReactionRuntime.ts", + "src/util/quotedMessagePreflight.ts", "src/util/contactPresenceRuntime.ts", "src/util/listChatsDiag.ts", "src/util/createSessionUtil.ts", diff --git a/tests/test_api_patches_in_sync.py b/tests/test_api_patches_in_sync.py index d497967ff..a1ab17166 100644 --- a/tests/test_api_patches_in_sync.py +++ b/tests/test_api_patches_in_sync.py @@ -74,6 +74,7 @@ def _setup_api_module(): "src/tests/controller/pinnedMessagesController.test.ts", "src/util/forwardRuntime.ts", "src/util/statusReactionRuntime.ts", + "src/util/quotedMessagePreflight.ts", "src/util/contactPresenceRuntime.ts", "src/util/listChatsDiag.ts", "src/util/createSessionUtil.ts", diff --git a/tests/test_missing_reply_quote.py b/tests/test_missing_reply_quote.py new file mode 100644 index 000000000..fbb379ad8 --- /dev/null +++ b/tests/test_missing_reply_quote.py @@ -0,0 +1,379 @@ +"""A missing quote before sending is a refusal, never an offline/unknown send.""" + +import copy +import json +import threading +import types + +import pytest + +from core.message_queue import MessageQueue, PendingMessage +from core.reply_failure import is_missing_quote_failure, mark_missing_reply_quote, quote_missing_before_send +from main import MainWindow +from ui.conversations import ConversationsPanel +from tests.god_modules import patch_main_global +from tests.test_reply_with_mention_keeps_quote import _Stub as SendStub, QUOTED, GROUP, LID, MENTION + + +VERDICT = {"status": "error", "code": "quoted_message_not_found", + "stage": "quote_resolution", "sendAttempted": False} + + +class Response: + def __init__(self, body=VERDICT, status=500): + self.body, self.status_code = body, status + self.text = json.dumps(body) + + def json(self): + return self.body + + +def test_complete_pre_send_verdict_is_recognized(): + assert quote_missing_before_send(Response()) + + +@pytest.mark.parametrize("body", [None, [], "msg_not_found", {}, + {**VERDICT, "sendAttempted": True}, {**VERDICT, "sendAttempted": 0}, + {**VERDICT, "sendAttempted": "false"}, {**VERDICT, "stage": "send"}, + {**VERDICT, "status": "success"}, {**VERDICT, "code": "msg_not_found"}, + {"error": {"message": "Message false_chat@c.us_ID not found"}}, + *[{k: v for k, v in VERDICT.items() if k != absent} for absent in VERDICT], +]) +def test_incomplete_or_legacy_error_does_not_claim_nothing_was_sent(body): + assert not quote_missing_before_send(Response(body)) + + +@pytest.mark.parametrize("status", [200, 201, 400, 404, 409, 502, 503]) +def test_unrelated_status_does_not_take_the_new_contract(status): + assert not quote_missing_before_send(Response(status=status)) + + +def test_invalid_json_does_not_override_ambiguity(): + response = Response() + response.json = lambda: json.loads("not json") + assert not quote_missing_before_send(response) + + +@pytest.mark.parametrize("destination", [GROUP, LID, "5511999999999@s.whatsapp.net"]) +@pytest.mark.parametrize("mentions", [None, [MENTION]]) +def test_missing_quote_is_not_resent_plain_or_to_another_address(monkeypatch, destination, mentions): + calls, flags = [], [] + stub = SendStub() + stub._set_wa_connected = lambda *args: flags.append(args) + patch_main_global(monkeypatch, "api_post", lambda url, **kw: calls.append(url) or Response()) + result = stub.send_text_message(destination, "hello", quoted=QUOTED, mentioned_jids=mentions) + assert len(calls) == 1 + assert result == {"ok": False, "retry": False, "reason": "quoted_message_not_found"} + assert flags == [] + + +def test_a_refusal_on_the_legacy_retry_does_not_strip_the_quote(monkeypatch): + replies = iter([Response({}, 400), Response()]) + calls = [] + patch_main_global(monkeypatch, "api_post", lambda url, **kw: calls.append(url) or next(replies)) + result = SendStub().send_text_message(LID, "hello", quoted=QUOTED) + assert len(calls) == 2 + assert all(url.endswith('/send-reply') for url in calls) + assert result['reason'] == 'quoted_message_not_found' + + +@pytest.mark.parametrize("quoted", [None, {"key": {"remoteJid": "status@broadcast", "id": "X"}}]) +def test_plain_and_status_sends_do_not_use_the_ordinary_reply_contract(monkeypatch, quoted): + patch_main_global(monkeypatch, "api_post", lambda *a, **kw: Response()) + result = SendStub().send_text_message(GROUP, "hello", quoted=quoted) + assert result.get("ambiguous") is True + assert result.get("reason") is None + + +def message(): + return {"_local_id": "local", "_local_pending": True, + "key": {"id": "local", "remoteJid": GROUP, "fromMe": True}, + "messageType": "conversation", "message": {"conversation": "draft"}, + "_reply_draft_text": "> keep this\n@Name https://example.com", + "_reply_draft_mentions": [MENTION], "_reply_draft_mention_names": {MENTION: "Name"}} + + +def chats(record): + return {GROUP: {"messages": {"messages": {"records": [record]}}}} + + +def test_failure_marks_the_origin_record_and_preserves_all_text(): + record = message() + original = copy.deepcopy(record) + assert mark_missing_reply_quote(chats(record), 'local') == (GROUP, record) + assert record['_send_failed'] and record['_reply_quote_missing'] + assert not record['_local_pending'] + assert record['message'] == original['message'] + assert record['_reply_draft_text'] == original['_reply_draft_text'] + assert mark_missing_reply_quote(chats(record), 'deleted') is None + + +def test_delayed_callback_updates_original_chat_without_touching_a_new_draft(): + record = message() + events = [] + stub = types.SimpleNamespace(chats=chats(record), background_mode=False, + db=types.SimpleNamespace(insert_message=lambda jid, msg: events.append(('message', jid, msg['_local_id']))), + _is_cancelled_send=lambda local: False, + _schedule_save=lambda **kw: events.append(('save', kw)), + _on_message_failed=lambda local: events.append(('failed', local)), + output=lambda *a, **kw: events.append(('speech', a)), + i18n=types.SimpleNamespace(t=lambda key: key)) + MainWindow._on_reply_quote_missing(stub, 'local') + assert events == [('message', GROUP, 'local'), ('save', {'dirty_jid': GROUP}), ('failed', 'local'), + ('speech', ('reply_quote_missing_error',))] + + +@pytest.mark.parametrize('cancelled', [True, False]) +def test_deleted_or_cancelled_record_is_not_restored_or_announced(cancelled): + dropped = [] + stub = types.SimpleNamespace(chats={}, + _is_cancelled_send=lambda local: cancelled, + _on_cancelled_message_dropped=lambda local: dropped.append(local)) + MainWindow._on_reply_quote_missing(stub, 'local') + assert dropped == (['local'] if cancelled else []) + + +class Field: + def __init__(self, text=''): + self.text, self.focused = text, False + def GetValue(self): + return self.text + def SetValue(self, text): + self.text = text + def SetInsertionPointEnd(self): + pass + def SetFocus(self): + self.focused = True + + +def panel(): + output = [] + return types.SimpleNamespace(conversation={'remoteJid': GROUP}, + message_field=Field(), _quoted_message=None, _editing_message_id=None, + _pending_mentions=[], _pending_mention_display_names={}, + _rebuild_mention_pills=lambda: None, + main_window=types.SimpleNamespace(output=lambda *a, **kw: output.append(a), + i18n=types.SimpleNamespace(t=lambda key: key)), output=output) + + +def test_explicit_recovery_restores_composed_text_and_mentions_without_sending(): + record = message() + mark_missing_reply_quote(chats(record), 'local') + stub = panel() + ConversationsPanel._on_restore_unsent_reply(stub, record) + assert stub.message_field.text.replace('\r\n', '\n') == record['_reply_draft_text'] + assert stub._pending_mentions == [MENTION] + assert stub._pending_mention_display_names == {MENTION: 'Name'} + assert stub._quoted_message is None + assert stub._last_sent_signature is None + assert stub.message_field.focused + assert stub.output == [] + + +@pytest.mark.parametrize('busy', ['text', 'quote', 'edit', 'mentions']) +def test_recovery_never_overwrites_other_composition_state(busy): + record = message() + mark_missing_reply_quote(chats(record), 'local') + stub = panel() + if busy == 'text': stub.message_field.text = 'new draft' + if busy == 'quote': stub._quoted_message = {'key': {'id': 'another'}} + if busy == 'edit': stub._editing_message_id = 'another' + if busy == 'mentions': stub._pending_mentions = [LID] + original = stub.message_field.text + ConversationsPanel._on_restore_unsent_reply(stub, record) + assert stub.message_field.text == original + assert not stub.message_field.focused + assert stub.output == [('reply_restore_draft_busy',)] + + +def test_recovery_does_not_insert_text_into_another_chat(): + record = message() + mark_missing_reply_quote(chats(record), 'local') + stub = panel() + stub.conversation = {'remoteJid': LID} + ConversationsPanel._on_restore_unsent_reply(stub, record) + assert stub.message_field.text == '' and stub.output == [] + + +def test_composition_snapshot_keeps_display_text_and_wire_mentions(monkeypatch): + import ui.conversation_panel.text_sending as module + monkeypatch.setattr(module, 'append_message_row', lambda *a: None) + monkeypatch.setattr(module, 'message_row_count', lambda *a: 0) + saved, queued = [], [] + stub = panel() + stub._pending_mentions = [MENTION] + stub._pending_mention_display_names = {MENTION: 'Name'} + stub._quoted_message = QUOTED + stub._pending_link_preview = {'title': 'do not inject this', 'description': '', 'canonicalUrl': 'https://example.com'} + stub._build_mention_payload = lambda text: ('@5511888888888 hello', [MENTION]) + stub._sorted_messages = [] + stub._clear_empty_placeholder = lambda: None + stub._render_message_line = lambda msg: 'rendered' + stub._hide_mention_suggestions = lambda: None + stub._on_cancel_reply = lambda: None + stub._clear_link_preview = lambda: None + stub._register_virtual_msg = lambda msg: saved.append(msg) + stub.main_window.message_queue = types.SimpleNamespace(enqueue=queued.append) + stub.main_window.mark_conversation_as_read = lambda jid: None + stub.main_window._schedule_set_chats = lambda: None + ConversationsPanel._send_new_text_message(stub, '@Name hello', GROUP) + assert saved[0]['_reply_draft_text'] == '@Name hello' + assert saved[0]['_reply_draft_mentions'] == [MENTION] + assert saved[0]['_reply_draft_mention_names'] == {MENTION: 'Name'} + assert queued[0].text == '@5511888888888 hello' + assert queued[0].mentioned_jids == [MENTION] + assert saved[0]['_reply_draft_mention_names'] is not stub._pending_mention_display_names + + +def test_empty_local_id_cannot_mark_a_real_message_failed(): + record = {'key': {'id': 'REAL'}, 'message': {'conversation': 'real'}} + assert mark_missing_reply_quote(chats(record), None) is None + assert '_send_failed' not in record + + +def test_queue_reports_the_special_failure_once_and_drops_it(monkeypatch): + import core.message_queue as module + monkeypatch.setattr(module.wx, 'CallAfter', lambda fn, *a: fn(*a)) + complete = threading.Event() + calls = [] + stub = types.SimpleNamespace(offline_mode=False, _wa_connected=True, + send_text_message=lambda *a, **kw: calls.append('send') or + {'ok': False, 'retry': False, 'reason': 'quoted_message_not_found'}, + _on_reply_quote_missing=lambda local: calls.append(local) or complete.set()) + queue = MessageQueue(stub) + try: + queue.enqueue(PendingMessage('local', GROUP, text='draft', quoted=QUOTED)) + assert complete.wait(timeout=2) + finally: + queue.stop() + assert calls == ['send', 'local'] + assert queue._pending == {} + + +@pytest.mark.parametrize('for_everyone', [False, True]) +def test_deleting_a_missing_quote_failure_is_local_only(monkeypatch, tmp_path, for_everyone): + from tests.test_unconfirmed_send_recovery import _DeleteStub, _patch_delete_dialog + import wx + record = message() + mark_missing_reply_quote(chats(record), 'local') + _patch_delete_dialog(monkeypatch, result=wx.ID_OK, everyone_selected=for_everyone, tmp_path=tmp_path) + stub = _DeleteStub(record) + stub._on_menu_delete_message(0) + assert stub.removed_ids == [{'local'}] + assert stub.main_window.everyone_calls == [] + assert stub.main_window.for_me_calls == [] + + +def test_failed_reply_and_mentions_survive_database_close_and_reopen(tmp_path, fernet_key): + from core.database_bridge import DatabaseBridge + path = str(tmp_path / 'reply.db') + record = message() + record['messageTimestamp'] = 1234 + db = DatabaseBridge(path, fernet_key) + try: + db.upsert_chat(GROUP, {'remoteJid': GROUP}) + stub = types.SimpleNamespace(chats=chats(record), background_mode=True, db=db, + _is_cancelled_send=lambda local: False, _schedule_save=lambda **kw: None, + _on_message_failed=lambda local: None) + MainWindow._on_reply_quote_missing(stub, 'local') + finally: + db.close() + db = DatabaseBridge(path, fernet_key) + try: + [restored] = db.get_messages(GROUP) + finally: + db.close() + for field in ('_local_id', '_send_failed', '_reply_quote_missing', '_local_pending', + '_reply_draft_text', '_reply_draft_mentions', '_reply_draft_mention_names', 'message'): + assert restored[field] == record[field] + # The persisted record itself is sufficient for recovery after restart. + stub = panel() + ConversationsPanel._on_restore_unsent_reply(stub, restored) + assert stub.message_field.text.replace('\r\n', '\n') == record['_reply_draft_text'] + + +def test_database_failure_keeps_memory_text_and_reports_that_it_was_not_saved(): + record = message() + spoken = [] + def fail(*a): + raise OSError('simulated write failure') + stub = types.SimpleNamespace(chats=chats(record), background_mode=False, + db=types.SimpleNamespace(insert_message=fail), + _is_cancelled_send=lambda local: False, _schedule_save=lambda **kw: None, + _on_message_failed=lambda local: None, output=lambda text, **kw: spoken.append(text), + i18n=types.SimpleNamespace(t=lambda key: key)) + MainWindow._on_reply_quote_missing(stub, 'local') + assert record['_reply_draft_text'] + assert spoken == ['reply_quote_missing_error', 'reply_quote_save_failed'] + + +@pytest.mark.parametrize('entry', ['shortcut', 'stale_menu']) +def test_remote_edit_entry_points_use_guarded_recovery(monkeypatch, entry): + from core.message_edit import edit_kind + record = message() + mark_missing_reply_quote(chats(record), 'local') + assert edit_kind(record) is None + stub = panel() + stub.message_field.text = 'another draft' + stub._sorted_messages = [record] + stub.messages_list = types.SimpleNamespace(GetFirstSelected=lambda: 0) + stub._is_separator = lambda msg: False + stub._on_restore_unsent_reply = lambda msg: ConversationsPanel._on_restore_unsent_reply(stub, msg) + if entry == 'shortcut': + ConversationsPanel._on_accel_edit_message(stub, None) + else: + ConversationsPanel._on_menu_edit_message(stub, 0, record) + assert stub.message_field.text == 'another draft' + assert stub._editing_message_id is None + assert stub.output == [('reply_restore_draft_busy',)] + + +@pytest.mark.parametrize('record, expected', [(None, False), ({}, False), + ({'_reply_quote_missing': True}, False), ({'_send_failed': True}, False), + ({'_reply_quote_missing': True, '_send_failed': True}, True)]) +def test_only_the_definite_missing_quote_state_uses_local_recovery(record, expected): + assert is_missing_quote_failure(record) is expected + + +def test_self_chat_quote_failure_deletes_without_a_remote_uuid_request(monkeypatch, tmp_path): + from tests.test_unconfirmed_send_recovery import _DeleteStub, _patch_delete_dialog + record = message() + mark_missing_reply_quote(chats(record), 'local') + _patch_delete_dialog(monkeypatch, tmp_path=tmp_path) + stub = _DeleteStub(record) + stub.main_window._is_self_jid = lambda jid: True + stub._confirm_local_only_delete = lambda count: True + stub._on_menu_delete_message(0) + assert stub.removed_ids == [{'local'}] + assert stub.main_window.everyone_calls == [] + assert stub.main_window.for_me_calls == [] + + +@pytest.mark.parametrize('for_everyone', [False, True]) +@pytest.mark.parametrize('mixed', [False, True]) +def test_bulk_delete_removes_failed_reply_locally_and_keeps_real_message_scope(monkeypatch, tmp_path, for_everyone, mixed): + from tests.test_unconfirmed_send_recovery import _DeleteStub, _patch_delete_dialog + import ui.conversation_panel.bulk_messages as module + _patch_delete_dialog(monkeypatch, everyone_selected=for_everyone, tmp_path=tmp_path) + monkeypatch.setattr(module.wx, 'CallAfter', lambda fn, *a: fn(*a)) + monkeypatch.setattr(module.threading, 'Thread', lambda target, **kw: types.SimpleNamespace(start=target)) + record = message() + mark_missing_reply_quote(chats(record), 'local') + stub = _DeleteStub(record) + real = {'key': {'id': 'REAL', 'remoteJid': GROUP, 'fromMe': True}, 'messageType': 'conversation'} + stub._sorted_messages = [record, real] if mixed else [record] + stub.selected_messages = {m['key']['id'] for m in stub._sorted_messages} + stub._group_admin_delete_override = lambda: False + stub._refresh_message_rows_by_ids = lambda ids: None + stub._on_bulk_delete_for_everyone_done = lambda failed: None + stub._apply_confirmed_revoke = lambda *a: None + stub._delete_target_jid = lambda key: key['remoteJid'] + ConversationsPanel._on_mass_delete_messages(stub, None) + expected = {'local', 'REAL'} if mixed and not for_everyone else {'local'} + assert stub.removed_ids == [expected] + every = stub.main_window.everyone_calls + local = stub.main_window.for_me_calls + assert len(every) == int(mixed and for_everyone) + assert len(local) == int(mixed and not for_everyone) + assert all(key['id'] == 'REAL' for jid, key in every + local) + assert not stub.selected_messages diff --git a/tests/test_reply_quote_preflight_runtime.py b/tests/test_reply_quote_preflight_runtime.py new file mode 100644 index 000000000..4990567be --- /dev/null +++ b/tests/test_reply_quote_preflight_runtime.py @@ -0,0 +1,64 @@ +"""Execute the actual preflight and response builder with a fake browser page.""" + +import os +from pathlib import Path +import shutil +import subprocess + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +RUNTIME = ROOT / 'client/api_patches/src/util/quotedMessagePreflight.ts' + + +def node(): + bundled = ROOT / 'client/node' / ('node.exe' if os.name == 'nt' else 'node') + return os.environ.get('WINZAPP_TEST_NODE') or (str(bundled) if bundled.exists() else shutil.which('node')) + + +HARNESS = r""" +const fs = require('fs'), vm = require('vm'), assert = require('assert'); +const source = fs.readFileSync(process.argv[1], 'utf8').replace(/^export /gm, ''); +const world = vm.createContext({window: {WPP: {chat: {}}}}); +vm.runInContext(source, world); +let reads = 0; +const client = {page: {evaluate: expression => vm.runInContext(expression, world)}}; +(async () => { + world.window.WPP.chat.getMessageById = async id => { reads++; assert.equal(id, 'false_chat@c.us_ID'); return {id}; }; + assert.equal(await world.replyQuoteIsMissing(client, 'false_chat@c.us_ID'), false); + world.window.WPP.chat.getMessageById = async () => { throw {code: 'msg_not_found'}; }; + assert.equal(await world.replyQuoteIsMissing(client, 'false_chat@c.us_ID'), true); + for (const error of [new Error('Message X not found'), {code: 'TargetCloseError'}, {code: 'other'}]) { + world.window.WPP.chat.getMessageById = async () => {throw error;}; + await assert.rejects(() => world.replyQuoteIsMissing(client, 'X'), e => e === error); + } + world.window.WPP.chat.getMessageById = async () => null; + await assert.rejects(() => world.replyQuoteIsMissing(client, 'X')); + let code, body; + const res = {status: n => {code = n; return res;}, json: b => {body = b; return res;}}; + world.missingReplyQuoteResponse(res); + assert.equal(code, 500); // old clients must NEVER strip the quote on a 4xx + assert.equal(JSON.stringify(body), JSON.stringify({status:'error',code:'quoted_message_not_found',stage:'quote_resolution',sendAttempted:false})); + assert.equal(reads, 1); + console.log('preflight behavior OK'); +})().catch(e => { console.error(e); process.exitCode = 1; }); +""" + + +@pytest.mark.skipif(node() is None, reason='Node is not available') +def test_runtime_distinguishes_only_a_proven_missing_quote(): + done = subprocess.run([node(), '-e', HARNESS, str(RUNTIME)], + capture_output=True, text=True, encoding='utf-8', timeout=10) + assert done.returncode == 0, done.stderr + assert 'preflight behavior OK' in done.stdout + + +@pytest.mark.parametrize('name', ['replyMessage', 'sendMentioned']) +def test_preflight_is_wired_before_the_recipient_loop(name): + # Structural guard: the behavioral tests above cannot detect a controller + # moving this safe read after a send to an earlier recipient. + source = (ROOT / 'client/api_patches/src/controller/messageController.ts').read_text(encoding='utf-8') + part = source.split('export async function ' + name + '(', 1)[1].split('export async function ', 1)[0] + assert part.index('replyQuoteIsMissing(') < part.index('for (const contato of phone)') + assert part.index('missingReplyQuoteResponse(res)') < part.index('for (const contato of phone)') + assert "!messageId.includes('status@broadcast')" in part diff --git a/translations/en-US/LC_MESSAGES/winzapp.po b/translations/en-US/LC_MESSAGES/winzapp.po index b26206ebf..ee8c3e189 100644 --- a/translations/en-US/LC_MESSAGES/winzapp.po +++ b/translations/en-US/LC_MESSAGES/winzapp.po @@ -7128,6 +7128,32 @@ msgid "" msgstr "" "Could not quote the original message; sending as a regular message instead." +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_missing_error" +msgid "" +"The quoted message was not found, so your reply was not sent. Your text is " +"kept in the message list. Choose 'Edit unsent reply without its quote' in " +"its menu to continue." +msgstr "" +"The quoted message was not found, so your reply was not sent. Your text is " +"kept in the message list. Choose 'Edit unsent reply without its quote' in " +"its menu to continue." + +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_save_failed" +msgid "" +"The reply could not be saved to disk. Its text is still in the message list;" +" copy it before closing WinZapp." +msgstr "" +"The reply could not be saved to disk. Its text is still in the message list;" +" copy it before closing WinZapp." + +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/text_sending.py +msgctxt "reply_restore_draft_busy" +msgid "Finish or clear your current message before restoring this reply." +msgstr "Finish or clear your current message before restoring this reply." + #: translations/en-US/LC_MESSAGES/winzapp.po #: client/ui/conversation_panel/message_menu.py #, python-brace-format @@ -7161,6 +7187,12 @@ msgctxt "restart_required_message" msgid "Some changes will only take effect after restarting WinZapp." msgstr "Some changes will only take effect after restarting WinZapp." +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/message_menu.py +msgctxt "restore_unsent_reply" +msgid "Edit unsent reply without its quote" +msgstr "Edit unsent reply without its quote" + #: translations/en-US/LC_MESSAGES/winzapp.po client/status_tab/status_voice.py #: client/ui/conversations.py msgctxt "resume_recording" diff --git a/translations/es-ES/LC_MESSAGES/winzapp.po b/translations/es-ES/LC_MESSAGES/winzapp.po index 7b02b3eaa..cbb78aa2e 100644 --- a/translations/es-ES/LC_MESSAGES/winzapp.po +++ b/translations/es-ES/LC_MESSAGES/winzapp.po @@ -7209,6 +7209,32 @@ msgid "" msgstr "" "No se pudo citar el mensaje original; se enviará como un mensaje normal." +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_missing_error" +msgid "" +"The quoted message was not found, so your reply was not sent. Your text is " +"kept in the message list. Choose 'Edit unsent reply without its quote' in " +"its menu to continue." +msgstr "" +"No se encontró el mensaje citado, por lo que tu respuesta no se envió. El " +"texto se conserva en la lista de mensajes. Elige 'Editar respuesta no " +"enviada sin la cita' en su menú para continuar." + +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_save_failed" +msgid "" +"The reply could not be saved to disk. Its text is still in the message list;" +" copy it before closing WinZapp." +msgstr "" +"No se pudo guardar la respuesta en el disco. El texto sigue en la lista de " +"mensajes; cópialo antes de cerrar WinZapp." + +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/text_sending.py +msgctxt "reply_restore_draft_busy" +msgid "Finish or clear your current message before restoring this reply." +msgstr "Termina o borra el mensaje actual antes de recuperar esta respuesta." + #: translations/en-US/LC_MESSAGES/winzapp.po #: client/ui/conversation_panel/message_menu.py #, python-brace-format @@ -7242,6 +7268,12 @@ msgctxt "restart_required_message" msgid "Some changes will only take effect after restarting WinZapp." msgstr "Algunos cambios solo tendrán efecto después de reiniciar WinZapp." +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/message_menu.py +msgctxt "restore_unsent_reply" +msgid "Edit unsent reply without its quote" +msgstr "Editar respuesta no enviada sin la cita" + #: translations/en-US/LC_MESSAGES/winzapp.po client/status_tab/status_voice.py #: client/ui/conversations.py msgctxt "resume_recording" diff --git a/translations/pl/LC_MESSAGES/winzapp.po b/translations/pl/LC_MESSAGES/winzapp.po index 71e581c9d..abcd9c76c 100644 --- a/translations/pl/LC_MESSAGES/winzapp.po +++ b/translations/pl/LC_MESSAGES/winzapp.po @@ -7185,6 +7185,33 @@ msgstr "" "Nie udało się zacytować oryginalnej wiadomości; zostanie wysłana jako zwykła" " wiadomość." +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_missing_error" +msgid "" +"The quoted message was not found, so your reply was not sent. Your text is " +"kept in the message list. Choose 'Edit unsent reply without its quote' in " +"its menu to continue." +msgstr "" +"Nie znaleziono cytowanej wiadomości, więc odpowiedź nie została wysłana. Jej" +" tekst zachowano na liście wiadomości. Aby kontynuować, wybierz w jej menu " +"'Edytuj niewysłaną odpowiedź bez cytatu'." + +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_save_failed" +msgid "" +"The reply could not be saved to disk. Its text is still in the message list;" +" copy it before closing WinZapp." +msgstr "" +"Nie udało się zapisać odpowiedzi na dysku. Jej tekst nadal jest na liście " +"wiadomości; skopiuj go przed zamknięciem WinZapp." + +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/text_sending.py +msgctxt "reply_restore_draft_busy" +msgid "Finish or clear your current message before restoring this reply." +msgstr "" +"Zakończ lub wyczyść bieżącą wiadomość przed przywróceniem tej odpowiedzi." + #: translations/en-US/LC_MESSAGES/winzapp.po #: client/ui/conversation_panel/message_menu.py #, python-brace-format @@ -7219,6 +7246,12 @@ msgid "Some changes will only take effect after restarting WinZapp." msgstr "" "Niektóre zmiany zaczną obowiązywać dopiero po ponownym uruchomieniu WinZapp." +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/message_menu.py +msgctxt "restore_unsent_reply" +msgid "Edit unsent reply without its quote" +msgstr "Edytuj niewysłaną odpowiedź bez cytatu" + #: translations/en-US/LC_MESSAGES/winzapp.po client/status_tab/status_voice.py #: client/ui/conversations.py msgctxt "resume_recording" diff --git a/translations/pt-BR/LC_MESSAGES/winzapp.po b/translations/pt-BR/LC_MESSAGES/winzapp.po index a5f514014..426d22695 100644 --- a/translations/pt-BR/LC_MESSAGES/winzapp.po +++ b/translations/pt-BR/LC_MESSAGES/winzapp.po @@ -7193,6 +7193,32 @@ msgid "" msgstr "" "Não foi possível citar a mensagem original; enviando como mensagem normal." +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_missing_error" +msgid "" +"The quoted message was not found, so your reply was not sent. Your text is " +"kept in the message list. Choose 'Edit unsent reply without its quote' in " +"its menu to continue." +msgstr "" +"A mensagem citada não foi encontrada, por isso sua resposta não foi enviada." +" Seu texto foi mantido na lista de mensagens. Escolha 'Editar resposta não " +"enviada sem a citação' no menu dela para continuar." + +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_save_failed" +msgid "" +"The reply could not be saved to disk. Its text is still in the message list;" +" copy it before closing WinZapp." +msgstr "" +"Não foi possível salvar a resposta no disco. O texto ainda está na lista de " +"mensagens; copie-o antes de fechar o WinZapp." + +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/text_sending.py +msgctxt "reply_restore_draft_busy" +msgid "Finish or clear your current message before restoring this reply." +msgstr "Conclua ou limpe a mensagem atual antes de recuperar esta resposta." + #: translations/en-US/LC_MESSAGES/winzapp.po #: client/ui/conversation_panel/message_menu.py #, python-brace-format @@ -7226,6 +7252,12 @@ msgctxt "restart_required_message" msgid "Some changes will only take effect after restarting WinZapp." msgstr "Algumas alterações só terão efeito após reiniciar o WinZapp." +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/message_menu.py +msgctxt "restore_unsent_reply" +msgid "Edit unsent reply without its quote" +msgstr "Editar resposta não enviada sem a citação" + #: translations/en-US/LC_MESSAGES/winzapp.po client/status_tab/status_voice.py #: client/ui/conversations.py msgctxt "resume_recording" diff --git a/translations/pt-PT/LC_MESSAGES/winzapp.po b/translations/pt-PT/LC_MESSAGES/winzapp.po index 6b4221d90..a299d189c 100644 --- a/translations/pt-PT/LC_MESSAGES/winzapp.po +++ b/translations/pt-PT/LC_MESSAGES/winzapp.po @@ -7229,6 +7229,32 @@ msgid "" msgstr "" "Não foi possível citar a mensagem original; a enviar como mensagem normal." +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_missing_error" +msgid "" +"The quoted message was not found, so your reply was not sent. Your text is " +"kept in the message list. Choose 'Edit unsent reply without its quote' in " +"its menu to continue." +msgstr "" +"A mensagem citada não foi encontrada, pelo que a sua resposta não foi " +"enviada. O texto foi mantido na lista de mensagens. Escolha 'Editar resposta" +" não enviada sem a citação' no respetivo menu para continuar." + +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_save_failed" +msgid "" +"The reply could not be saved to disk. Its text is still in the message list;" +" copy it before closing WinZapp." +msgstr "" +"Não foi possível guardar a resposta no disco. O texto ainda está na lista de" +" mensagens; copie-o antes de fechar o WinZapp." + +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/text_sending.py +msgctxt "reply_restore_draft_busy" +msgid "Finish or clear your current message before restoring this reply." +msgstr "Conclua ou limpe a mensagem atual antes de recuperar esta resposta." + #: translations/en-US/LC_MESSAGES/winzapp.po #: client/ui/conversation_panel/message_menu.py #, python-brace-format @@ -7262,6 +7288,12 @@ msgctxt "restart_required_message" msgid "Some changes will only take effect after restarting WinZapp." msgstr "Algumas alterações só terão efeito depois de reiniciar o WinZapp." +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/message_menu.py +msgctxt "restore_unsent_reply" +msgid "Edit unsent reply without its quote" +msgstr "Editar resposta não enviada sem a citação" + #: translations/en-US/LC_MESSAGES/winzapp.po client/status_tab/status_voice.py #: client/ui/conversations.py msgctxt "resume_recording" diff --git a/translations/ro/LC_MESSAGES/winzapp.po b/translations/ro/LC_MESSAGES/winzapp.po index 776e6435f..0a5fef557 100644 --- a/translations/ro/LC_MESSAGES/winzapp.po +++ b/translations/ro/LC_MESSAGES/winzapp.po @@ -7209,6 +7209,33 @@ msgid "" msgstr "" "Mesajul original nu a putut fi citat; mesajul va fi trimis ca unul obișnuit." +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_missing_error" +msgid "" +"The quoted message was not found, so your reply was not sent. Your text is " +"kept in the message list. Choose 'Edit unsent reply without its quote' in " +"its menu to continue." +msgstr "" +"Mesajul citat nu a fost găsit, deci răspunsul nu a fost trimis. Textul a " +"fost păstrat în lista de mesaje. Alege „Editează răspunsul netrimis fără " +"citat” din meniul acestuia pentru a continua." + +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_save_failed" +msgid "" +"The reply could not be saved to disk. Its text is still in the message list;" +" copy it before closing WinZapp." +msgstr "" +"Răspunsul nu a putut fi salvat pe disc. Textul este încă în lista de mesaje;" +" copiază-l înainte de a închide WinZapp." + +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/text_sending.py +msgctxt "reply_restore_draft_busy" +msgid "Finish or clear your current message before restoring this reply." +msgstr "" +"Finalizează sau șterge mesajul curent înainte de a restaura acest răspuns." + #: translations/en-US/LC_MESSAGES/winzapp.po #: client/ui/conversation_panel/message_menu.py #, python-brace-format @@ -7242,6 +7269,12 @@ msgctxt "restart_required_message" msgid "Some changes will only take effect after restarting WinZapp." msgstr "Unele modificări vor intra în vigoare numai după repornirea WinZapp." +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/message_menu.py +msgctxt "restore_unsent_reply" +msgid "Edit unsent reply without its quote" +msgstr "Editează răspunsul netrimis fără citat" + #: translations/en-US/LC_MESSAGES/winzapp.po client/status_tab/status_voice.py #: client/ui/conversations.py msgctxt "resume_recording" diff --git a/translations/tr-TR/LC_MESSAGES/winzapp.po b/translations/tr-TR/LC_MESSAGES/winzapp.po index 82f206f45..73859f245 100644 --- a/translations/tr-TR/LC_MESSAGES/winzapp.po +++ b/translations/tr-TR/LC_MESSAGES/winzapp.po @@ -7138,6 +7138,34 @@ msgstr "" "Özgün mesaj alıntılanamadı; bunun yerine normal bir mesaj olarak " "gönderiliyor." +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_missing_error" +msgid "" +"The quoted message was not found, so your reply was not sent. Your text is " +"kept in the message list. Choose 'Edit unsent reply without its quote' in " +"its menu to continue." +msgstr "" +"Alıntılanan mesaj bulunamadığından yanıtınız gönderilmedi. Metniniz mesaj " +"listesinde korundu. Devam etmek için menüsünden 'Gönderilmemiş yanıtı " +"alıntısız düzenle' seçeneğini seçin." + +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_save_failed" +msgid "" +"The reply could not be saved to disk. Its text is still in the message list;" +" copy it before closing WinZapp." +msgstr "" +"Yanıt diske kaydedilemedi. Metni hâlâ mesaj listesinde; WinZapp’ı kapatmadan" +" önce kopyalayın." + +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/text_sending.py +msgctxt "reply_restore_draft_busy" +msgid "Finish or clear your current message before restoring this reply." +msgstr "" +"Bu yanıtı geri almadan önce yazmakta olduğunuz mesajı tamamlayın veya " +"temizleyin." + #: translations/en-US/LC_MESSAGES/winzapp.po #: client/ui/conversation_panel/message_menu.py #, python-brace-format @@ -7173,6 +7201,12 @@ msgstr "" "Bazı değişiklikler yalnızca WinZapp yeniden başlatıldıktan sonra geçerli " "olacaktır." +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/message_menu.py +msgctxt "restore_unsent_reply" +msgid "Edit unsent reply without its quote" +msgstr "Gönderilmemiş yanıtı alıntısız düzenle" + #: translations/en-US/LC_MESSAGES/winzapp.po client/status_tab/status_voice.py #: client/ui/conversations.py msgctxt "resume_recording" diff --git a/translations/winzapp.pot b/translations/winzapp.pot index ab413b1bf..1ed1bc0d9 100644 --- a/translations/winzapp.pot +++ b/translations/winzapp.pot @@ -6737,6 +6737,27 @@ msgid "" "Could not quote the original message; sending as a regular message instead." msgstr "" +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_missing_error" +msgid "" +"The quoted message was not found, so your reply was not sent. Your text is " +"kept in the message list. Choose 'Edit unsent reply without its quote' in " +"its menu to continue." +msgstr "" + +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_save_failed" +msgid "" +"The reply could not be saved to disk. Its text is still in the message list;" +" copy it before closing WinZapp." +msgstr "" + +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/text_sending.py +msgctxt "reply_restore_draft_busy" +msgid "Finish or clear your current message before restoring this reply." +msgstr "" + #: translations/en-US/LC_MESSAGES/winzapp.po #: client/ui/conversation_panel/message_menu.py #, python-brace-format @@ -6770,6 +6791,12 @@ msgctxt "restart_required_message" msgid "Some changes will only take effect after restarting WinZapp." msgstr "" +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/message_menu.py +msgctxt "restore_unsent_reply" +msgid "Edit unsent reply without its quote" +msgstr "" + #: translations/en-US/LC_MESSAGES/winzapp.po client/status_tab/status_voice.py #: client/ui/conversations.py msgctxt "resume_recording" From d1beb8f8636fe76ce0df0c62058268e8a9d92dcb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 22:28:48 +0000 Subject: [PATCH 2/4] Address review of the missing reply quote recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "Edit unsent reply without its quote" compares the record's chat with the open one through _chat_jids_equivalent(): a reply refused while the chat was keyed by its @lid kept that address after deduplicate_chats() re-keyed the chat to its phone JID, and the restore silently did nothing. Tested with the real resolver, both directions and @c.us. - Romanian: the four new strings use the catalog's formal register, like the neighbouring menu items ("Editați", "Alegeți", "copiați-l"). - send-contract.md: the new bullet moves below the file's header block. - Import order in core/message_edit.py; en-US changelog wording. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WmFGjY3WDiwbRjXL6zhiyY --- client/changelog_en-US.txt | 2 +- client/core/message_edit.py | 2 +- client/ui/conversation_panel/text_sending.py | 8 ++-- docs/traps/send-contract.md | 27 ++++++------ tests/test_missing_reply_quote.py | 43 +++++++++++++++++++- translations/ro/LC_MESSAGES/winzapp.po | 8 ++-- 6 files changed, 65 insertions(+), 25 deletions(-) diff --git a/client/changelog_en-US.txt b/client/changelog_en-US.txt index b18f7ce79..473bfb15e 100644 --- a/client/changelog_en-US.txt +++ b/client/changelog_en-US.txt @@ -84,7 +84,7 @@ WHAT'S NEW FIXES -- Replies: when the original message cannot be found before sending, WinZapp reports the missing quote and keeps your text for editing from the message menu. It does not silently send without the quote or report a connection loss. Uncertain sends still are not retried automatically. +- Replies: when the original message cannot be found before sending, WinZapp reports the missing quote and keeps your text for editing from the message menu. It does not silently send without the quote or report a connection loss. Uncertain sends are still not retried automatically. - Synchronization: replayed history messages keep their chronological position instead of appearing as new arrivals. Temporary failures when checking an alternate chat address remain queued for retry. diff --git a/client/core/message_edit.py b/client/core/message_edit.py index 775a64892..29c6c7d23 100644 --- a/client/core/message_edit.py +++ b/client/core/message_edit.py @@ -43,12 +43,12 @@ from __future__ import annotations import copy -from core.reply_failure import is_missing_quote_failure import json import time from urllib3.exceptions import NewConnectionError +from core.reply_failure import is_missing_quote_failure from core.utils import is_message_forwarded #: How the normaliser tags an edit inside message.protocolMessage.type. diff --git a/client/ui/conversation_panel/text_sending.py b/client/ui/conversation_panel/text_sending.py index b49775c0a..3bef0e7e3 100644 --- a/client/ui/conversation_panel/text_sending.py +++ b/client/ui/conversation_panel/text_sending.py @@ -581,9 +581,11 @@ def _on_restore_unsent_reply(self, msg: dict): """Explicit recovery only: never overwrite another draft or send here.""" if not is_missing_quote_failure(msg): return - if not self.conversation or ( - msg.get("key", {}).get("remoteJid") != self.conversation.get("remoteJid") - ): + # The record keeps the address it was sent to; after an @lid chat is + # re-keyed to its phone JID the two differ but are the same chat. + conv_jid = self.conversation.get("remoteJid", "") if self.conversation else "" + if not conv_jid or not self.main_window._chat_jids_equivalent( + (msg.get("key") or {}).get("remoteJid", ""), conv_jid): return if (self.message_field.GetValue() or self._quoted_message or self._editing_message_id is not None or self._pending_mentions): diff --git a/docs/traps/send-contract.md b/docs/traps/send-contract.md index 8b271f393..a3766225a 100644 --- a/docs/traps/send-contract.md +++ b/docs/traps/send-contract.md @@ -1,19 +1,5 @@ # Send compatibility contract -- **A missing ordinary reply quote has a pre-send verdict (2026-10-13).** - `replyMessage` and quoted `sendMentioned` resolve the quote before the first - recipient's send. Only the in-page `msg_not_found` code emits - `{status:'error', code:'quoted_message_not_found', stage:'quote_resolution', - sendAttempted:false}`. It deliberately stays HTTP 500: old Python clients - strip quotes and resend on 4xx. New clients recognize the complete verdict - before any fallback, keep the failed row/text in its original chat and offer - an explicit edit action. No automatic plain resend or offline transition. - Unknown preflight errors and EVERY exception during/after a send still use - the old ambiguous path. Never infer this verdict from an error message or add - it to a catch around a send. Status replies retain their separate model path. - `quotedMessagePreflight.ts` is shipped through all patch lists; without a - rebuilt API an older Node still returns the existing ambiguous response. - > Exact wppconnect/wa-js pins, `auditSendResult()`, and why HTTP 201 without a message id is a failure. > > Moved verbatim out of `CLAUDE.md` so it is read when the area is touched, not on every session. Keep it here: this is measured history, not a summary. @@ -46,3 +32,16 @@ - **Forwarding goes through `forwardRuntime.ts`, not `forwardMessagesV2` (2026-10-02)**: `deviceController.forwardMessages` evaluates one in-page function that loads the unloaded Bootloader resources of the forward components and calls WhatsApp's `forwardMessages({chat, msgs, ...})` directly, because wa-js's own binding fails with `forward_messages_not_available` on builds with unresolved dependencies. The response keeps its shape (`201 {status:'success', response}`; any failure is a 500, now with a `detail` field). A rejected forward is never retried on the Node side; Python's own single retry in `forward_message` stays. Cause, rule and diagnosis ladder: `docs/traps/whatsapp-web-version-pin.md`. - **Retry overlap: the forward is deduplicated in the page (2026-10-02)**: `forward_message` posts again after its 20 s timeout while the first request's `page.evaluate` is still running (a slow heal, or a slow WhatsApp). Without a guard the retry forwards the same messages a second time. `forwardRuntime.ts` therefore keeps `window.__winzappForwardInflight[chatId + '|' + ids]`: a request whose key is already in flight awaits the first one's result and never sends; the key is released in a `finally` (also on rejection), so a deliberate later forward is not blocked. The heal budget is 10 s so it ends well inside Python's timeout. `response` is passed through `JSON.parse(JSON.stringify())` because a result that cannot be serialized would fail the evaluate AFTER the send, a 500, and the very retry this rule exists to avoid. The same rule applies to any new send route that Python retries. - **`keepVoice`: a forwarded voice message stays a voice message (2026-10-04)**: WhatsApp Web converts a forwarded `ptt` into a plain `audio` on the client, in `WAWebMediaForwardMediaMsg.forwardMediaMsg`: `q = msg.mediaData.toJSON()`, then `q.type = AUDIO` unless the message came from a channel — so a forwarded `ptt` is something WhatsApp itself sends and its apps display. With `keepVoice: true` in the POST (Settings > User Interface, `forward_voice_as_voice`, off by default; Python sends it only for a voice message), `forwardRuntime.ts` wraps `toJSON` of that one message for the length of that one forward so the write to `audio` is refused; everything else is still WhatsApp's own forward, and the copy keeps `isForwarded`/`forwardingScore`. Measured over CDP: the copy arrived as `type ptt`, `isForwarded true`, `forwardingScore 1`, duration and waveform intact. Three rules: the guard is **shared per message** (a `WeakMap`, removed by the last overlapping forward — two independent wrappers restore each other's function and leave one behind for good); it is taken off in a `finally`, also on a rejected forward; and it **cannot fail a send** — if WhatsApp stops converting through `toJSON`, the guard never fires, the message goes out as audio as before, and the handler logs `voice kept for N of M` (the outcome carries `voice: {asked, kept}`). `keepVoice` is part of the in-flight key, so a retry still dedupes but a keep and a plain forward of the same message are two sends. +- **A missing ordinary reply quote has a pre-send verdict (2026-10-13).** + `replyMessage` and quoted `sendMentioned` resolve the quote before the first + recipient's send. Only the in-page `msg_not_found` code emits + `{status:'error', code:'quoted_message_not_found', stage:'quote_resolution', + sendAttempted:false}`. It deliberately stays HTTP 500: old Python clients + strip quotes and resend on 4xx. New clients recognize the complete verdict + before any fallback, keep the failed row/text in its original chat and offer + an explicit edit action. No automatic plain resend or offline transition. + Unknown preflight errors and EVERY exception during/after a send still use + the old ambiguous path. Never infer this verdict from an error message or add + it to a catch around a send. Status replies retain their separate model path. + `quotedMessagePreflight.ts` is shipped through all patch lists; without a + rebuilt API an older Node still returns the existing ambiguous response. diff --git a/tests/test_missing_reply_quote.py b/tests/test_missing_reply_quote.py index fbb379ad8..681b56762 100644 --- a/tests/test_missing_reply_quote.py +++ b/tests/test_missing_reply_quote.py @@ -146,14 +146,29 @@ def SetFocus(self): self.focused = True +PHONE = "5511988887777@s.whatsapp.net" + + +class _Identity: + """The real JID equivalence, with LID bridged to PHONE.""" + _normalize_jid = staticmethod(MainWindow._normalize_jid) + _jid_address_forms = MainWindow._jid_address_forms + _chat_jids_equivalent = MainWindow._chat_jids_equivalent + + def __init__(self, output): + self.output = lambda *a, **kw: output.append(a) + self.i18n = types.SimpleNamespace(t=lambda key: key) + self._lid_to_phone = {LID: PHONE} + self._phone_to_lid = {PHONE: LID} + + def panel(): output = [] return types.SimpleNamespace(conversation={'remoteJid': GROUP}, message_field=Field(), _quoted_message=None, _editing_message_id=None, _pending_mentions=[], _pending_mention_display_names={}, _rebuild_mention_pills=lambda: None, - main_window=types.SimpleNamespace(output=lambda *a, **kw: output.append(a), - i18n=types.SimpleNamespace(t=lambda key: key)), output=output) + main_window=_Identity(output), output=output) def test_explicit_recovery_restores_composed_text_and_mentions_without_sending(): @@ -195,6 +210,30 @@ def test_recovery_does_not_insert_text_into_another_chat(): assert stub.message_field.text == '' and stub.output == [] +@pytest.mark.parametrize('sent_to, open_chat', [(LID, PHONE), (PHONE, LID), + (PHONE.replace('@s.whatsapp.net', '@c.us'), PHONE)]) +def test_recovery_survives_the_chat_being_rekeyed(sent_to, open_chat): + """Sent while the chat was keyed by its @lid, restored after + deduplicate_chats() re-keyed it to the phone JID (or the reverse): the + record keeps the old address, and the recovery must still work.""" + record = message() + record['key']['remoteJid'] = sent_to + mark_missing_reply_quote({sent_to: {"messages": {"messages": {"records": [record]}}}}, 'local') + stub = panel() + stub.conversation = {'remoteJid': open_chat} + ConversationsPanel._on_restore_unsent_reply(stub, record) + assert stub.message_field.text.replace('\r\n', '\n') == record['_reply_draft_text'] + + +def test_recovery_needs_an_open_conversation(): + record = message() + mark_missing_reply_quote(chats(record), 'local') + stub = panel() + stub.conversation = None + ConversationsPanel._on_restore_unsent_reply(stub, record) + assert stub.message_field.text == '' + + def test_composition_snapshot_keeps_display_text_and_wire_mentions(monkeypatch): import ui.conversation_panel.text_sending as module monkeypatch.setattr(module, 'append_message_row', lambda *a: None) diff --git a/translations/ro/LC_MESSAGES/winzapp.po b/translations/ro/LC_MESSAGES/winzapp.po index 0a5fef557..de8f72840 100644 --- a/translations/ro/LC_MESSAGES/winzapp.po +++ b/translations/ro/LC_MESSAGES/winzapp.po @@ -7217,7 +7217,7 @@ msgid "" "its menu to continue." msgstr "" "Mesajul citat nu a fost găsit, deci răspunsul nu a fost trimis. Textul a " -"fost păstrat în lista de mesaje. Alege „Editează răspunsul netrimis fără " +"fost păstrat în lista de mesaje. Alegeți „Editați răspunsul netrimis fără " "citat” din meniul acestuia pentru a continua." #: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py @@ -7227,14 +7227,14 @@ msgid "" " copy it before closing WinZapp." msgstr "" "Răspunsul nu a putut fi salvat pe disc. Textul este încă în lista de mesaje;" -" copiază-l înainte de a închide WinZapp." +" copiați-l înainte de a închide WinZapp." #: translations/en-US/LC_MESSAGES/winzapp.po #: client/ui/conversation_panel/text_sending.py msgctxt "reply_restore_draft_busy" msgid "Finish or clear your current message before restoring this reply." msgstr "" -"Finalizează sau șterge mesajul curent înainte de a restaura acest răspuns." +"Finalizați sau ștergeți mesajul curent înainte de a restaura acest răspuns." #: translations/en-US/LC_MESSAGES/winzapp.po #: client/ui/conversation_panel/message_menu.py @@ -7273,7 +7273,7 @@ msgstr "Unele modificări vor intra în vigoare numai după repornirea WinZapp." #: client/ui/conversation_panel/message_menu.py msgctxt "restore_unsent_reply" msgid "Edit unsent reply without its quote" -msgstr "Editează răspunsul netrimis fără citat" +msgstr "Editați răspunsul netrimis fără citat" #: translations/en-US/LC_MESSAGES/winzapp.po client/status_tab/status_voice.py #: client/ui/conversations.py From 365c9daf634e867d108f27467a88c1b106df460a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 22:46:00 +0000 Subject: [PATCH 3/4] Drop a sent reply's recovery copy instead of storing it forever Every quoted reply carries _reply_draft_text, _reply_draft_mentions and _reply_draft_mention_names so a pre-send "quote missing" refusal can give the text back. On a successful send nothing reads them again, yet the echo match saved them to messages.db with the row: a second copy of every reply's text and a JID-to-name map, kept indefinitely. clear_reply_draft() (core/reply_failure.py) drops them where a send is confirmed: the echo match, before its DB write, and _mark_message_sent when it has the real id. An unconfirmed send keeps them for the echo. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WmFGjY3WDiwbRjXL6zhiyY --- client/core/reply_failure.py | 17 ++++++++ client/main_window/message_events.py | 2 + client/ui/conversation_panel/text_sending.py | 4 +- tests/test_missing_reply_quote.py | 45 +++++++++++++++++++- 4 files changed, 66 insertions(+), 2 deletions(-) diff --git a/client/core/reply_failure.py b/client/core/reply_failure.py index cd61ed412..9750f50f6 100644 --- a/client/core/reply_failure.py +++ b/client/core/reply_failure.py @@ -36,3 +36,20 @@ def mark_missing_reply_quote(chats, local_id): record["_reply_quote_missing"] = True return jid, record return None + + +#: What the composer held when a quoted reply was queued, kept on the row only +#: so a pre-send "quote missing" refusal can give the text back. +REPLY_DRAFT_KEYS = ("_reply_draft_text", "_reply_draft_mentions", "_reply_draft_mention_names") + + +def clear_reply_draft(record) -> None: + """Drop the recovery copy once the reply was sent. + + Nothing reads it after that (the restore needs a missing-quote failure), + and left on the row it is saved to messages.db with every sent reply: a + second copy of the text and a JID-to-name map, forever. + """ + if isinstance(record, dict): + for key in REPLY_DRAFT_KEYS: + record.pop(key, None) diff --git a/client/main_window/message_events.py b/client/main_window/message_events.py index d68a7ca80..68d216fb6 100644 --- a/client/main_window/message_events.py +++ b/client/main_window/message_events.py @@ -30,6 +30,7 @@ apply_caption_edit, is_edit_event, ) +from core.reply_failure import clear_reply_draft from core.call_log import ( call_log_supersedes, is_call_log, @@ -905,6 +906,7 @@ def on_new_message(self, msg: dict): if pending_msg: # Found the corresponding pending message: update it and skip appending a duplicate pending_msg["_local_pending"] = False + clear_reply_draft(pending_msg) # delivered: before the DB write below local_id = pending_msg.get("_local_id") pending_msg["key"]["id"] = msg_id pending_msg["messageTimestamp"] = msg.get("messageTimestamp", pending_msg["messageTimestamp"]) diff --git a/client/ui/conversation_panel/text_sending.py b/client/ui/conversation_panel/text_sending.py index 3bef0e7e3..d2a815c79 100644 --- a/client/ui/conversation_panel/text_sending.py +++ b/client/ui/conversation_panel/text_sending.py @@ -12,7 +12,7 @@ import uuid import wx from core.message_queue import PendingMessage -from core.reply_failure import is_missing_quote_failure +from core.reply_failure import clear_reply_draft, is_missing_quote_failure from ui.conversation_panel.typing_row import append_message_row, message_row_count from app_paths import data_path from ui.conversation_panel.media_paths import ( @@ -496,6 +496,7 @@ def _mark_message_sent(self, local_id: str, real_id: str = None, quote_lost: boo tracked = self._outgoing_virtual_messages.pop(local_id, None) if tracked is not None: tracked["_local_pending"] = False + clear_reply_draft(tracked) if real_id and isinstance(real_id, str): tracked.setdefault("key", {})["id"] = real_id self._media_upload_progress.pop(local_id, None) @@ -518,6 +519,7 @@ def _mark_message_sent(self, local_id: str, real_id: str = None, quote_lost: boo return # Already marked sent on the UI, ignore to prevent duplicate sound and actions msg["_ui_sent"] = True msg["_local_pending"] = False + clear_reply_draft(msg) # The quoted send failed and the message went out as a plain # send: drop the reply contextInfo so the row no longer reads # as "respondendo a …". The quote never reached the recipient. diff --git a/tests/test_missing_reply_quote.py b/tests/test_missing_reply_quote.py index 681b56762..862e6178b 100644 --- a/tests/test_missing_reply_quote.py +++ b/tests/test_missing_reply_quote.py @@ -8,11 +8,13 @@ import pytest from core.message_queue import MessageQueue, PendingMessage -from core.reply_failure import is_missing_quote_failure, mark_missing_reply_quote, quote_missing_before_send +from core.reply_failure import (REPLY_DRAFT_KEYS, clear_reply_draft, is_missing_quote_failure, + mark_missing_reply_quote, quote_missing_before_send) from main import MainWindow from ui.conversations import ConversationsPanel from tests.god_modules import patch_main_global from tests.test_reply_with_mention_keeps_quote import _Stub as SendStub, QUOTED, GROUP, LID, MENTION +from tests import test_duplicate_message_echo_matching as echo VERDICT = {"status": "error", "code": "quoted_message_not_found", @@ -416,3 +418,44 @@ def test_bulk_delete_removes_failed_reply_locally_and_keeps_real_message_scope(m assert len(local) == int(mixed and not for_everyone) assert all(key['id'] == 'REAL' for jid, key in every + local) assert not stub.selected_messages + + +# ── the recovery copy goes once the reply was delivered ────────────────────── + +def _draft(**record): + return {**record, "_reply_draft_text": "text", "_reply_draft_mentions": [MENTION], + "_reply_draft_mention_names": {MENTION: "Name"}} + + +def test_clear_reply_draft_drops_only_the_recovery_copy(): + record = _draft(message={"conversation": "text"}) + clear_reply_draft(record) + assert record == {"message": {"conversation": "text"}} + clear_reply_draft(record) # idempotent + clear_reply_draft(None) # and tolerant + + +def test_a_reply_marked_sent_no_longer_carries_its_draft(): + msg = echo._make_msg(**_draft()) + panel = echo._make_panel(msg) + ConversationsPanel._mark_message_sent(panel, "loc-1", real_id="REAL") + assert not any(key in msg for key in REPLY_DRAFT_KEYS) + + +def test_a_reply_whose_send_is_still_unconfirmed_keeps_its_draft(): + """No real id yet: the row stays pending for the echo, draft included.""" + msg = echo._make_msg(**_draft()) + panel = echo._make_panel(msg) + ConversationsPanel._mark_message_sent(panel, "loc-1", real_id=None) + assert all(key in msg for key in REPLY_DRAFT_KEYS) + + +def test_the_echo_of_a_reply_drops_its_draft_before_it_is_saved(): + saved = [] + record = _draft(**echo._pending_record("locB")) + stub = echo._Stub(echo._chat_with([record])) + stub._msg_bg_executor = types.SimpleNamespace(submit=lambda fn: fn()) + stub.db = types.SimpleNamespace(insert_message=lambda jid, msg: saved.append(dict(msg))) + stub.on_new_message(echo._echo("REAL_B")) + assert record["_local_pending"] is False + assert saved and not any(key in saved[0] for key in REPLY_DRAFT_KEYS) From e4b1d8912021816bec52ba35e4e2ab3c43c61bd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 22:49:41 +0000 Subject: [PATCH 4/4] Also drop the recovery copy when a cancelled reply was delivered anyway _restore_cancelled_message() puts back a reply whose cancel lost the race and stores it under its real id; it mirrors _mark_message_sent() and now clears the _reply_draft_* keys too. The _mark_message_sent test gives the tracked copy and the row separate dicts, so both clears are covered. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WmFGjY3WDiwbRjXL6zhiyY --- client/ui/conversation_panel/text_sending.py | 1 + tests/test_message_cancel_race.py | 20 ++++++++++++++++++++ tests/test_missing_reply_quote.py | 6 +++++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/client/ui/conversation_panel/text_sending.py b/client/ui/conversation_panel/text_sending.py index d2a815c79..18125cded 100644 --- a/client/ui/conversation_panel/text_sending.py +++ b/client/ui/conversation_panel/text_sending.py @@ -813,6 +813,7 @@ def _restore_cancelled_message(self, local_id: str, real_id: str, if real_id: msg["_local_pending"] = False msg.setdefault("key", {})["id"] = real_id + clear_reply_draft(msg) # delivered, as in _mark_message_sent() if quote_lost: # The quoted send failed server-side and it went out as a plain # message: the row must stop reading as a reply, exactly as diff --git a/tests/test_message_cancel_race.py b/tests/test_message_cancel_race.py index b4bd9b095..0b16ab9d1 100644 --- a/tests/test_message_cancel_race.py +++ b/tests/test_message_cancel_race.py @@ -1173,6 +1173,26 @@ def test_a_failed_revoke_puts_the_row_back_under_the_real_id(self, monkeypatch, assert main_window.records() == [msg] assert main_window.spoken == ["cancelled_message_still_sent"] + def test_a_restored_reply_is_stored_without_its_recovery_copy(self, monkeypatch, tmp_path): + """Delivered after all: the reply's draft copy is not saved to the DB.""" + from core.reply_failure import REPLY_DRAFT_KEYS + patch_conversations_global(monkeypatch, "data_path", lambda name: str(tmp_path)) + msg = _pending_text("loc-1", "oi") + msg.update({"_reply_draft_text": "oi", "_reply_draft_mentions": [], + "_reply_draft_mention_names": {}}) + main_window = _EchoMainWindow(records=[msg]) + stored = [] + main_window.db.insert_message = lambda jid, m: stored.append(dict(m)) + main_window.message_queue = _FakeQueue(result=False) + panel = _make_panel(main_window, msg) + ConversationsPanel._cancel_pending_message(panel, msg, "loc-1") + + ConversationsPanel._finish_cancelled_message_delivery( + panel, "loc-1", "REAL_A", False + ) + + assert stored and not any(key in stored[-1] for key in REPLY_DRAFT_KEYS) + def test_a_restored_row_drops_a_quote_that_never_went_out(self, monkeypatch, tmp_path): patch_conversations_global(monkeypatch, "data_path", lambda name: str(tmp_path)) msg = _pending_text("loc-1", "oi") diff --git a/tests/test_missing_reply_quote.py b/tests/test_missing_reply_quote.py index 862e6178b..efad72e74 100644 --- a/tests/test_missing_reply_quote.py +++ b/tests/test_missing_reply_quote.py @@ -438,8 +438,12 @@ def test_clear_reply_draft_drops_only_the_recovery_copy(): def test_a_reply_marked_sent_no_longer_carries_its_draft(): msg = echo._make_msg(**_draft()) panel = echo._make_panel(msg) + # The tracked copy and the row are separate dicts after a list rebuild. + tracked = copy.deepcopy(msg) + panel._outgoing_virtual_messages = {"loc-1": tracked} ConversationsPanel._mark_message_sent(panel, "loc-1", real_id="REAL") - assert not any(key in msg for key in REPLY_DRAFT_KEYS) + for record in (msg, tracked): + assert not any(key in record for key in REPLY_DRAFT_KEYS) def test_a_reply_whose_send_is_still_unconfirmed_keeps_its_draft():