Improve send rate limit handling

ober

d5d4301788281b6ec942a90295f2c827a4840154

diff --git a/signal/tui/main.ss b/signal/tui/main.ss
index 14d0c5e..e51f276 100644
--- a/signal/tui/main.ss
+++ b/signal/tui/main.ss
@@ -31,10 +31,13 @@
   ;; can never be re-fired by mashing Enter; Ctrl-R restores it deliberately.
   ;; challenge holds the proof-required token from the last rate-limited send
   ;; (or #f); Ctrl-Y submits it together with a solved captcha.
+  ;; rate-limit-until is a real-time millisecond deadline from signal-cli's
+  ;; retryAfterSeconds hint. While active, sends are blocked locally so we do
+  ;; not turn one Signal refusal into many retries.
   (defstruct tui-state
     (account version receive-mode width height input status quit? event-count
      conversations selected-index mode picker-query picker-index removed logdb
-     resend challenge))
+     resend challenge rate-limit-until))
 
   (def (run-tui-terminal account actor version receive-mode)
     ;; Prompt for the log passphrase and open the DB BEFORE termbox grabs the
@@ -75,6 +78,7 @@
                                          removed
                                          logdb
                                          #f
+                                         #f
                                          #f)])
               (append-system-message!
                 state
@@ -844,6 +848,8 @@
         [(or (eq? (conversation-kind conv) 'system)
              (not (conversation-target conv)))
          (tui-state-status-set! state "Select a Signal conversation before sending.")]
+        [(rate-limit-active? state)
+         (tui-state-status-set! state (rate-limit-status state))]
         [else
          ;; Clear the composer BEFORE the call: once the request is written to
          ;; signal-cli the message may be delivered even if the RPC reply is an
@@ -864,6 +870,7 @@
                                   (tui-state-account state)
                                   (conversation-id conv) text ts)
                (tui-state-resend-set! state #f)
+               (tui-state-rate-limit-until-set! state #f)
                (conversation-unread-set! conv 0)
                (tui-state-status-set! state "Sent."))
              (begin
@@ -916,7 +923,7 @@
   ;; event overwrites it.
 
   (def *rpc-hint-keys*
-    '("retryAfterSeconds" "retryAfter" "token" "challenge" "options"
+    '("type" "retryAfterSeconds" "retryAfter" "token" "challenge" "options"
       "captchaRequired"))
 
   (def (report-send-failure! state conv e retry-hint)
@@ -925,28 +932,40 @@
                                 " failed (it may still have gone out): "
                                 (safe-display e))]
            [err (rpc-error-data e)]
-           [hints (if err (collect-rpc-hints err '()) '())])
+           [hints (if err (collect-rpc-hints err '()) '())]
+           [token (and err (rpc-error-field err "token"))]
+           [retry-after (and err (rpc-error-number-field err "retryAfterSeconds"))]
+           [rate-limit? (and err
+                             (let ([type (rpc-error-field err "type")])
+                               (and type (string=? type "RATE_LIMIT_FAILURE"))))])
       (append-system-message! state base)
       (for-each
         (lambda (hint) (append-system-message! state (string-append "  " hint)))
         hints)
       (when err
         (append-json-detail! state err)
-        ;; A proof-required refusal carries a challenge token; remember it so
-        ;; Ctrl-Y can submit a solved captcha without retyping it.
-        (let ([token (rpc-error-field err "token")])
-          (when token
-            (tui-state-challenge-set! state token)
+        (when retry-after
+          (let ([until (+ (real-time) (* retry-after 1000))])
+            (tui-state-rate-limit-until-set! state until)
             (append-system-message!
               state
-              "Challenge token recorded -- solve the captcha at signalcaptchas.org/challenge/generate.html, then Ctrl-Y to submit it."))))
+              (string-append "Rate-limit cooldown recorded: wait "
+                             (number->string retry-after)
+                             " seconds before retrying."))))
+        ;; A proof-required refusal carries a challenge token; remember it so
+        ;; Ctrl-Y can submit a solved captcha without retyping it.
+        (when token
+          (tui-state-challenge-set! state token)
+          (append-system-message!
+            state
+            "Challenge token recorded -- solve the captcha at signalcaptchas.org/challenge/generate.html, then Ctrl-Y to submit it."))
+        (when (and rate-limit? (not token))
+          (append-system-message!
+            state
+            "Signal returned a plain rate-limit failure with no challenge token; this one is wait-only.")))
       (tui-state-status-set!
         state
-        (string-append base
-                       (if (pair? hints)
-                         (string-append " [" (join-strings hints "; ") "]")
-                         "")
-                       " -- " retry-hint))))
+        (send-failure-status token retry-after rate-limit? retry-hint base hints))))
 
   ;; First string value stored under `key` anywhere in the payload, or #f.
   (def (rpc-error-field x key)
@@ -965,6 +984,61 @@
            (rpc-error-field (cdr x) key))]
       [else #f]))
 
+  ;; First numeric value stored under `key` anywhere in the payload, or #f.
+  (def (rpc-error-number-field x key)
+    (cond
+      [(hashtable? x)
+       (let ([direct (hashtable-ref x key #f)])
+         (if (number? direct)
+           direct
+           (let-values ([(_ vals) (hashtable-entries x)])
+             (let loop ([i 0])
+               (and (< i (vector-length vals))
+                    (or (rpc-error-number-field (vector-ref vals i) key)
+                        (loop (+ i 1))))))))]
+      [(pair? x)
+       (or (rpc-error-number-field (car x) key)
+           (rpc-error-number-field (cdr x) key))]
+      [else #f]))
+
+  (def (send-failure-status token retry-after rate-limit? retry-hint base hints)
+    (cond
+      [token
+       (string-append "Signal requires captcha proof. Ctrl-Y submits it; "
+                      retry-hint)]
+      [retry-after
+       (string-append "Signal rate-limited sending. Retry after "
+                      (number->string retry-after)
+                      " seconds; "
+                      retry-hint)]
+      [rate-limit?
+       (string-append "Signal rate-limited sending with no retry time; wait before retrying. "
+                      retry-hint)]
+      [else
+       (string-append base
+                      (if (pair? hints)
+                        (string-append " [" (join-strings hints "; ") "]")
+                        "")
+                      " -- " retry-hint)]))
+
+  (def (rate-limit-active? state)
+    (let ([until (tui-state-rate-limit-until state)])
+      (and (number? until)
+           (if (> until (real-time))
+             #t
+             (begin
+               (tui-state-rate-limit-until-set! state #f)
+               #f)))))
+
+  (def (rate-limit-status state)
+    (let* ([until (tui-state-rate-limit-until state)]
+           [remaining (if (number? until)
+                        (max 1 (quotient (+ (- until (real-time)) 999) 1000))
+                        0)])
+      (string-append "Signal asked us to wait "
+                     (number->string remaining)
+                     " seconds before retrying; message not sent.")))
+
   ;; actor-call raises (error 'actor-call <message> <error-object>) where the
   ;; error object is the parsed JSON-RPC "error" member; fish it back out of
   ;; the condition irritants. #f when the failure was not an RPC error.
@@ -1093,6 +1167,7 @@
            (if (eq? (car outcome) 'ok)
              (begin
                (tui-state-challenge-set! state #f)
+               (tui-state-rate-limit-until-set! state #f)
                (append-system-message! state "Rate-limit challenge accepted.")
                (tui-state-status-set!
                  state "Challenge accepted -- try sending again."))
@@ -1164,6 +1239,8 @@
         [(not (file-exists? path))
          (tui-state-status-set!
            state (string-append "No such file: " path))]
+        [(rate-limit-active? state)
+         (tui-state-status-set! state (rate-limit-status state))]
         [else
          ;; Leave attach mode and clear the path/caption BEFORE the call, for
          ;; the same reason as send-composer-message!: a "failed" reply may
@@ -1185,6 +1262,7 @@
                (capture-outbound! (tui-state-logdb state)
                                   (tui-state-account state)
                                   (conversation-id conv) label ts)
+               (tui-state-rate-limit-until-set! state #f)
                (conversation-unread-set! conv 0)
                (tui-state-status-set! state "File sent."))
              (report-send-failure! state conv (cdr outcome)