Files
noise/noise-rpc.el
T
2026-06-10 12:12:31 -04:00

133 lines
5.7 KiB
EmacsLisp

;;; 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