From 7e882bb21535cbbecd5a13e156a7b381318a61f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 20:13:07 +0000 Subject: [PATCH 1/3] fix(whatsapp): retry first-contact usync stalls via cached LID; surface recipient-lookup health MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outbound sends addressed to a raw phone number whose device list the bridge has not cached stalled in the usync device-list lookup ("failed to get device list: failed to send usync query: info query timed out") while /health stayed green — the IQ probe usyncs our OWN JID, which succeeds even when resolving an arbitrary recipient times out. Sends addressed by the recipient's cached LID delivered fine (issue #120). The send path now recovers along exactly that observation: a logical send is decomposed into ordered ops (first media carries the caption), and on a usync/device-list failure the remaining ops are retried — first against the recipient's LID when the whatsmeow store knows one (any contact who has messaged this account before; resolved via get_lid_from_pn, defensively, mirroring the existing _lid_to_pn), then once more after a backoff (WHATSAPP_SEND_USYNC_RETRIES, default 1; WHATSAPP_SEND_USYNC_BACKOFF, default 15s). Completed ops are never re-run, so a failure between the parts of a multi-part send cannot duplicate what already went out. Non-usync errors propagate immediately. When every attempt fails, the pending send terminates with a clear error naming the first-contact failure mode (surfaced on /sends via the #116 async flow), and a new recipient_lookup_ok health signal records the degradation — deliberately separate from iq_ok (mixing them would flap against the green own-JID probe) and informational only: it does not flip `connected`, and the evidence decays after WHATSAPP_RECIPIENT_LOOKUP_TTL (default 30 min) since a specific recipient's resolution cannot be re-probed safely. The /gateways page shows it as a warning on an otherwise connected gateway. Closes #120. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YbqfmpARas38AfiwtCjZbn --- README.md | 13 +++ scripts/web-gateway.py | 8 ++ scripts/whatsapp-gateway.py | 209 ++++++++++++++++++++++++++++++---- tests/test_whatsapp_health.py | 111 ++++++++++++++++++ 4 files changed, 317 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index e2c0b22..1220530 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,19 @@ volume. Agents send with `scripts/whatsapp-push.py` and resolve contacts with `scripts/whatsapp-contacts.py`. +Sends to a **first-contact recipient** (a number whose device list the bridge +has not cached) can stall in the usync device-list lookup while the link — and +`/health` — stay green. The gateway handles this itself: on a usync failure it +retries against the recipient's **LID** when the store knows one (any contact +who has messaged this account before — that path delivers where the raw-number +lookup stalls), then once more after a backoff (`WHATSAPP_SEND_USYNC_RETRIES`, +default 1; `WHATSAPP_SEND_USYNC_BACKOFF`, default 15 s), without ever re-sending +the parts of a multi-part send that already went out. If every attempt fails, +the pending send terminates with a clear error on `/sends`, and `/health` +reports `recipient_lookup_ok: false` (shown as a warning on `/gateways`; the +evidence decays after `WHATSAPP_RECIPIENT_LOOKUP_TTL`, default 30 min, since it +cannot be re-probed safely). + ### Telegram accounts Telegram is reached through the sibling `telegram-gateway` service, which logs in diff --git a/scripts/web-gateway.py b/scripts/web-gateway.py index 96eb9c1..4f6333f 100644 --- a/scripts/web-gateway.py +++ b/scripts/web-gateway.py @@ -1970,6 +1970,14 @@ def _render_gateways_html(statuses: list[dict]) -> str: rows.append('

The device is still paired — no QR scan needed; ' 'the gateway recovers on its own or reports the error above.

') elif connected: + # A connected gateway can still be degraded: WhatsApp reports + # recipient_lookup_ok: false while outbound sends to uncached + # (first-contact) recipients fail their device-list lookup, even + # though the link — and the own-JID probe — are fine (issue #120). + if h.get("recipient_lookup_ok") is False: + rl_err = h.get("recipient_lookup_error") or "device-list resolution is failing" + rows.append('

⚠ Outbound sends to new (first-contact) recipients ' + 'are currently failing: ' + html.escape(str(rl_err)) + '

') age = h.get("last_ok_age") if isinstance(age, (int, float)): rows.append(f'

Last verified {int(age)}s ago.

') diff --git a/scripts/whatsapp-gateway.py b/scripts/whatsapp-gateway.py index 434df9d..8420fa5 100644 --- a/scripts/whatsapp-gateway.py +++ b/scripts/whatsapp-gateway.py @@ -304,6 +304,13 @@ def _approval_slug(host_header) -> str: "iq_error": None, "iq_fails": 0, # consecutive failed probes "iq_checked": None, + # Outcome of the last outbound delivery's recipient resolution (issue + # #120). Kept separate from the probe state: the probe usyncs our OWN JID, + # which can succeed while an arbitrary recipient's device-list lookup times + # out — see _note_recipient_lookup(). + "recipient_lookup_ok": None, + "recipient_lookup_error": None, + "recipient_lookup_at": None, } # How often the probe thread completes an IQ round trip (0 disables the probe; @@ -315,6 +322,14 @@ def _approval_slug(host_header) -> str: WHATSAPP_IQ_PROBE_FAILURES = int(os.environ.get("WHATSAPP_IQ_PROBE_FAILURES", "") or "2") # Minimum seconds between automatic reconnect attempts while IQ stays wedged. WHATSAPP_IQ_RECONNECT_BACKOFF = float(os.environ.get("WHATSAPP_IQ_RECONNECT_BACKOFF", "") or "600") +# Outbound usync/device-list failures (issue #120): how many extra attempts a +# send gets after the candidate JIDs are exhausted, and the pause before each. +WHATSAPP_SEND_USYNC_RETRIES = int(os.environ.get("WHATSAPP_SEND_USYNC_RETRIES", "") or "1") +WHATSAPP_SEND_USYNC_BACKOFF = float(os.environ.get("WHATSAPP_SEND_USYNC_BACKOFF", "") or "15") +# How long a recorded recipient-lookup failure keeps /health's +# recipient_lookup_ok at false before the evidence is considered stale (it is +# tied to whoever was last messaged, so it decays instead of being probed). +WHATSAPP_RECIPIENT_LOOKUP_TTL = float(os.environ.get("WHATSAPP_RECIPIENT_LOOKUP_TTL", "") or "1800") def _set_conn(**changes) -> None: @@ -323,6 +338,28 @@ def _set_conn(**changes) -> None: _conn["last_change"] = time.time() +def _note_recipient_lookup(ok: bool, error: str | None = None) -> None: + """Record the outcome of an outbound delivery's recipient resolution. + + Deliberately separate from the IQ-probe state: the probe usyncs our OWN + JID, which can succeed while resolving an arbitrary recipient's device + list times out (issue #120) — folding send failures into iq_ok would flap + against the green probe. This is an informational health signal (exposed + as recipient_lookup_ok in /health and warned about on /gateways); it does + not flip `connected`, because a failure is tied to whoever was messaged + last and there is no safe way to re-probe it — the evidence decays after + WHATSAPP_RECIPIENT_LOOKUP_TTL instead. + """ + with _CONN_LOCK: + _conn["recipient_lookup_at"] = time.time() + if ok: + _conn["recipient_lookup_ok"] = True + _conn["recipient_lookup_error"] = None + else: + _conn["recipient_lookup_ok"] = False + _conn["recipient_lookup_error"] = (error or "recipient lookup failed")[:500] + + def _note_iq_result(ok: bool, error: str | None = None) -> bool: """Fold one IQ probe result into the connection state. @@ -367,6 +404,11 @@ def _health_snapshot() -> dict: + (state["iq_error"] or "info query timed out")) connected = link_up and not iq_wedged qr_available = WHATSAPP_QR_PNG_PATH.exists() + rl_ok = state["recipient_lookup_ok"] + rl_error = state["recipient_lookup_error"] + rl_at = state["recipient_lookup_at"] + if rl_ok is False and rl_at and time.time() - rl_at > WHATSAPP_RECIPIENT_LOOKUP_TTL: + rl_ok, rl_error = None, None # the evidence went stale return { "status": "ok", "configured": True, # linking IS the configuration; nothing else is needed @@ -380,6 +422,8 @@ def _health_snapshot() -> dict: # the device is still linked there, so the /gateways page must show the # error, not a pairing QR that cannot exist. "needs_repair": bool(state["logged_out"] or state["pairing"] or qr_available), + "recipient_lookup_ok": rl_ok, + "recipient_lookup_error": rl_error if rl_ok is False else None, "error": None if connected else error, } @@ -796,38 +840,155 @@ def _download_media(message) -> Path | None: return Path(out) +def _is_usync_error(exc: Exception) -> bool: + """True when a send failure is the usync/device-list resolution class. + + whatsmeow reports a recipient whose device list cannot be resolved (a + first-contact / uncached number, issue #120) as "failed to get device + list: failed to send usync query: info query timed out". Only this class + is retried / LID-falled-back; anything else propagates unchanged. + """ + text = str(exc).lower() + return "usync" in text or "device list" in text or "info query timed out" in text + + +def _pn_to_lid(user: str) -> str | None: + """Resolve a phone-number user to its LID via the bridge's LID store. + + The reverse of _lid_to_pn: whatsmeow keeps the PN↔LID mapping, populated + by inbound traffic and contact sync. A contact who has messaged this + account before therefore has a LID here — and per issue #120 a + LID-addressed send delivers where the phone-number path stalls in the + usync device-list lookup (the LID chat's devices are already cached from + the inbound). Returns None when the store holds no mapping (a true first + contact) or the installed neonize has no lookup method. + """ + client = _wa_client + if client is None or not user: + return None + fn = getattr(client, "get_lid_from_pn", None) + if not callable(fn): + return None + from neonize.utils import build_jid # noqa: PLC0415 - localized bridge dep + try: + with WA_CLIENT_LOCK: + lid = fn(build_jid(user, WA_PN_SERVER)) + except Exception: # noqa: BLE001 - any store miss means "no mapping" + return None + resolved = _jid_user(lid) + if resolved and resolved != user: + return resolved + return None + + +def _build_send_ops(text: str | None, media_paths: list[Path] | None) -> list[dict]: + """Represent one logical send as an ordered list of per-message operations. + + The first media op carries the text as its caption (WhatsApp's own + presentation); a standalone text op is emitted only when no media consumed + it. Keeping the parts explicit is what lets the retry engine resume after + a partial failure without re-sending the parts that already went out. + """ + text = (text or "").strip() + ops: list[dict] = [] + for path in media_paths or []: + ops.append({"kind": "media", "path": Path(path), "caption": text}) + text = "" + if text: + ops.append({"kind": "text", "text": text}) + return ops + + +def _run_send_op(jid, op: dict) -> None: + """Execute one send operation against the bridge (serialized via the lock).""" + client = _wa_client + with WA_CLIENT_LOCK: + if op["kind"] == "text": + client.send_message(jid, op["text"]) + return + path = op["path"] + data = path.read_bytes() + mime = mimetypes.guess_type(str(path))[0] or "application/octet-stream" + if mime.startswith("image/"): + # build_image_message derives the mime type from the bytes itself + # and takes no mime keyword — passing one raises TypeError. + msg = client.build_image_message(data, caption=op["caption"] or "") + else: + # neonize's document builder spells the parameter `mimetype` + # (not `mime_type`); the wrong spelling crashed every PDF send. + msg = client.build_document_message( + data, filename=path.name, caption=op["caption"] or "", mimetype=mime + ) + client.send_message(jid, message=msg) + + +def _send_ops_with_retry(candidates: list, ops: list[dict], runner, label: str, + retries: int | None = None, backoff: float | None = None) -> None: + """Run `ops` in order, retrying usync/device-list failures (issue #120). + + `candidates` are the JIDs to try, best first — typically the phone-number + JID, then the recipient's LID when the store knows one (the path that + delivers where the phone-number lookup stalls). After the candidates are + exhausted, the last one gets `retries` further attempts, each preceded by + `backoff` seconds. Completed ops are never re-run, so a failure between + the parts of a multi-part send cannot duplicate the parts already sent. + A non-usync failure propagates immediately. When every attempt fails, the + recipient-lookup health signal is recorded and a clear terminal error is + raised — this is what the /sends page shows (issue #116). + """ + retries = WHATSAPP_SEND_USYNC_RETRIES if retries is None else retries + backoff = WHATSAPP_SEND_USYNC_BACKOFF if backoff is None else backoff + plan = [(cand, 0.0) for cand in candidates] + plan += [(candidates[-1], backoff)] * max(0, retries) + idx = 0 + last_exc: Exception | None = None + for attempt_no, (jid, delay) in enumerate(plan): + if delay: + time.sleep(delay) + try: + while idx < len(ops): + runner(jid, ops[idx]) + idx += 1 + _note_recipient_lookup(True) + return + except Exception as exc: # noqa: BLE001 - classified below + if not _is_usync_error(exc): + raise + last_exc = exc + print(f"[whatsapp-gateway] usync/device-list failure sending to {label} " + f"(attempt {attempt_no + 1}/{len(plan)}): {exc}", flush=True) + _note_recipient_lookup(False, str(last_exc)) + raise RuntimeError( + f"could not resolve the recipient's device list after {len(plan)} attempt(s): " + f"{last_exc}. First-contact (uncached) recipients are currently failing this " + f"lookup; a recipient who has messaged this account before stays reachable, " + f"and a later retry may succeed." + ) + + def _wa_send(recipient: str, text: str | None, media_paths: list[Path] | None = None) -> None: """Send a WhatsApp message: optional text plus any number of media files. - Serialized via WA_CLIENT_LOCK so it never races the receive callback. + Bridge calls are serialized via WA_CLIENT_LOCK (inside _run_send_op) so + they never race the receive callback. A usync/device-list failure — the + first-contact stall of issue #120 — is retried: first against the + recipient's LID when the store knows one, then after a backoff. """ client = _wa_client if client is None: raise RuntimeError("WhatsApp bridge is not connected yet") jid = _to_jid(recipient) - text = (text or "").strip() - media_paths = media_paths or [] - - with WA_CLIENT_LOCK: - for path in media_paths: - data = Path(path).read_bytes() - mime = mimetypes.guess_type(str(path))[0] or "application/octet-stream" - if mime.startswith("image/"): - # build_image_message derives the mime type from the bytes itself - # and takes no mime keyword — passing one raises TypeError. - msg = client.build_image_message(data, caption=text or "") - client.send_message(jid, message=msg) - text = "" # caption already carried the text with the first image - else: - # neonize's document builder spells the parameter `mimetype` - # (not `mime_type`); the wrong spelling crashed every PDF send. - msg = client.build_document_message( - data, filename=Path(path).name, caption=text or "", mimetype=mime - ) - client.send_message(jid, message=msg) - text = "" - if text: - client.send_message(jid, text) + ops = _build_send_ops(text, media_paths) + if not ops: + return + candidates = [jid] + server = str(_attr(jid, "Server", "server", default="")) or str(jid).rpartition("@")[2] + if server == WA_PN_SERVER: + lid_user = _pn_to_lid(_jid_user(jid)) + if lid_user: + from neonize.utils import build_jid # noqa: PLC0415 - localized bridge dep + candidates.append(build_jid(lid_user, WA_LID_SERVER)) + _send_ops_with_retry(candidates, ops, _run_send_op, recipient) def _start_bridge() -> None: diff --git a/tests/test_whatsapp_health.py b/tests/test_whatsapp_health.py index c764696..62faf71 100644 --- a/tests/test_whatsapp_health.py +++ b/tests/test_whatsapp_health.py @@ -172,6 +172,112 @@ def test_iq_probe_real_failure_still_raises(): raise AssertionError("expected the wedge to raise") +# ── Outbound usync retry / LID fallback (issue #120) ────────────────────────── + +class _Runner: + """Fake op runner: fails usync-style for the JIDs in `bad`, else records.""" + + def __init__(self, bad=()): + self.bad = set(bad) + self.sent = [] # (jid, op-kind) in execution order + + def __call__(self, jid, op): + if jid in self.bad: + raise RuntimeError("failed to get device list: failed to send usync query: " + "info query timed out") + self.sent.append((jid, op["kind"])) + + +def test_usync_error_classification(): + with tempfile.TemporaryDirectory() as tmp: + wg = _load_whatsapp_gateway(tmp) + assert wg._is_usync_error(RuntimeError( + "failed to get device list: failed to send usync query: info query timed out")) + assert wg._is_usync_error(RuntimeError("usync query rejected")) + assert not wg._is_usync_error(ValueError("recipient not on WhatsApp")) + + +def test_send_falls_back_to_lid(): + with tempfile.TemporaryDirectory() as tmp: + wg = _load_whatsapp_gateway(tmp) + runner = _Runner(bad={"pn-jid"}) + ops = wg._build_send_ops("hello", [Path(tmp) / "doc.pdf"]) + # The first media op carries the text as caption — no separate text op. + assert [op["kind"] for op in ops] == ["media"] + assert ops[0]["caption"] == "hello" + # The phone-number JID stalls in usync; the cached-LID candidate — the + # path that delivered in the issue-#120 repro — takes over. + wg._send_ops_with_retry(["pn-jid", "lid-jid"], ops, runner, "+15551112222", + retries=1, backoff=0) + assert runner.sent == [("lid-jid", "media")] + assert wg._health_snapshot()["recipient_lookup_ok"] is True + + +def test_send_partial_failure_never_resends(): + with tempfile.TemporaryDirectory() as tmp: + wg = _load_whatsapp_gateway(tmp) + + sent = [] + fails = {"n": 0} + + def runner(jid, op): + # The first part succeeds on the first candidate; the second part + # (caption already consumed → "") stalls in usync once. + if op["caption"] == "" and fails["n"] < 1: + fails["n"] += 1 + raise RuntimeError("failed to send usync query: info query timed out") + sent.append((jid, op["caption"])) + + ops = wg._build_send_ops("hello", [Path(tmp) / "a.pdf", Path(tmp) / "b.pdf"]) + assert [op["kind"] for op in ops] == ["media", "media"] + wg._send_ops_with_retry(["a", "b"], ops, runner, "x", retries=1, backoff=0) + # The first part went out exactly once (on the first candidate); only + # the failed second part was re-attempted, on the fallback candidate. + assert sent == [("a", "hello"), ("b", "")] + + +def test_send_exhausted_records_lookup_failure(): + with tempfile.TemporaryDirectory() as tmp: + wg = _load_whatsapp_gateway(tmp) + runner = _Runner(bad={"pn-jid"}) + ops = wg._build_send_ops("hello", []) + try: + wg._send_ops_with_retry(["pn-jid"], ops, runner, "+15551112222", + retries=1, backoff=0) + except RuntimeError as exc: + assert "device list" in str(exc) and "First-contact" in str(exc) + else: + raise AssertionError("expected the exhausted send to raise") + snap = wg._health_snapshot() + # Informational signal only: the link stays connected (the own-JID + # probe is green), but the degradation is visible. + assert snap["recipient_lookup_ok"] is False + assert "usync" in snap["recipient_lookup_error"] + # The evidence decays instead of being cleared by a probe. + with wg._CONN_LOCK: + wg._conn["recipient_lookup_at"] -= wg.WHATSAPP_RECIPIENT_LOOKUP_TTL + 1 + assert wg._health_snapshot()["recipient_lookup_ok"] is None + + +def test_send_non_usync_error_propagates(): + with tempfile.TemporaryDirectory() as tmp: + wg = _load_whatsapp_gateway(tmp) + calls = [] + + def runner(jid, op): + calls.append(jid) + raise ValueError("recipient not on WhatsApp") + + ops = wg._build_send_ops("hello", []) + try: + wg._send_ops_with_retry(["a", "b"], ops, runner, "x", retries=3, backoff=0) + except ValueError: + pass + else: + raise AssertionError("expected the non-usync error to propagate") + assert calls == ["a"] # no retry, no fallback + + def test_reconnect_backoff(): with tempfile.TemporaryDirectory() as tmp: wg = _load_whatsapp_gateway(tmp) @@ -191,6 +297,11 @@ def main() -> int: test_iq_probe_discovers_call_shape, test_iq_probe_shape_error_is_unsupported_not_down, test_iq_probe_real_failure_still_raises, + test_usync_error_classification, + test_send_falls_back_to_lid, + test_send_partial_failure_never_resends, + test_send_exhausted_records_lookup_failure, + test_send_non_usync_error_propagates, test_reconnect_backoff] failures = 0 for test in tests: From 851d23010382f1dfe7c12aaeafa838ced3476f41 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 08:46:16 +0000 Subject: [PATCH 2/3] fix(whatsapp): record LID-rescued sends as a recipient-lookup failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A send rescued by the LID fallback recorded recipient_lookup_ok: true — masking exactly the degraded state issue #120 describes (every raw- number lookup timing out while cached-LID delivery works), so /gateways never warned while true first-contact recipients stayed unreachable. The rescue itself witnessed the raw-number lookup failing, so it now records the failure ("delivered via fallback/retry; raw-number usync lookup failed: …"); only a clean first-candidate success records healthy. Also name the per-op locking trade-off (concurrent multi-part sends may interleave) as a deliberate choice in _run_send_op. Found by Aros in the PR #121 review. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YbqfmpARas38AfiwtCjZbn --- scripts/whatsapp-gateway.py | 20 ++++++++++++++++++-- tests/test_whatsapp_health.py | 9 +++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/scripts/whatsapp-gateway.py b/scripts/whatsapp-gateway.py index 8420fa5..3ac4466 100644 --- a/scripts/whatsapp-gateway.py +++ b/scripts/whatsapp-gateway.py @@ -900,7 +900,14 @@ def _build_send_ops(text: str | None, media_paths: list[Path] | None) -> list[di def _run_send_op(jid, op: dict) -> None: - """Execute one send operation against the bridge (serialized via the lock).""" + """Execute one send operation against the bridge (serialized via the lock). + + Deliberately per-op locking, not one lock around the whole logical send: + the retry engine sleeps between attempts, and holding WA_CLIENT_LOCK + through a backoff would block the receive callback and the IQ probe. The + accepted trade-off is that two concurrently-approved multi-part sends may + interleave their parts in a chat. + """ client = _wa_client with WA_CLIENT_LOCK: if op["kind"] == "text": @@ -949,7 +956,16 @@ def _send_ops_with_retry(candidates: list, ops: list[dict], runner, label: str, while idx < len(ops): runner(jid, ops[idx]) idx += 1 - _note_recipient_lookup(True) + if last_exc is None: + _note_recipient_lookup(True) + else: + # Delivered — but only via the LID fallback / a retry. That is + # direct evidence that raw-number (uncached) resolution is + # broken right now, so record it as a lookup failure: /health + # and /gateways then warn while true first-contact recipients + # remain unreachable, instead of the rescue masking the state. + _note_recipient_lookup(False, "delivered via fallback/retry; raw-number " + f"usync lookup failed: {last_exc}") return except Exception as exc: # noqa: BLE001 - classified below if not _is_usync_error(exc): diff --git a/tests/test_whatsapp_health.py b/tests/test_whatsapp_health.py index 62faf71..27e36d5 100644 --- a/tests/test_whatsapp_health.py +++ b/tests/test_whatsapp_health.py @@ -210,6 +210,15 @@ def test_send_falls_back_to_lid(): wg._send_ops_with_retry(["pn-jid", "lid-jid"], ops, runner, "+15551112222", retries=1, backoff=0) assert runner.sent == [("lid-jid", "media")] + # The rescue itself witnessed the raw-number lookup failing, so the + # health signal must record the degradation — not let the fallback + # mask it while true first-contact recipients stay unreachable. + snap = wg._health_snapshot() + assert snap["recipient_lookup_ok"] is False + assert "fallback" in snap["recipient_lookup_error"] + # A clean first-candidate success records healthy again. + wg._send_ops_with_retry(["ok-jid"], wg._build_send_ops("hi", []), runner, "x", + retries=0, backoff=0) assert wg._health_snapshot()["recipient_lookup_ok"] is True From 280a32f20c100a4f24e4e2b601a63cc59e42a5aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 08:46:16 +0000 Subject: [PATCH 3/3] feat(sends): spinner + green check on the send status page; auto-advance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-approval status page showed a plain "Sending…" text, reloaded itself with a full-page meta refresh, and always offered a hardcoded "Next pending send" link — ugly, flickering, and misleading when no next request existed. Now: - a CSS spinner (universally read as "processing") while the gateway delivers, updated by client-side polling of the new lean JSON endpoint GET /sends///status — no page reloads (a meta refresh remains as no-JS fallback only); - on success the spinner flips to a green check, and after ~1.5s the page auto-advances: to the next pending request when one exists (the status response carries its URL), else it tries window.close() and falls back to /sends; - a failure shows the gateway's real error and stays put; - the next-request button — and the approval page's Skip — render only when a next request actually exists. The status handler's next-request lookup now also works for entries no longer in the pending list (a sending/terminal entry falls back to the first still-pending request). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YbqfmpARas38AfiwtCjZbn --- scripts/web-gateway.py | 144 ++++++++++++++++++++++++---- tests/test_web_gateway_send_page.py | 121 +++++++++++++++++++++++ 2 files changed, 248 insertions(+), 17 deletions(-) create mode 100644 tests/test_web_gateway_send_page.py diff --git a/scripts/web-gateway.py b/scripts/web-gateway.py index 4f6333f..5e5593f 100644 --- a/scripts/web-gateway.py +++ b/scripts/web-gateway.py @@ -1621,6 +1621,7 @@ def _start_conv_turn(cid: str) -> None: _SEND_SINGLE_RE = re.compile(r"^/sends/([^/]+)/([^/]+?)/?$") _SEND_ACTION_RE = re.compile(r"^/sends/([^/]+)/([^/]+)/(approve|reject)/?$") +_SEND_STATUS_RE = re.compile(r"^/sends/([^/]+)/([^/]+)/status/?$") _GATEWAY_HEALTH_RE = re.compile(r"^/gateways/([A-Za-z0-9._-]+)/health/?$") _GATEWAY_QR_RE = re.compile(r"^/gateways/([A-Za-z0-9._-]+)/qr/?$") @@ -1793,7 +1794,10 @@ def _render_channel_send_html(detail: dict, channel: str, request_id: str, next_ recipient = html.escape(detail.get("recipient") or detail.get("to") or "") cat = html.escape(detail.get("category") or "") msg = html.escape(detail.get("message") or "") - skip = html.escape(next_url) if next_url else "/sends" + # "Skip" jumps to the next pending request — rendered only when one exists + # (the nav already links back to /sends and the dashboard). + skip_btn = (f' Skip\n' + if next_url else "") meta_rows = [ f"Channel{label_e}", f"To{recipient}", @@ -1801,37 +1805,97 @@ def _render_channel_send_html(detail: dict, channel: str, request_id: str, next_ ] status = detail.get("status") or "pending" if status != "pending": + # Status page: a "sending" entry shows a spinner and polls the JSON + # status endpoint client-side — no full-page refresh flicker. Success + # flips the spinner to a green check and auto-advances a moment later: + # to the next pending request when one exists, else the page tries to + # close itself (falling back to /sends — window.close() only works for + # script-opened windows). The next-request button is rendered only when + # a next request actually exists; a failure shows the gateway's real + # error and stays put so the user can read it. if status == "sending": - headline = "Sending…" - note = ("The gateway accepted the send and is delivering it in the " - "background. This page refreshes until it completes.") - extra_head = '\n' + icon = '
' + note = "Delivering in the background…" elif status == "approved": - headline = "Sent ✓" - note = "The message was delivered to the channel." - extra_head = "" + icon = '
' + note = "Sent." elif status == "rejected": - headline = "Rejected" + icon = '
' note = "The message was discarded without sending." - extra_head = "" else: # "error" - headline = "Send failed" + icon = '
' note = ("The gateway could not deliver the message: " + (detail.get("error") or "unknown error")) - extra_head = "" return ( _HTML_HEAD + f"Retinue — {label_e} Send {rid}\n" - + extra_head + # No-JS fallback only: with scripting available the page polls + # instead of reloading. + + ('\n' + if status == "sending" else "") + + "\n" + "\n" - + f"

{label_e} send: {html.escape(headline)}

\n" + + f"

{label_e} send

\n" + f'\n' + '\n' + "\n".join(meta_rows) + "\n
\n" + f'
{msg}
\n' - + f'

{html.escape(note)}

\n' + + f'
{icon}
' + + f'

{html.escape(note)}

\n' + '
\n' - + ' Next pending send\n' + + f' Next pending send\n' + "
\n" + + "\n" + "\n\n" ) return ( @@ -1847,7 +1911,7 @@ def _render_channel_send_html(detail: dict, channel: str, request_id: str, next_ f'\n' + f'
' f'
\n' - + f' Skip\n' + + skip_btn + "\n" + "