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