Initial scaffold: signal-cli JSON-RPC bridge in Jerboa
ober
1a1a03a273c4e544285fcdcc5f05e5cb3c3b2fb0
new file mode 100644 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +*.so +*.wpo +*.boot +*.o +signal-bin +signal-macos +signal-musl new file mode 100644 --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +JERBOA_HOME ?= $(realpath $(CURDIR)/../jerboa) +SCHEME ?= $(JERBOA_HOME)/.chez/bin/scheme +BIN_DIR := $(HOME)/.local/bin + +.PHONY: run test install clean help + +.DEFAULT_GOAL := help + +run: + JERBOA_HOME=$(JERBOA_HOME) \ + $(SCHEME) -q --libdirs $(CURDIR):$(JERBOA_HOME)/lib --script signal/main.ss -- $(ARGS) + +test: + JERBOA_HOME=$(JERBOA_HOME) \ + $(SCHEME) -q --libdirs $(CURDIR):$(JERBOA_HOME)/lib --script test/test-signal.ss + +compile-check: + JERBOA_HOME=$(JERBOA_HOME) \ + $(SCHEME) -q --libdirs $(CURDIR):$(JERBOA_HOME)/lib --script - <<< '(import (signal rpc))' + +install: + mkdir -p $(BIN_DIR) + printf '#!/bin/sh\nexec $(SCHEME) -q --libdirs $(CURDIR):$(JERBOA_HOME)/lib --script $(CURDIR)/signal/main.ss -- "$$@"\n' > $(BIN_DIR)/jerboa-signal + chmod +x $(BIN_DIR)/jerboa-signal + @echo "Installed jerboa-signal to $(BIN_DIR)/jerboa-signal" + +clean: + find signal -name '*.so' -delete + find signal -name '*.wpo' -delete + +help: + @echo "jerboa-signal -- Signal client over signal-cli" + @echo "" + @echo "Targets:" + @echo " run ARGS='<args>' Run jerboa-signal with arguments" + @echo " test Run tests" + @echo " install Install jerboa-signal launcher to ~/.local/bin" + @echo " clean Remove build artifacts" + @echo "" + @echo "Prerequisite: signal-cli must be linked to your Signal account." + @echo " signal-cli link -n 'jerboa-signal'" + @echo " (then scan/paste the printed sgnl://linkdevice URI on your phone)" new file mode 100644 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# jerboa-signal + +A Signal client written in [Jerboa](https://git.sr.ht/~lisp/jerboa), driving +[signal-cli](https://github.com/AsamK/signal-cli) (Java) as a JSON-RPC +subprocess. All orchestration, arg parsing, and message routing is Jerboa; +the Signal protocol itself is delegated to signal-cli's `libsignal-service-java`. + +## What it does + +Bidirectional bridge between Signal and your shell: + +``` +jerboa-signal send [-a +PHONE] RECIPIENT MESSAGE # send one message, exit +jerboa-signal listen [-a +PHONE] # stream inbound as NDJSON +``` + +`-a` is required only if multiple Signal accounts are linked to signal-cli. + +## Prerequisites + +- `signal-cli` on `PATH` (Homebrew: `brew install signal-cli`) +- A Jerboa checkout next to this repo at `../jerboa`, or `JERBOA_HOME` set + +## One-time setup: link as a secondary device + +jerboa-signal expects signal-cli to already be linked. Run this once: + +```sh +signal-cli link -n "jerboa-signal" +``` + +It prints a `sgnl://linkdevice?uuid=...` URI. On your phone: +**Signal → Settings → Linked devices → "+" → scan/paste the URI**. signal-cli +will register the device and exit. Verify: + +```sh +signal-cli listAccounts +``` + +## Usage + +```sh +make install # installs ~/.local/bin/jerboa-signal +jerboa-signal send +15551234567 "hello world" +jerboa-signal listen | jq . # pretty-print incoming envelopes +``` + +Without `make install`, run via the Makefile: + +```sh +make run ARGS="send +15551234567 hello" +make run ARGS="listen" +``` + +## Architecture + +``` +┌─────────────────┐ argv ┌──────────────────┐ NDJSON ┌────────────┐ +│ jerboa-signal │ ─────────► │ signal/main.ss │ ───────► │ signal-cli │ +│ (CLI argv) │ │ (subcommands) │ │ jsonRpc │ +└─────────────────┘ └──────────────────┘ ◄─────── └────────────┘ + │ NDJSON │ + ▼ ▼ + stdout / stderr Signal servers +``` + +Modules: +- `signal/main.ss` — entry point, argv parsing, subcommand dispatch +- `signal/rpc.ss` — JSON-RPC client over signal-cli's stdin/stdout (NDJSON framing, id correlation, notification queue) +- `signal/cmd-send.ss` — `send` subcommand +- `signal/cmd-listen.ss` — `listen` subcommand + +## License + +ISC. new file mode 100644 --- /dev/null +++ b/signal/cmd-listen.ss @@ -0,0 +1,49 @@ +#!chezscheme +;;; signal/cmd-listen -- `listen` subcommand. +;;; +;;; Usage: jerboa-signal listen [-a +PHONE] +;;; +;;; Streams incoming Signal envelopes as NDJSON on stdout. Each line is +;;; the `params` object from a signal-cli "receive" notification. + +(library (signal cmd-listen) + (export cmd-listen) + + (import (except (chezscheme) + make-hash-table hash-table? + sort sort! + printf fprintf + path-extension path-absolute? + with-input-from-string with-output-to-string + iota 1+ 1- + partition + make-date make-time) + (except (jerboa prelude) meta atom?) + (std text json) + (signal rpc)) + + (def (cmd-listen account) + (let ([sc (spawn-signal-cli account)]) + (dynamic-wind + (lambda () (void)) + (lambda () + (let loop () + (let ([n (next-notification sc)]) + (cond + [(eof-object? n) + (display "signal-cli stream ended\n" (current-error-port))] + [else + (emit-envelope n) + (loop)])))) + (lambda () (close-signal-cli sc))))) + + (def (emit-envelope notif) + ;; signal-cli emits notifications with method "receive"; payload is in + ;; `params`. We unwrap to keep stdout consumers from caring about the + ;; JSON-RPC envelope. + (let ([params (and (hashtable? notif) (hashtable-ref notif "params" #f))]) + (display (json-object->string (or params notif))) + (newline) + (flush-output-port (current-output-port)))) + + ) ;; end library new file mode 100644 --- /dev/null +++ b/signal/cmd-send.ss @@ -0,0 +1,51 @@ +#!chezscheme +;;; signal/cmd-send -- `send` subcommand. +;;; +;;; Usage: jerboa-signal send [-a +PHONE] RECIPIENT MESSAGE +;;; +;;; RECIPIENT is either a phone number (+15551234567) or a Signal group id. +;;; This wraps signal-cli's `send` JSON-RPC method. + +(library (signal cmd-send) + (export cmd-send) + + (import (except (chezscheme) + make-hash-table hash-table? + sort sort! + printf fprintf + path-extension path-absolute? + with-input-from-string with-output-to-string + iota 1+ 1- + partition + make-date make-time) + (except (jerboa prelude) meta atom?) + (std text json) + (signal rpc)) + + (def (cmd-send account recipient message) + (let ([sc (spawn-signal-cli account)]) + (dynamic-wind + (lambda () (void)) + (lambda () + (let* ([params (make-send-params recipient message)] + [result (rpc-call sc "send" params)]) + (display (json-object->string result)) + (newline))) + (lambda () (close-signal-cli sc))))) + + (def (make-send-params recipient message) + (let ([p (make-hashtable equal-hash equal?)]) + ;; signal-cli accepts `recipient` as a list of phone numbers / UUIDs, + ;; or `groupId` for groups. We auto-detect by leading char. + (cond + [(and (string? recipient) + (> (string-length recipient) 0) + (char=? (string-ref recipient 0) #\+)) + (hashtable-set! p "recipient" (list recipient))] + [else + ;; Treat as group id if it doesn't look like a phone number. + (hashtable-set! p "groupId" recipient)]) + (hashtable-set! p "message" message) + p)) + + ) ;; end library new file mode 100644 --- /dev/null +++ b/signal/main.ss @@ -0,0 +1,103 @@ +#!chezscheme +;;; jerboa-signal -- entry point. +;;; +;;; Drives signal-cli (Java) as a JSON-RPC subprocess. Bidirectional bridge: +;;; jerboa-signal send [-a +PHONE] RECIPIENT MESSAGE -- send one message +;;; jerboa-signal listen [-a +PHONE] -- stream inbound as NDJSON +;;; +;;; Prerequisite: signal-cli must already be linked to your account +;;; signal-cli link -n "jerboa-signal" +;;; then scan/paste the printed sgnl://linkdevice URI on your phone. + +(import (except (chezscheme) + make-hash-table hash-table? + sort sort! + printf fprintf + path-extension path-absolute? + with-input-from-string with-output-to-string + iota 1+ 1- + partition + make-date make-time)) + +(define home (or (getenv "HOME") ".")) +(define jerboa-dir (or (getenv "JERBOA_HOME") + (string-append home "/mine/jerboa"))) +(define project-dir (or (getenv "JERBOA_SIGNAL_DIR") + (current-directory))) + +(library-directories + (append + (list (cons project-dir project-dir) + (cons (string-append jerboa-dir "/lib") + (string-append jerboa-dir "/lib"))) + (library-directories))) + +(import (except (jerboa prelude) meta atom?) + (signal cmd-send) + (signal cmd-listen)) + +(def *version* "0.1.0") + +(def (usage) + (display +"Usage: jerboa-signal SUBCOMMAND [OPTIONS] + +Subcommands: + send [-a +PHONE] RECIPIENT MESSAGE Send a Signal message + listen [-a +PHONE] Stream inbound envelopes as NDJSON + version Print version + help Print this help + +RECIPIENT is either a phone number (+15551234567) or a Signal group id. + +-a is required only if multiple signal-cli accounts are linked. Set up +linking once before using this tool: + + signal-cli link -n 'jerboa-signal' + # then scan/paste the printed sgnl://linkdevice URI on your phone. +")) + +(def (parse-account-flag args) + ;; Returns (values account remaining-args). + ;; Recognizes `-a VALUE` or `--account VALUE` anywhere in the first two slots. + (cond + [(and (pair? args) + (pair? (cdr args)) + (or (string=? (car args) "-a") (string=? (car args) "--account"))) + (values (cadr args) (cddr args))] + [else (values #f args)])) + +(def (main argv) + (cond + [(null? argv) (usage) (exit 2)] + [else + (let ([sub (car argv)] + [rest (cdr argv)]) + (cond + [(or (string=? sub "help") (string=? sub "--help") (string=? sub "-h")) + (usage) (exit 0)] + [(or (string=? sub "version") (string=? sub "--version")) + (display "jerboa-signal ") (display *version*) (newline) (exit 0)] + [(string=? sub "send") + (let-values ([(account args) (parse-account-flag rest)]) + (cond + [(< (length args) 2) + (display "jerboa-signal: send requires RECIPIENT and MESSAGE\n" + (current-error-port)) + (exit 2)] + [else + (cmd-send account (car args) (cadr args))]))] + [(string=? sub "listen") + (let-values ([(account args) (parse-account-flag rest)]) + (cmd-listen account))] + [else + (display "jerboa-signal: unknown subcommand: " (current-error-port)) + (display sub (current-error-port)) + (newline (current-error-port)) + (usage) + (exit 2)]))])) + +(main (let ([args (cdr (command-line))]) + (if (and (pair? args) (string=? (car args) "--")) + (cdr args) + args))) new file mode 100644 --- /dev/null +++ b/signal/rpc.ss @@ -0,0 +1,154 @@ +#!chezscheme +;;; signal/rpc -- JSON-RPC client over signal-cli's `jsonRpc` subcommand. +;;; +;;; signal-cli reads NDJSON (one JSON object per line) on stdin and writes +;;; NDJSON on stdout. Each outbound line is either a response to one of our +;;; requests (correlated by `id`) or a notification (no `id`) carrying a +;;; received Signal envelope. + +(library (signal rpc) + (export + spawn-signal-cli + close-signal-cli + signal-cli? + signal-cli-pid + + rpc-call + + next-notification + drain-notifications) + + (import (except (chezscheme) + make-hash-table hash-table? + sort sort! + printf fprintf + path-extension path-absolute? + with-input-from-string with-output-to-string + iota 1+ 1- + partition + make-date make-time) + (except (jerboa prelude) meta atom?) + (std misc process) + (std text json)) + + ;; --- Process handle --- + ;; proc : process-port-rec from (std misc process) + ;; next-id-box : box holding the next JSON-RPC request id + ;; pending : reserved for future async correlation + ;; notif-box : box holding FIFO queue of pending notifications + (defstruct signal-cli (proc next-id-box pending notif-box)) + + (def (spawn-signal-cli account) + ;; Spawn `signal-cli [-a ACCOUNT] jsonRpc`. Pass #f for account if only + ;; one is linked. + (let* ([cmd (if account + (list "signal-cli" "-a" account "jsonRpc") + (list "signal-cli" "jsonRpc"))] + [proc (open-process cmd)]) + (make-signal-cli proc (box 1) (make-hashtable equal-hash equal?) (box '())))) + + (def (signal-cli-pid sc) + (process-port-pid (signal-cli-proc sc))) + + (def (close-signal-cli sc) + (let ([proc (signal-cli-proc sc)]) + (close-port (process-port-rec-stdin-port proc)) + (process-kill (process-port-pid proc) 15) + (void))) + + ;; --- Wire IO --- + + (def (write-frame sc obj) + (let ([port (process-port-rec-stdin-port (signal-cli-proc sc))]) + (display (json-object->string obj) port) + (newline port) + (flush-output-port port))) + + (def (read-frame sc) + (let* ([port (process-port-rec-stdout-port (signal-cli-proc sc))] + [line (get-line port)]) + (if (eof-object? line) + line + (string->json-object line)))) + + ;; --- Request/response correlation --- + + (def (next-id! sc) + (let* ([b (signal-cli-next-id-box sc)] + [n (unbox b)]) + (set-box! b (+ n 1)) + n)) + + (def (make-rpc-request id method params) + (let ([h (make-hashtable equal-hash equal?)]) + (hashtable-set! h "jsonrpc" "2.0") + (hashtable-set! h "id" id) + (hashtable-set! h "method" method) + (when params (hashtable-set! h "params" params)) + h)) + + (def (rpc-response-for? msg id) + (and (hashtable? msg) + (let ([msg-id (hashtable-ref msg "id" #f)]) + (and msg-id (equal? msg-id id))))) + + (def (rpc-notification? msg) + (and (hashtable? msg) + (not (hashtable-ref msg "id" #f)) + (hashtable-ref msg "method" #f))) + + (def (rpc-response-result-or-raise msg) + (let ([err (hashtable-ref msg "error" #f)]) + (if err + (error 'rpc-call + (if (hashtable? err) + (or (hashtable-ref err "message" #f) "rpc error") + "rpc error") + err) + (hashtable-ref msg "result" #f)))) + + (def (enqueue-notification! sc msg) + (let* ([b (signal-cli-notif-box sc)] + [cur (unbox b)]) + (set-box! b (append cur (list msg))))) + + (def (rpc-call sc method params) + (let* ([id (next-id! sc)] + [req (make-rpc-request id method params)]) + (write-frame sc req) + (let loop () + (let ([msg (read-frame sc)]) + (cond + [(eof-object? msg) + (error 'rpc-call "signal-cli closed stdout before responding")] + [(rpc-response-for? msg id) + (rpc-response-result-or-raise msg)] + [(rpc-notification? msg) + (enqueue-notification! sc msg) + (loop)] + [else (loop)]))))) + + (def (next-notification sc) + (let loop () + (let* ([b (signal-cli-notif-box sc)] + [q (unbox b)]) + (cond + [(pair? q) + (set-box! b (cdr q)) + (car q)] + [else + (let ([msg (read-frame sc)]) + (cond + [(eof-object? msg) msg] + [(rpc-notification? msg) + (enqueue-notification! sc msg) + (loop)] + [else (loop)]))])))) + + (def (drain-notifications sc) + (let* ([b (signal-cli-notif-box sc)] + [q (unbox b)]) + (set-box! b '()) + q)) + + ) ;; end library