;;; noise-rpc.el --- JSON-RPC client for signal-cli -*- lexical-binding: t; -*- ;;; Commentary: ;; Communicates with a signal-cli daemon over HTTP JSON-RPC. ;;; Code: (require 'json) (require 'url) (require 'cl-lib) (defvar noise-signal-cli-address) (defvar noise-account) (define-error 'noise-rpc-error "signal-cli JSON-RPC error") (defvar noise-rpc--next-id 0 "Monotonically increasing request ID for JSON-RPC.") (defun noise-rpc--plist-to-alist (plist) "Convert PLIST to an alist for json-encode. Strips the leading colon from keyword symbols." (let (alist) (while plist (let ((key-name (symbol-name (car plist)))) (when (string-prefix-p ":" key-name) (setq key-name (substring key-name 1))) (push (cons key-name (cadr plist)) alist)) (setq plist (cddr plist))) (nreverse alist))) (defun noise-rpc--build-request (method &rest params) "Build a JSON-RPC 2.0 request string for METHOD with PARAMS. PARAMS is a plist of :key value pairs." (let* ((id (cl-incf noise-rpc--next-id)) (req `((jsonrpc . "2.0") (method . ,method) (id . ,id)))) (when params (setq req (append req `((params . ,(noise-rpc--plist-to-alist params)))))) (json-encode req))) (defun noise-rpc-call (method &rest params) "Call signal-cli JSON-RPC METHOD with PARAMS. PARAMS is a plist of :key value pairs. Returns the parsed `result` field on success. Signals `noise-rpc-error` on failure or connection error." (let* ((url-request-method "POST") (url-request-extra-headers '(("Content-Type" . "application/json"))) (url-request-data (apply #'noise-rpc--build-request method params)) (url (concat noise-signal-cli-address "/api/v1/rpc")) (url-show-status nil) (buf (condition-case nil (url-retrieve-synchronously url t) (error (signal 'noise-rpc-error (list (format "Could not connect to signal-cli at %s. Is signal-cli daemon running?" noise-signal-cli-address))))))) (if (not buf) (signal 'noise-rpc-error (list (format "No response from signal-cli at %s" noise-signal-cli-address))) (unwind-protect (with-current-buffer buf (goto-char (point-min)) (re-search-forward "\n\n" nil t) (let* ((body (buffer-substring (point) (point-max))) (result (noise-rpc--parse-response body))) result)) (kill-buffer buf))))) (defun noise-rpc-call-for-account (method &rest params) "Call signal-cli JSON-RPC METHOD with PARAMS for the configured account. Like `noise-rpc-call', but when `noise-account' is set the `account' parameter is added to the request. Account-scoped methods (such as listContacts or send) require it when the daemon serves multiple accounts." (if (and (boundp 'noise-account) noise-account) (apply #'noise-rpc-call method :account noise-account params) (apply #'noise-rpc-call method params))) (defun noise-rpc--parse-response (raw) "Parse RAW JSON-RPC response body. Returns the `result` on success. Signals `noise-rpc-error` on JSON-RPC error or malformed response." (let* ((parsed (condition-case nil (json-parse-string raw) (error (signal 'noise-rpc-error (list "signal-cli returned invalid JSON response")))))) (if-let ((err (gethash "error" parsed))) (signal 'noise-rpc-error (list (format "signal-cli error %s: %s" (gethash "code" err) (gethash "message" err)))) (gethash "result" parsed)))) (defun noise-rpc-check () "Check connectivity to signal-cli. Returns t if the daemon responds (even with an error indicating a valid JSON-RPC endpoint). Signals `noise-rpc-error` on connection failure or timeout." ;; signal-cli daemon doesn't have a dedicated ping/version RPC method. ;; We send a request for a purposefully invalid method. If we get back ;; a valid JSON-RPC error response, the daemon is running and reachable. ;; If we get a connection error, the daemon is not running. (let* ((url-request-method "POST") (url-request-extra-headers '(("Content-Type" . "application/json"))) (url-request-data "{\"jsonrpc\":\"2.0\",\"method\":\"noisePing\",\"id\":0}") (url (concat noise-signal-cli-address "/api/v1/rpc")) (url-show-status nil) (buf (condition-case nil (url-retrieve-synchronously url t) (error (signal 'noise-rpc-error (list (format "Could not connect to signal-cli at %s. Is signal-cli daemon running?" noise-signal-cli-address))))))) (if (not buf) (signal 'noise-rpc-error (list (format "No response from signal-cli at %s" noise-signal-cli-address))) (unwind-protect (with-current-buffer buf (goto-char (point-min)) (re-search-forward "\n\n" nil t) (let* ((body (buffer-substring (point) (point-max))) (parsed (condition-case nil (json-parse-string body) (error (signal 'noise-rpc-error (list "signal-cli returned invalid JSON response")))))) ;; Any valid JSON-RPC response means the daemon is alive (if (or (gethash "result" parsed) (gethash "error" parsed)) t (signal 'noise-rpc-error (list "Invalid JSON-RPC response from signal-cli"))))) (kill-buffer buf))))) (provide 'noise-rpc) ;;; noise-rpc.el ends here