225 lines
8.5 KiB
EmacsLisp
225 lines
8.5 KiB
EmacsLisp
;;; noise-sse.el --- SSE client for signal-cli -*- lexical-binding: t; -*-
|
|
|
|
;;; Commentary:
|
|
|
|
;; Implements Server-Sent Events client for receiving real-time messages
|
|
;; from signal-cli daemon's /api/v1/events endpoint.
|
|
|
|
;;; Code:
|
|
|
|
(require 'url)
|
|
(require 'json)
|
|
(require 'cl-lib)
|
|
|
|
(defvar noise-sse--process nil
|
|
"Active SSE network process, or nil.")
|
|
|
|
(defvar noise-sse--reconnect-timer nil
|
|
"Timer for reconnection attempts, or nil.")
|
|
|
|
(defvar noise-sse--reconnect-delay 1
|
|
"Current delay in seconds before next reconnection attempt.")
|
|
|
|
(defcustom noise-sse-max-reconnect-delay 60
|
|
"Maximum delay in seconds between reconnection attempts."
|
|
:type 'integer
|
|
:group 'noise)
|
|
|
|
(defun noise-sse--increase-reconnect-delay ()
|
|
"Increase reconnect delay with exponential backoff, capped at max."
|
|
(setq noise-sse--reconnect-delay
|
|
(min (* noise-sse--reconnect-delay 2)
|
|
noise-sse-max-reconnect-delay)))
|
|
|
|
(defun noise-sse--reset-reconnect-delay ()
|
|
"Reset reconnect delay to initial value after successful connection."
|
|
(setq noise-sse--reconnect-delay 1))
|
|
|
|
(defun noise-sse--parse-event (event-string)
|
|
"Parse an SSE EVENT-STRING into a hash table.
|
|
Returns nil for empty or invalid events."
|
|
(when (and event-string (not (string-empty-p event-string)))
|
|
(let ((lines (split-string event-string "\r?\n"))
|
|
(data-lines '()))
|
|
(dolist (line lines)
|
|
(when (string-match "\\`data:\\(?: \\)?\\(.*\\)\\'" line)
|
|
(push (match-string 1 line) data-lines)))
|
|
(when data-lines
|
|
(let ((json-str (mapconcat #'identity (nreverse data-lines) "\n")))
|
|
(condition-case nil
|
|
(json-parse-string json-str)
|
|
(error nil)))))))
|
|
|
|
(defvar noise-sse--buffer ""
|
|
"Decoded SSE event data awaiting a complete event delimiter.")
|
|
|
|
(defvar noise-sse--http-buffer ""
|
|
"Raw HTTP response data buffered until headers are consumed.")
|
|
|
|
(defvar noise-sse--chunk-buffer ""
|
|
"Raw chunked HTTP body data buffered until a whole chunk arrives.")
|
|
|
|
(defvar noise-sse--headers-received nil
|
|
"Non-nil once the HTTP response headers have been consumed.")
|
|
|
|
(defvar noise-sse--chunked-response-p nil
|
|
"Non-nil when the SSE response uses chunked transfer encoding.")
|
|
|
|
(defun noise-sse--dispatch-events (string)
|
|
"Append STRING to the SSE event buffer and dispatch complete events."
|
|
(setq noise-sse--buffer (concat noise-sse--buffer string))
|
|
(while (string-match "\r?\n\r?\n" noise-sse--buffer)
|
|
(let ((event-end (match-beginning 0))
|
|
(event-next (match-end 0)))
|
|
(let ((event-str (substring noise-sse--buffer 0 event-end)))
|
|
(setq noise-sse--buffer (substring noise-sse--buffer event-next))
|
|
(when-let ((event (noise-sse--parse-event event-str)))
|
|
(funcall noise-sse--event-handler event))))))
|
|
|
|
(defun noise-sse--decode-chunked (string)
|
|
"Decode HTTP chunked transfer body STRING.
|
|
Returns newly decoded SSE payload, buffering incomplete chunks."
|
|
(setq noise-sse--chunk-buffer (concat noise-sse--chunk-buffer string))
|
|
(let ((decoded '()))
|
|
(catch 'stop
|
|
(while t
|
|
(unless (string-match "\r?\n" noise-sse--chunk-buffer)
|
|
(throw 'stop nil))
|
|
(let* ((line-end (match-beginning 0))
|
|
(line-next (match-end 0))
|
|
(size-line (car (split-string
|
|
(substring noise-sse--chunk-buffer 0 line-end)
|
|
";" t "[ \t]*"))))
|
|
(unless (and size-line
|
|
(string-match-p "\\`[0-9A-Fa-f]+\\'" size-line))
|
|
(throw 'stop nil))
|
|
(let* ((size (string-to-number size-line 16))
|
|
(data-start line-next)
|
|
(data-end (+ data-start size)))
|
|
(when (= size 0)
|
|
(setq noise-sse--chunk-buffer "")
|
|
(throw 'stop nil))
|
|
(when (> data-end (length noise-sse--chunk-buffer))
|
|
(throw 'stop nil))
|
|
(let ((tail-end
|
|
(cond
|
|
((and (<= (+ data-end 2) (length noise-sse--chunk-buffer))
|
|
(string= "\r\n"
|
|
(substring noise-sse--chunk-buffer data-end (+ data-end 2))))
|
|
(+ data-end 2))
|
|
((and (< data-end (length noise-sse--chunk-buffer))
|
|
(eq (aref noise-sse--chunk-buffer data-end) ?\n))
|
|
(1+ data-end))
|
|
(t
|
|
(throw 'stop nil)))))
|
|
(push (substring noise-sse--chunk-buffer data-start data-end) decoded)
|
|
(setq noise-sse--chunk-buffer
|
|
(substring noise-sse--chunk-buffer tail-end)))))))
|
|
(apply #'concat (nreverse decoded))))
|
|
|
|
(defun noise-sse--consume-http-body (string)
|
|
"Consume raw HTTP response STRING and return decoded SSE payload, if any."
|
|
(let ((body string))
|
|
(unless noise-sse--headers-received
|
|
(setq noise-sse--http-buffer (concat noise-sse--http-buffer string))
|
|
(if (not (string-match "\r?\n\r?\n" noise-sse--http-buffer))
|
|
(setq body nil)
|
|
(let ((headers (substring noise-sse--http-buffer 0 (match-beginning 0)))
|
|
(body-start (match-end 0)))
|
|
(let ((case-fold-search t))
|
|
(setq noise-sse--chunked-response-p
|
|
(string-match-p "\ntransfer-encoding:[^\r\n]*chunked" headers)))
|
|
(setq body (substring noise-sse--http-buffer body-start)
|
|
noise-sse--http-buffer ""
|
|
noise-sse--headers-received t))))
|
|
(when body
|
|
(if noise-sse--chunked-response-p
|
|
(noise-sse--decode-chunked body)
|
|
body))))
|
|
|
|
(defun noise-sse--filter (proc string)
|
|
"Process filter for SSE connection.
|
|
Consumes HTTP framing from STRING and dispatches decoded SSE events."
|
|
(ignore proc)
|
|
(when-let ((body (noise-sse--consume-http-body string)))
|
|
(noise-sse--dispatch-events body)))
|
|
|
|
(defun noise-sse--default-handle-event (event)
|
|
"Handle a parsed SSE EVENT when no receiver is installed yet."
|
|
(message "SSE event received: %s" event))
|
|
|
|
(defvar noise-sse--event-handler #'noise-sse--default-handle-event
|
|
"Function called with each parsed SSE event.")
|
|
|
|
(defvar noise-signal-cli-address)
|
|
(defvar noise-account)
|
|
|
|
(defun noise-sse--build-url ()
|
|
"Build the SSE endpoint URL for signal-cli."
|
|
(let ((base (concat noise-signal-cli-address "/api/v1/events")))
|
|
(if (and (boundp 'noise-account) noise-account)
|
|
(concat base "?account=" (url-hexify-string noise-account))
|
|
base)))
|
|
|
|
(defun noise-sse--sentinel (proc status)
|
|
"Process sentinel for SSE connection.
|
|
Handles connection close and triggers reconnection."
|
|
(when (or (equal status "finished\n")
|
|
(string-match-p "failed" status)
|
|
(string-match-p "deleted" status))
|
|
(message "Noise: SSE connection lost, reconnecting in %ds..."
|
|
noise-sse--reconnect-delay)
|
|
(setq noise-sse--process nil)
|
|
(noise-sse--schedule-reconnect)))
|
|
|
|
(defun noise-sse--schedule-reconnect ()
|
|
"Schedule a reconnection attempt after the current delay."
|
|
(when noise-sse--reconnect-timer
|
|
(cancel-timer noise-sse--reconnect-timer))
|
|
(setq noise-sse--reconnect-timer
|
|
(run-with-timer noise-sse--reconnect-delay nil
|
|
#'noise-sse-connect)))
|
|
|
|
;;;###autoload
|
|
(defun noise-sse-connect ()
|
|
"Connect to signal-cli SSE endpoint."
|
|
(interactive)
|
|
(noise-sse-disconnect)
|
|
(let* ((url (noise-sse--build-url))
|
|
(parsed (url-generic-parse-url url))
|
|
(host (url-host parsed))
|
|
(port (or (url-port parsed) 80))
|
|
(proc (make-network-process
|
|
:name "noise-sse"
|
|
:host host
|
|
:service port
|
|
:filter #'noise-sse--filter
|
|
:sentinel #'noise-sse--sentinel)))
|
|
(setq noise-sse--process proc)
|
|
(noise-sse--reset-reconnect-delay)
|
|
(process-send-string proc
|
|
(format "GET %s HTTP/1.1\r\nHost: %s\r\nAccept: text/event-stream\r\n\r\n"
|
|
(url-filename parsed)
|
|
host))
|
|
(message "Noise: SSE connected to %s" url)))
|
|
|
|
;;;###autoload
|
|
(defun noise-sse-disconnect ()
|
|
"Disconnect from signal-cli SSE endpoint."
|
|
(interactive)
|
|
(when noise-sse--process
|
|
(delete-process noise-sse--process)
|
|
(setq noise-sse--process nil))
|
|
(when noise-sse--reconnect-timer
|
|
(cancel-timer noise-sse--reconnect-timer)
|
|
(setq noise-sse--reconnect-timer nil))
|
|
(setq noise-sse--buffer ""
|
|
noise-sse--http-buffer ""
|
|
noise-sse--chunk-buffer ""
|
|
noise-sse--headers-received nil
|
|
noise-sse--chunked-response-p nil)
|
|
(message "Noise: SSE disconnected"))
|
|
|
|
(provide 'noise-sse)
|
|
;;; noise-sse.el ends here
|