Add Ctrl-Y submit-captcha flow for rate-limit challenges
ober
0ce1d1f0e67bedd22b898b05ba360813bc009b85
--- 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) --- 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