diff --git a/noise-rpc.el b/noise-rpc.el new file mode 100644 index 0000000..03816cc --- /dev/null +++ b/noise-rpc.el @@ -0,0 +1,92 @@ +;;; 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) + +(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--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 the version string on success. +Signals `noise-rpc-error` on failure." + (let ((result (noise-rpc-call "getVersion"))) + (gethash "version" result))) + +(provide 'noise-rpc) +;;; noise-rpc.el ends here diff --git a/noise.el b/noise.el index 7b97271..ee4eb3d 100644 --- a/noise.el +++ b/noise.el @@ -11,6 +11,8 @@ ;;; Code: +(require 'noise-rpc) + (defgroup noise nil "Signal Messenger client for Emacs." :group 'comm @@ -22,6 +24,16 @@ Format: http://HOST:PORT" :type 'string :group 'noise) +;;;###autoload +(defun noise-check-connection () + "Check connectivity to the signal-cli JSON-RPC daemon." + (interactive) + (condition-case err + (let ((version (noise-rpc-check))) + (message "signal-cli connected (version %s)" version)) + (noise-rpc-error + (message "Error: %s" (cdr err))))) + ;;;###autoload (define-minor-mode noise-mode "Toggle Noise Signal client mode." diff --git a/test/noise-rpc-test.el b/test/noise-rpc-test.el new file mode 100644 index 0000000..7f6b7e4 --- /dev/null +++ b/test/noise-rpc-test.el @@ -0,0 +1,39 @@ +;;; noise-rpc-test.el --- Tests for noise-rpc -*- lexical-binding: t; -*- + +(require 'ert) +(require 'noise-rpc) + +(ert-deftest noise-rpc--build-request-no-params () + "Test that noise-rpc--build-request produces valid JSON-RPC 2.0 without params." + (let* ((body (noise-rpc--build-request "getVersion")) + (parsed (json-parse-string body))) + (should (string= "2.0" (gethash "jsonrpc" parsed))) + (should (string= "getVersion" (gethash "method" parsed))) + (should (integerp (gethash "id" parsed))) + (should-not (gethash "params" parsed)))) + +(ert-deftest noise-rpc--build-request-with-params () + "Test that params are included when provided." + (let* ((body (noise-rpc--build-request "sendMessage" :recipient "+123" :message "hi")) + (parsed (json-parse-string body))) + (should (string= "sendMessage" (gethash "method" parsed))) + (let ((params (gethash "params" parsed))) + (should params) + (should (string= "+123" (gethash "recipient" params))) + (should (string= "hi" (gethash "message" params)))))) + +(ert-deftest noise-rpc--parse-response-success () + "Test parsing a successful JSON-RPC response." + (let* ((raw "{\"jsonrpc\":\"2.0\",\"result\":{\"version\":\"0.13.0\"},\"id\":1}") + (result (noise-rpc--parse-response raw))) + (should result) + (should (string= "0.13.0" (gethash "version" result))))) + +(ert-deftest noise-rpc--parse-response-error () + "Test parsing a JSON-RPC error raises noise-rpc-error." + (let ((raw "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32000,\"message\":\"method not found\"},\"id\":1}")) + (should-error (noise-rpc--parse-response raw) :type 'noise-rpc-error))) + +(ert-deftest noise-rpc--parse-response-malformed () + "Test that malformed JSON raises noise-rpc-error." + (should-error (noise-rpc--parse-response "not json") :type 'noise-rpc-error))