Add Ctrl-Y submit-captcha flow for rate-limit challenges

ober

0ce1d1f0e67bedd22b898b05ba360813bc009b85

diff --git a/signal/tui/ffi.ss b/signal/tui/ffi.ss
index 797b612..890c248 100644
--- a/signal/tui/ffi.ss
+++ b/signal/tui/ffi.ss
@@ -21,7 +21,7 @@
     TB_KEY_MOUSE_WHEEL_UP TB_KEY_MOUSE_WHEEL_DOWN
     TB_KEY_CTRL_A TB_KEY_CTRL_B TB_KEY_CTRL_C TB_KEY_CTRL_D
     TB_KEY_CTRL_E TB_KEY_CTRL_F TB_KEY_CTRL_J TB_KEY_CTRL_K TB_KEY_CTRL_L
-    TB_KEY_CTRL_N TB_KEY_CTRL_Q TB_KEY_CTRL_R TB_KEY_CTRL_U
+    TB_KEY_CTRL_N TB_KEY_CTRL_Q TB_KEY_CTRL_R TB_KEY_CTRL_U TB_KEY_CTRL_Y
     TB_MOD_ALT TB_MOD_CTRL TB_MOD_SHIFT
     TB_INPUT_ESC TB_INPUT_MOUSE
     TB_OUTPUT_TRUECOLOR
@@ -157,6 +157,7 @@
   (def TB_KEY_CTRL_Q #x11)
   (def TB_KEY_CTRL_R #x12)
   (def TB_KEY_CTRL_U #x15)
+  (def TB_KEY_CTRL_Y #x19)
 
   (def TB_MOD_ALT 1)
   (def TB_MOD_CTRL 2)
diff --git a/signal/tui/main.ss b/signal/tui/main.ss
index 35a8fdf..efca62b 100644
--- a/signal/tui/main.ss
+++ b/signal/tui/main.ss
@@ -28,10 +28,12 @@
   ;; resend holds the text of the last failed send (or #f). The composer is
   ;; cleared the moment a message is handed to signal-cli, so a "failed" send
   ;; 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.
   (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))
+     resend challenge))
 
   (def (run-tui-terminal account actor version receive-mode)
     ;; Prompt for the log passphrase and open the DB BEFORE termbox grabs the
@@ -64,6 +66,7 @@
                                          0
                                          removed
                                          logdb
+                                         #f
                                          #f)])
               (append-system-message!
                 state
@@ -596,6 +599,8 @@
           (handle-search-event! state ev)]
          [(eq? (tui-state-mode state) 'attach)
           (handle-attach-event! state actor ev)]
+         [(eq? (tui-state-mode state) 'captcha)
+          (handle-captcha-event! state actor ev)]
          [else (handle-chat-event! state actor ev)])]
       [else (void)]))
 
@@ -618,6 +623,8 @@
          (open-attach! state)]
         [(= key TB_KEY_CTRL_R)
          (restore-failed-send! state)]
+        [(= key TB_KEY_CTRL_Y)
+         (open-captcha! state)]
         [(= key TB_KEY_CTRL_D)
          (open-delete-confirm! state)]
         [(or (= key TB_KEY_BACKSPACE) (= key TB_KEY_BACKSPACE2))
@@ -907,7 +914,15 @@
         (lambda (hint) (append-system-message! state (string-append "  " hint)))
         hints)
       (when err
-        (append-json-detail! state 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)
+            (append-system-message!
+              state
+              "Challenge token recorded -- solve the captcha at signalcaptchas.org/challenge/generate.html, then Ctrl-Y to submit it."))))
       (tui-state-status-set!
         state
         (string-append base
@@ -916,6 +931,23 @@
                          "")
                        " -- " retry-hint))))
 
+  ;; First string value stored under `key` anywhere in the payload, or #f.
+  (def (rpc-error-field x key)
+    (cond
+      [(hashtable? x)
+       (let ([direct (hashtable-ref x key #f)])
+         (if (string? direct)
+           direct
+           (let-values ([(_ vals) (hashtable-entries x)])
+             (let loop ([i 0])
+               (and (< i (vector-length vals))
+                    (or (rpc-error-field (vector-ref vals i) key)
+                        (loop (+ i 1))))))))]
+      [(pair? x)
+       (or (rpc-error-field (car x) key)
+           (rpc-error-field (cdr x) key))]
+      [else #f]))
+
   ;; 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.
@@ -979,6 +1011,94 @@
           (let ([end (min len (+ i n))])
             (loop end (cons (substring s i end) acc)))))))
 
+  ;; --- Submit a rate-limit captcha (Ctrl-Y) ---
+  ;;
+  ;; When Signal rate-limits the account with a proof challenge, the failed
+  ;; send records the challenge token (see report-send-failure!). The user
+  ;; solves the captcha at signalcaptchas.org/challenge/generate.html, pastes
+  ;; the resulting signalcaptcha:// token here, and we submit both via
+  ;; signal-cli's submitRateLimitChallenge.
+
+  (def (open-captcha! state)
+    (if (tui-state-challenge state)
+      (begin
+        (tui-state-mode-set! state 'captcha)
+        (tui-state-picker-query-set! state "")
+        (tui-state-status-set! state "Paste the solved captcha token, Enter to submit."))
+      (tui-state-status-set!
+        state
+        "No challenge token recorded. A rate-limited send stores one first.")))
+
+  (def (close-captcha! state)
+    (tui-state-mode-set! state 'chat)
+    (tui-state-picker-query-set! state "")
+    (tui-state-status-set! state "Captcha cancelled."))
+
+  (def (handle-captcha-event! state actor ev)
+    (let ([key (tui-event-key ev)]
+          [ch (tui-event-ch ev)])
+      (cond
+        [(= key TB_KEY_ESC)
+         (close-captcha! state)]
+        [(or (= key TB_KEY_CTRL_C) (= key TB_KEY_CTRL_Q))
+         (tui-state-quit?-set! state #t)]
+        [(or (= key TB_KEY_BACKSPACE) (= key TB_KEY_BACKSPACE2))
+         (tui-state-picker-query-set!
+           state (delete-last-char (tui-state-picker-query state)))]
+        [(= key TB_KEY_CTRL_K)
+         (tui-state-picker-query-set! state "")]
+        [(= key TB_KEY_ENTER)
+         (submit-captcha! state actor)]
+        [(and (> ch 0) (>= ch 32))
+         (tui-state-picker-query-set!
+           state
+           (string-append (tui-state-picker-query state)
+                          (string (integer->char ch))))]
+        [else (void)])))
+
+  (def (submit-captcha! state actor)
+    (let ([challenge (tui-state-challenge state)]
+          [captcha (strip-captcha-scheme
+                     (trim-spaces (tui-state-picker-query state)))])
+      (cond
+        [(not challenge)
+         (close-captcha! state)
+         (tui-state-status-set! state "No challenge token recorded.")]
+        [(string=? captcha "")
+         (tui-state-status-set! state "Paste the captcha token first.")]
+        [else
+         (tui-state-mode-set! state 'chat)
+         (tui-state-picker-query-set! state "")
+         (tui-state-status-set! state "Submitting challenge...")
+         (let ([outcome
+                (guard (e [#t (cons 'failed e)])
+                  (cons 'ok (actor-call actor "submitRateLimitChallenge"
+                                        (make-challenge-params challenge captcha))))])
+           (if (eq? (car outcome) 'ok)
+             (begin
+               (tui-state-challenge-set! state #f)
+               (append-system-message! state "Rate-limit challenge accepted.")
+               (tui-state-status-set!
+                 state "Challenge accepted -- try sending again."))
+             (let ([msg (string-append "Challenge rejected: "
+                                       (safe-display (cdr outcome)))])
+               (append-system-message! state msg)
+               (let ([err (rpc-error-data (cdr outcome))])
+                 (when err (append-json-detail! state err)))
+               (tui-state-status-set!
+                 state (string-append msg " -- Ctrl-Y to retry.")))))])))
+
+  (def (make-challenge-params challenge captcha)
+    (let ([p (make-hashtable equal-hash equal?)])
+      (hashtable-set! p "challenge" challenge)
+      (hashtable-set! p "captcha" captcha)
+      p))
+
+  ;; The capture page hands back "signalcaptcha://<token>"; signal-cli wants
+  ;; the token, so drop the scheme when present.
+  (def (strip-captcha-scheme s)
+    (or (id-prefix s "signalcaptcha://") s))
+
   ;; --- Attach a file (Ctrl-U) ---
 
   (def (open-attach! state)
@@ -1360,6 +1480,8 @@
          (draw-search-page! state 0 0 w status-y)]
         [(eq? (tui-state-mode state) 'attach)
          (draw-attach-page! state 0 0 w status-y)]
+        [(eq? (tui-state-mode state) 'captcha)
+         (draw-captcha-page! state 0 0 w status-y)]
         [else
          (draw-panel! 0 0 left-w body-h "Conversations")
          (draw-panel! thread-x 0 thread-w body-h "Messages")
@@ -1479,6 +1601,25 @@
     (draw-text! (+ x 2) (+ y 7) (- width 4) (fg-accent) (panel-bg)
                 "Enter  send       Esc  cancel"))
 
+  (def (draw-captcha-page! state x y width height)
+    (draw-panel! x y width height "Submit rate-limit captcha")
+    (draw-text! (+ x 2) (+ y 2) 9 (fg-strong) (panel-bg) "Captcha:")
+    (fill-rect! (+ x 11) (+ y 2) (max 1 (- width 13)) 1 (fg) (input-bg))
+    (draw-text! (+ x 12) (+ y 2) (max 1 (- width 15)) (fg-strong) (input-bg)
+                (tui-state-picker-query state))
+    (draw-text! (+ x 2) (+ y 4) (- width 4) (fg-dim) (panel-bg)
+                "Solve the captcha at signalcaptchas.org/challenge/generate.html,")
+    (draw-text! (+ x 2) (+ y 5) (- width 4) (fg-dim) (panel-bg)
+                "then paste the signalcaptcha:// token above (scheme optional).")
+    (let ([challenge (tui-state-challenge state)])
+      (draw-text! (+ x 2) (+ y 7) (- width 4) (fg-dim) (panel-bg)
+                  (string-append "Challenge: "
+                                 (if challenge
+                                   (clip-field challenge (max 8 (- width 17)))
+                                   "none recorded"))))
+    (draw-text! (+ x 2) (+ y 9) (- width 4) (fg-accent) (panel-bg)
+                "Enter  submit       Esc  cancel"))
+
   (def (draw-search-page! state x y width height)
     (draw-panel! x y width height "Search message history")
     (draw-text! (+ x 2) (+ y 2) 6 (fg-strong) (panel-bg) "Find:")
@@ -1604,6 +1745,10 @@
        (let* ([query-len (string-length (tui-state-picker-query state))]
               [cursor-x (min (- width 2) (+ 9 query-len))])
          (tb-set-cursor! cursor-x 2))]
+      [(eq? (tui-state-mode state) 'captcha)
+       (let* ([query-len (string-length (tui-state-picker-query state))]
+              [cursor-x (min (- width 2) (+ 12 query-len))])
+         (tb-set-cursor! cursor-x 2))]
       [(eq? (tui-state-mode state) 'confirm-delete)
        (tb-hide-cursor!)]
       [else