Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 20 additions & 0 deletions client/api_patches/src/controller/messageController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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(
Expand Down
33 changes: 33 additions & 0 deletions client/api_patches/src/util/quotedMessagePreflight.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
2 changes: 2 additions & 0 deletions client/changelog_en-US.txt
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ FIXES

- Updates: "Check for updates" in the Help menu now tells you when WinZapp could not reach the update server, instead of saying nothing.

- 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.

- 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.
Expand Down
2 changes: 2 additions & 0 deletions client/changelog_es-ES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ CORRECCIONES

- Actualizaciones: "Comprobar actualizaciones", en el menú Ayuda, ahora avisa cuando WinZapp no puede conectar con el servidor de actualizaciones, en lugar de no decir nada.

- 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.
Expand Down
2 changes: 2 additions & 0 deletions client/changelog_pl.txt
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ POPRAWKI

- Aktualizacje: „Sprawdź aktualizacje” w menu Pomoc informuje teraz, gdy WinZapp nie może połączyć się z serwerem aktualizacji, zamiast nic nie mówić.

- 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.
Expand Down
2 changes: 2 additions & 0 deletions client/changelog_pt-BR.txt
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ CORREÇÕES

- Atualizações: "Verificar atualizações", no menu Ajuda, agora avisa quando o WinZapp não consegue acessar o servidor de atualizações, em vez de não dizer nada.

- 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.
Expand Down
2 changes: 2 additions & 0 deletions client/changelog_pt-PT.txt
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ CORREÇÕES

- Atualizações: "Verificar atualizações", no menu Ajuda, passa a avisar quando o WinZapp não consegue aceder ao servidor de atualizações, em vez de não dizer nada.

- 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.
Expand Down
2 changes: 2 additions & 0 deletions client/changelog_ro.txt
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ CORECTĂRI

- Actualizări: „Verificați actualizările” din meniul Ajutor vă anunță acum când WinZapp nu poate contacta serverul de actualizări, în loc să nu spună nimic.

- 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.
Expand Down
2 changes: 2 additions & 0 deletions client/changelog_tr-TR.txt
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ DÜZELTMELER

- Güncellemeler: Yardım menüsündeki "Güncellemeleri denetle", WinZapp güncelleme sunucusuna ulaşamadığında artık sessiz kalmak yerine bunu bildirir.

- 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.
Expand Down
3 changes: 3 additions & 0 deletions client/core/message_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@

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.
Expand Down Expand Up @@ -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"
Expand Down
17 changes: 11 additions & 6 deletions client/core/message_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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():
Expand Down Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions client/core/reply_failure.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""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


#: 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)
2 changes: 2 additions & 0 deletions client/main_window/message_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"])
Expand Down
29 changes: 29 additions & 0 deletions client/main_window/sending.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion client/ui/conversation_panel/bulk_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading