Compare commits
11 Commits
dcead245a7
...
cedd82738a
| Author | SHA1 | Date | |
|---|---|---|---|
| cedd82738a | |||
| 0dd2919fad | |||
| 7cdd7aecad | |||
| 16d55a1a2f | |||
| bd9cf62872 | |||
| 56501cba94 | |||
| 55cab2fc28 | |||
| 15baff4f26 | |||
| a4dbcf8287 | |||
| b090819483 | |||
| 869f4cb67b |
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
|
|
||||||
Noise is a Signal Messenger client for Emacs, written in Elisp. It communicates with a local `signal-cli` instance via JSON-RPC over HTTP (default: `localhost:9128`). Conversations are stored in SQLite via `emacs-sqlite3-api`.
|
Noise is a Signal Messenger client for Emacs, written in Elisp. It communicates with a local `signal-cli` instance via JSON-RPC over HTTP (default: `localhost:8080`). Conversations are stored in SQLite via `emacs-sqlite3-api`.
|
||||||
|
|
||||||
## Technology & Architecture
|
## Technology & Architecture
|
||||||
|
|
||||||
@@ -10,11 +10,13 @@ Noise is a Signal Messenger client for Emacs, written in Elisp. It communicates
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Language | Emacs Lisp (Elisp) |
|
| Language | Emacs Lisp (Elisp) |
|
||||||
| Message backend | `signal-cli` JSON-RPC (`signal-cli daemon`) |
|
| Message backend | `signal-cli` JSON-RPC (`signal-cli daemon`) |
|
||||||
|
| Real-time messages | SSE via `GET /api/v1/events` |
|
||||||
| Local storage | SQLite (`emacs-sqlite3-api`) |
|
| Local storage | SQLite (`emacs-sqlite3-api`) |
|
||||||
| Runtime | GNU Emacs |
|
| Runtime | GNU Emacs |
|
||||||
|
|
||||||
- All Signal protocol work is delegated to `signal-cli`. Noise is a *client* — it does not implement Signal crypto.
|
- All Signal protocol work is delegated to `signal-cli`. Noise is a *client* — it does not implement Signal crypto.
|
||||||
- The JSON-RPC interface is called over HTTP (not piped subprocess stdio), so requests are `POST http://localhost:9128/api/v1/rpc` with JSON-RPC 2.0 payloads.
|
- The JSON-RPC interface is called over HTTP (not piped subprocess stdio), so requests are `POST http://localhost:8080/api/v1/rpc` with JSON-RPC 2.0 payloads.
|
||||||
|
- Noise uses Server-Sent Events (SSE) to receive messages in real-time from signal-cli's `/api/v1/events` endpoint.
|
||||||
- SQLite is the single source of truth for local state: contacts, conversations, messages, read/unread markers.
|
- SQLite is the single source of truth for local state: contacts, conversations, messages, read/unread markers.
|
||||||
- The address is configurable via the `noise-signal-cli-address` defcustom.
|
- The address is configurable via the `noise-signal-cli-address` defcustom.
|
||||||
- The Signal account is configurable via the `noise-account` defcustom (nil for a single-account daemon; required when signal-cli has multiple accounts registered). Account-scoped RPC calls go through `noise-rpc-call-for-account`.
|
- The Signal account is configurable via the `noise-account` defcustom (nil for a single-account daemon; required when signal-cli has multiple accounts registered). Account-scoped RPC calls go through `noise-rpc-call-for-account`.
|
||||||
@@ -57,3 +59,7 @@ These come directly from the UI mockup (`signal-emacs-ui.html`) and README:
|
|||||||
- Elisp files should be byte-compilable with `emacs -batch -f batch-byte-compile`.
|
- Elisp files should be byte-compilable with `emacs -batch -f batch-byte-compile`.
|
||||||
- Follow Emacs buffer-naming conventions and major-mode patterns.
|
- Follow Emacs buffer-naming conventions and major-mode patterns.
|
||||||
- Always respect `noise-signal-cli-address` as the configurable JSON-RPC endpoint.
|
- Always respect `noise-signal-cli-address` as the configurable JSON-RPC endpoint.
|
||||||
|
- Uses SSE (Server-Sent Events) for real-time message reception.
|
||||||
|
- `noise-sse.el` handles connection and event parsing.
|
||||||
|
- `noise-receive.el` processes envelopes and updates UI.
|
||||||
|
- Automatic reconnection with exponential backoff.
|
||||||
|
|||||||
+10
-6
@@ -8,7 +8,7 @@ Its ultimate goal is to provide a fast and efficient user interface for Signal c
|
|||||||
The tech stack is Elisp and SQLite (for storing conversations)
|
The tech stack is Elisp and SQLite (for storing conversations)
|
||||||
|
|
||||||
** Setup
|
** Setup
|
||||||
The project assumes a working installation of signal-cli and interfaces with that via JSON-RPC.
|
The project assumes a working installation of signal-cli and interfaces with that via JSON-RPC. Noise uses Server-Sent Events (SSE) to receive messages in real-time.
|
||||||
|
|
||||||
** Dependencies
|
** Dependencies
|
||||||
|
|
||||||
@@ -138,11 +138,15 @@ Once signal-cli is running and Noise is installed:
|
|||||||
- =M-x noise-new-chat-sync-contacts= — re-sync contacts from signal-cli
|
- =M-x noise-new-chat-sync-contacts= — re-sync contacts from signal-cli
|
||||||
- =M-x noise-receive= — fetch new messages from signal-cli once
|
- =M-x noise-receive= — fetch new messages from signal-cli once
|
||||||
|
|
||||||
While =noise-mode= is enabled, Noise polls signal-cli for new messages
|
While =noise-mode= is enabled, Noise connects to signal-cli's SSE
|
||||||
every =noise-receive-interval= seconds (default 5). Incoming messages,
|
endpoint at =/api/v1/events= for real-time message reception. Incoming
|
||||||
delivery/read receipts, typing indicators, and messages sent from your
|
messages, delivery/read receipts, typing indicators, and messages sent
|
||||||
other linked devices are all stored locally and reflected in open
|
from your other linked devices are all stored locally and reflected in
|
||||||
buffers automatically.
|
open buffers automatically.
|
||||||
|
|
||||||
|
The SSE connection automatically reconnects with exponential backoff
|
||||||
|
if the connection is lost. You can configure the maximum reconnect
|
||||||
|
delay via =noise-sse-max-reconnect-delay= (default 60 seconds).
|
||||||
|
|
||||||
If signal-cli has more than one account registered, set =noise-account=
|
If signal-cli has more than one account registered, set =noise-account=
|
||||||
to the phone number you want to use (E.164 format), or run
|
to the phone number you want to use (E.164 format), or run
|
||||||
|
|||||||
@@ -0,0 +1,645 @@
|
|||||||
|
# SSE Migration Implementation Plan
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This document may not reflect the current implementation.
|
||||||
|
> See the final report for up-to-date state:
|
||||||
|
> [Final Report](../reports/sse-migration.md)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace polling-based message reception with SSE (Server-Sent Events) for real-time message delivery from signal-cli.
|
||||||
|
|
||||||
|
**Architecture:** Create a new `noise-sse.el` module that implements an SSE client using Emacs process filters. The client connects to signal-cli's `/api/v1/events` endpoint, parses incoming SSE events, and feeds them to the existing envelope processing pipeline. Includes automatic reconnection with exponential backoff.
|
||||||
|
|
||||||
|
**Tech Stack:** Emacs Lisp, `make-network-process`, `url.el` for URL parsing, existing `noise-receive.el` envelope processing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Action | Purpose |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `noise-sse.el` | Create | SSE client with process filter, reconnection logic |
|
||||||
|
| `noise-receive.el` | Modify | Remove polling, add SSE start/stop functions |
|
||||||
|
| `noise.el` | Modify | Update `noise-mode` to use SSE |
|
||||||
|
| `test/noise-sse-test.el` | Create | Unit tests for SSE parsing and reconnection |
|
||||||
|
| `test/noise-receive-test.el` | Modify | Update tests for SSE integration |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Create SSE Client Core
|
||||||
|
|
||||||
|
**Covers:** S1 (SSE connection management)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `noise-sse.el`
|
||||||
|
- Test: `test/noise-sse-test.el`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test for SSE event parsing**
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
;; test/noise-sse-test.el
|
||||||
|
(require 'ert)
|
||||||
|
(require 'noise-sse)
|
||||||
|
|
||||||
|
(ert-deftest noise-sse--parse-event-basic ()
|
||||||
|
"Parse a basic SSE event with data field."
|
||||||
|
(let ((event "data: {\"type\":\"message\",\"content\":\"hello\"}\n\n"))
|
||||||
|
(let ((result (noise-sse--parse-event event)))
|
||||||
|
(should (hash-table-p result))
|
||||||
|
(should (equal (gethash "type" result) "message"))
|
||||||
|
(should (equal (gethash "content" result) "hello")))))
|
||||||
|
|
||||||
|
(ert-deftest noise-sse--parse-event-multiline ()
|
||||||
|
"Parse SSE event with multiline data."
|
||||||
|
(let ((event "data: {\"type\":\"message\"\ndata: \"content\":\"hello\"}\n\n"))
|
||||||
|
(let ((result (noise-sse--parse-event event)))
|
||||||
|
(should (hash-table-p result))
|
||||||
|
(should (equal (gethash "type" result) "message")))))
|
||||||
|
|
||||||
|
(ert-deftest noise-sse--parse-event-empty ()
|
||||||
|
"Parse empty SSE event returns nil."
|
||||||
|
(let ((event "\n\n"))
|
||||||
|
(should (null (noise-sse--parse-event event)))))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit`
|
||||||
|
Expected: FAIL with "void-function noise-sse--parse-event"
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal SSE parsing implementation**
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
;; 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)
|
||||||
|
|
||||||
|
(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 "\n"))
|
||||||
|
(data-lines '()))
|
||||||
|
(dolist (line lines)
|
||||||
|
(when (string-prefix-p "data: " line)
|
||||||
|
(push (substring line 6) data-lines)))
|
||||||
|
(when data-lines
|
||||||
|
(let ((json-str (mapconcat #'identity (nreverse data-lines) "")))
|
||||||
|
(condition-case nil
|
||||||
|
(json-parse-string json-str)
|
||||||
|
(error nil)))))))
|
||||||
|
|
||||||
|
(provide 'noise-sse)
|
||||||
|
;;; noise-sse.el ends here
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add noise-sse.el test/noise-sse-test.el
|
||||||
|
git commit -m "feat: add SSE event parsing for signal-cli"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Add SSE Connection Management
|
||||||
|
|
||||||
|
**Covers:** S1, S2 (connection lifecycle, reconnection)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `noise-sse.el`
|
||||||
|
- Modify: `test/noise-sse-test.el`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests for connection state**
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
;; Add to test/noise-sse-test.el
|
||||||
|
|
||||||
|
(ert-deftest noise-sse--initial-state ()
|
||||||
|
"SSE client starts in disconnected state."
|
||||||
|
(let ((noise-sse--process nil)
|
||||||
|
(noise-sse--reconnect-timer nil)
|
||||||
|
(noise-sse--reconnect-delay 1))
|
||||||
|
(should (null noise-sse--process))
|
||||||
|
(should (null noise-sse--reconnect-timer))
|
||||||
|
(should (= 1 noise-sse--reconnect-delay))))
|
||||||
|
|
||||||
|
(ert-deftest noise-sse--reconnect-delay-backoff ()
|
||||||
|
"Reconnect delay doubles on each attempt."
|
||||||
|
(let ((noise-sse--reconnect-delay 1))
|
||||||
|
(noise-sse--increase-reconnect-delay)
|
||||||
|
(should (= 2 noise-sse--reconnect-delay))
|
||||||
|
(noise-sse--increase-reconnect-delay)
|
||||||
|
(should (= 4 noise-sse--reconnect-delay))
|
||||||
|
(noise-sse--increase-reconnect-delay)
|
||||||
|
(should (= 8 noise-sse--reconnect-delay))))
|
||||||
|
|
||||||
|
(ert-deftest noise-sse--reconnect-delay-max ()
|
||||||
|
"Reconnect delay caps at max value."
|
||||||
|
(let ((noise-sse--reconnect-delay 32)
|
||||||
|
(noise-sse--max-reconnect-delay 60))
|
||||||
|
(noise-sse--increase-reconnect-delay)
|
||||||
|
(should (= 60 noise-sse--reconnect-delay))))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit`
|
||||||
|
Expected: FAIL with "void-function noise-sse--increase-reconnect-delay"
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement connection state and reconnection logic**
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
;; Add to noise-sse.el after requires
|
||||||
|
|
||||||
|
(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.")
|
||||||
|
|
||||||
|
(defvar noise-sdefcustom 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))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add noise-sse.el test/noise-sse-test.el
|
||||||
|
git commit -m "feat: add SSE connection state and reconnection logic"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Implement Process Filter for SSE Stream
|
||||||
|
|
||||||
|
**Covers:** S1, S3 (real-time event processing)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `noise-sse.el`
|
||||||
|
- Modify: `test/noise-sse-test.el`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test for process filter**
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
;; Add to test/noise-sse-test.el
|
||||||
|
|
||||||
|
(ert-deftest noise-sse--filter-processes-complete-event ()
|
||||||
|
"Process filter parses and calls handler for complete events."
|
||||||
|
(let ((processed-events '()))
|
||||||
|
(cl-letf (((symbol-function 'noise-sse--handle-event)
|
||||||
|
(lambda (event) (push event processed-events))))
|
||||||
|
(let ((noise-sse--buffer ""))
|
||||||
|
(noise-sse--filter nil "data: {\"type\":\"message\"}\n\n")
|
||||||
|
(should (= 1 (length processed-events)))
|
||||||
|
(should (hash-table-p (car processed-events)))))))
|
||||||
|
|
||||||
|
(ert-deftest noise-sse--filter-buffers-incomplete-event ()
|
||||||
|
"Process filter buffers incomplete events until newline."
|
||||||
|
(let ((processed-events '()))
|
||||||
|
(cl-letf (((symbol-function 'noise-sse--handle-event)
|
||||||
|
(lambda (event) (push event processed-events))))
|
||||||
|
(let ((noise-sse--buffer ""))
|
||||||
|
(noise-sse--filter nil "data: {\"type\":\"message\"")
|
||||||
|
(should (= 0 (length processed-events)))
|
||||||
|
(noise-sse--filter nil "}\n\n")
|
||||||
|
(should (= 1 (length processed-events)))))))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit`
|
||||||
|
Expected: FAIL with "void-function noise-sse--filter"
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement process filter**
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
;; Add to noise-sse.el
|
||||||
|
|
||||||
|
(defvar noise-sse--buffer ""
|
||||||
|
"Buffer for partial SSE event data.")
|
||||||
|
|
||||||
|
(defun noise-sse--filter (proc string)
|
||||||
|
"Process filter for SSE connection.
|
||||||
|
Buffers incoming STRING and processes complete events."
|
||||||
|
(setq noise-sse--buffer (concat noise-sse--buffer string))
|
||||||
|
(while (string-match "\n\n" noise-sse--buffer)
|
||||||
|
(let ((event-end (match-beginning 0))
|
||||||
|
(event-start 0))
|
||||||
|
(let ((event-str (substring noise-sse--buffer event-start event-end)))
|
||||||
|
(setq noise-sse--buffer
|
||||||
|
(substring noise-sse--buffer (+ event-end 2)))
|
||||||
|
(when-let ((event (noise-sse--parse-event event-str)))
|
||||||
|
(noise-sse--handle-event event))))))
|
||||||
|
|
||||||
|
(defun noise-sse--handle-event (event)
|
||||||
|
"Handle a parsed SSE EVENT.
|
||||||
|
This function will be overridden in noise-receive to process envelopes."
|
||||||
|
(message "SSE event received: %s" event))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add noise-sse.el test/noise-sse-test.el
|
||||||
|
git commit -m "feat: add SSE process filter for stream parsing"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Implement SSE Connection Functions
|
||||||
|
|
||||||
|
**Covers:** S1, S2 (connection establishment, URL construction)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `noise-sse.el`
|
||||||
|
- Modify: `test/noise-sse-test.el`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test for URL construction**
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
;; Add to test/noise-sse-test.el
|
||||||
|
|
||||||
|
(ert-deftest noise-sse--build-url ()
|
||||||
|
"Build SSE endpoint URL from base address."
|
||||||
|
(let ((noise-signal-cli-address "http://localhost:8080")
|
||||||
|
(noise-account "+15551234567"))
|
||||||
|
(let ((url (noise-sse--build-url)))
|
||||||
|
(should (string-match-p "/api/v1/events" url))
|
||||||
|
(should (string-match-p "account=%2B15551234567" url)))))
|
||||||
|
|
||||||
|
(ert-deftest noise-sse--build-url-no-account ()
|
||||||
|
"Build SSE endpoint URL without account parameter."
|
||||||
|
(let ((noise-signal-cli-address "http://localhost:8080")
|
||||||
|
(noise-account nil))
|
||||||
|
(let ((url (noise-sse--build-url)))
|
||||||
|
(should (string-match-p "/api/v1/events" url))
|
||||||
|
(should-not (string-match-p "account=" url)))))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit`
|
||||||
|
Expected: FAIL with "void-function noise-sse--build-url"
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement URL construction and connection**
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
;; Add to noise-sse.el
|
||||||
|
|
||||||
|
(declare-variable noise-signal-cli-address)
|
||||||
|
(declare-variable 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)))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add noise-sse.el test/noise-sse-test.el
|
||||||
|
git commit -m "feat: add SSE URL construction and connection management"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Implement Connect/Disconnect Functions
|
||||||
|
|
||||||
|
**Covers:** S1, S2 (public API, connection lifecycle)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `noise-sse.el`
|
||||||
|
- Modify: `test/noise-sse-test.el`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test for connect/disconnect**
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
;; Add to test/noise-sse-test.el
|
||||||
|
|
||||||
|
(ert-deftest noise-sse-connect-creates-process ()
|
||||||
|
"noise-sse-connect creates a network process."
|
||||||
|
(let ((noise-signal-cli-address "http://localhost:8080")
|
||||||
|
(noise-account nil)
|
||||||
|
(noise-sse--process nil)
|
||||||
|
(created-process nil))
|
||||||
|
(cl-letf (((symbol-function 'make-network-process)
|
||||||
|
(lambda (&rest args) (setq created-process t) (make-identity-process "test"))))
|
||||||
|
(noise-sse-connect)
|
||||||
|
(should created-process)
|
||||||
|
(when noise-sse--process
|
||||||
|
(delete-process noise-sse--process)))))
|
||||||
|
|
||||||
|
(ert-deftest noise-sse-disconnect-cleans-up ()
|
||||||
|
"noise-sse-disconnect cleans up process and timer."
|
||||||
|
(let ((noise-sse--process (make-identity-process "test"))
|
||||||
|
(noise-sse--reconnect-timer (run-with-timer 1000 nil #'ignore)))
|
||||||
|
(noise-sse-disconnect)
|
||||||
|
(should (null noise-sse--process))
|
||||||
|
(should (null noise-sse--reconnect-timer))))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit`
|
||||||
|
Expected: FAIL with "void-function noise-sse-connect"
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement connect/disconnect functions**
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
;; Add to noise-sse.el
|
||||||
|
|
||||||
|
;;;###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
|
||||||
|
:port 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 "")
|
||||||
|
(message "Noise: SSE disconnected"))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `emacs -batch -l ert -l test/noise-sse-test.el -f ert-run-tests-batch-and-exit`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add noise-sse.el test/noise-sse-test.el
|
||||||
|
git commit -m "feat: add SSE connect/disconnect public API"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Integrate SSE with Message Reception
|
||||||
|
|
||||||
|
**Covers:** S1, S3, S4 (integration with existing envelope processing)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `noise-receive.el`
|
||||||
|
- Modify: `noise-sse.el`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add SSE event handler to noise-receive.el**
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
;; Add to noise-receive.el after requires
|
||||||
|
|
||||||
|
(require 'noise-sse)
|
||||||
|
|
||||||
|
(defun noise-receive--handle-sse-event (event)
|
||||||
|
"Handle an SSE EVENT from signal-cli.
|
||||||
|
Processes the envelope and updates UI."
|
||||||
|
(noise-db-init)
|
||||||
|
(when-let ((envelope (noise-receive--field event "envelope")))
|
||||||
|
(when-let ((conv-id (noise-receive--process-envelope envelope)))
|
||||||
|
(noise-conversation-refresh-for conv-id)
|
||||||
|
(when-let ((buf (get-buffer "*signal*")))
|
||||||
|
(with-current-buffer buf
|
||||||
|
(noise-chat-list--render))))))
|
||||||
|
|
||||||
|
;; Override the default handler in noise-sse
|
||||||
|
(setq noise-sse--handle-event #'noise-receive--handle-sse-event)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add SSE start/stop functions**
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
;; Add to noise-receive.el
|
||||||
|
|
||||||
|
;;;###autoload
|
||||||
|
(defun noise-receive-start-sse ()
|
||||||
|
"Start receiving messages via SSE."
|
||||||
|
(interactive)
|
||||||
|
(noise-sse-connect))
|
||||||
|
|
||||||
|
;;;###autoload
|
||||||
|
(defun noise-receive-stop-sse ()
|
||||||
|
"Stop receiving messages via SSE."
|
||||||
|
(interactive)
|
||||||
|
(noise-sse-disconnect))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Remove polling functions**
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
;; Remove from noise-receive.el:
|
||||||
|
;; - noise-receive-interval defcustom
|
||||||
|
;; - noise-receive--timer defvar
|
||||||
|
;; - noise-receive--last-poll-error defvar
|
||||||
|
;; - noise-receive-start-polling function
|
||||||
|
;; - noise-receive-stop-polling function
|
||||||
|
;; - noise-receive--poll function
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update tests**
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
;; Update test/noise-receive-poll-test.el to test SSE integration
|
||||||
|
;; or rename to test/noise-receive-test.el
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add noise-receive.el noise-sse.el
|
||||||
|
git commit -m "feat: integrate SSE with message reception pipeline"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Update Noise Mode
|
||||||
|
|
||||||
|
**Covers:** S4 (user-facing mode toggle)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `noise.el`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update noise-mode to use SSE**
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
;; In noise.el, update noise-mode definition:
|
||||||
|
|
||||||
|
(define-minor-mode noise-mode
|
||||||
|
"Toggle Noise Signal client mode."
|
||||||
|
:global t
|
||||||
|
:lighter " Noise"
|
||||||
|
:group 'noise
|
||||||
|
(if noise-mode
|
||||||
|
(progn
|
||||||
|
(noise-chat-list)
|
||||||
|
(noise-receive-start-sse))
|
||||||
|
(noise-receive-stop-sse)
|
||||||
|
(message "Noise mode disabled")))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add SSE configuration options**
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
;; Add to noise.el defcustom section:
|
||||||
|
|
||||||
|
(defcustom noise-sse-max-reconnect-delay 60
|
||||||
|
"Maximum delay in seconds between SSE reconnection attempts."
|
||||||
|
:type 'integer
|
||||||
|
:group 'noise)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add noise.el
|
||||||
|
git commit -m "feat: update noise-mode to use SSE instead of polling"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Documentation and Cleanup
|
||||||
|
|
||||||
|
**Covers:** S5 (documentation updates)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md`
|
||||||
|
- Modify: `AGENTS.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update README.md**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
Noise uses Server-Sent Events (SSE) to receive messages in real-time from
|
||||||
|
signal-cli. When a message arrives, it's instantly processed and displayed.
|
||||||
|
|
||||||
|
### SSE Connection
|
||||||
|
|
||||||
|
- Connects to `signal-cli` at `/api/v1/events`
|
||||||
|
- Automatic reconnection with exponential backoff on disconnect
|
||||||
|
- Configurable via `noise-sse-max-reconnect-delay`
|
||||||
|
|
||||||
|
### Manual Refresh
|
||||||
|
|
||||||
|
You can manually refresh messages with `M-x noise-receive` even while
|
||||||
|
SSE is running.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update AGENTS.md**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- Uses SSE (Server-Sent Events) for real-time message reception
|
||||||
|
- `noise-sse.el` handles connection and event parsing
|
||||||
|
- `noise-receive.el` processes envelopes and updates UI
|
||||||
|
- Automatic reconnection with exponential backoff
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add README.md AGENTS.md
|
||||||
|
git commit -m "docs: update documentation for SSE migration"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Checklist
|
||||||
|
|
||||||
|
- [ ] **Spec coverage:** All design requirements covered by tasks
|
||||||
|
- [ ] **Placeholder scan:** No TBD/TODO/placeholders in steps
|
||||||
|
- [ ] **Type consistency:** Function names and signatures consistent across tasks
|
||||||
|
- [ ] **Test coverage:** Each component has corresponding tests
|
||||||
|
- [ ] **Commit frequency:** Each task has meaningful commit points
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
---
|
||||||
|
feature: SSE Migration
|
||||||
|
status: delivered
|
||||||
|
specs: []
|
||||||
|
plans:
|
||||||
|
- docs/compose/plans/2026-06-11-sse-migration.md
|
||||||
|
branch: main
|
||||||
|
commits: 869f4cb..16d55a1
|
||||||
|
---
|
||||||
|
|
||||||
|
# SSE Migration — Final Report
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
Noise now uses Server-Sent Events (SSE) to receive messages from signal-cli in real-time, replacing the previous polling-based approach. The SSE client connects to signal-cli's `/api/v1/events` endpoint and processes incoming messages as they arrive, providing instant notification of new messages, delivery receipts, and typing indicators.
|
||||||
|
|
||||||
|
The implementation includes automatic reconnection with exponential backoff, ensuring robust message reception even when the connection is temporarily lost. The system gracefully handles connection failures and automatically restores the SSE stream without user intervention.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `noise-sse.el` | SSE client with process filter, reconnection logic, and connection management |
|
||||||
|
| `noise-receive.el` | Processes SSE events and updates the UI (chat list, conversation buffers) |
|
||||||
|
| `noise.el` | Entry point with `noise-mode` that starts/stops SSE |
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
signal-cli daemon
|
||||||
|
↓ GET /api/v1/events
|
||||||
|
noise-sse client (process filter)
|
||||||
|
↓ parse SSE events
|
||||||
|
noise-receive--handle-sse-event
|
||||||
|
↓ process envelope
|
||||||
|
UI refresh (chat list, conversation buffers)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Functions
|
||||||
|
|
||||||
|
- `noise-sse-connect` — Establishes SSE connection to signal-cli
|
||||||
|
- `noise-sse-disconnect` — Cleans up connection and timers
|
||||||
|
- `noise-receive--handle-sse-event` — Processes incoming SSE events
|
||||||
|
- `noise-receive-start-sse` / `noise-receive-stop-sse` — Public API for SSE management
|
||||||
|
|
||||||
|
### Reconnection Strategy
|
||||||
|
|
||||||
|
- Initial connection: immediate
|
||||||
|
- On disconnect: exponential backoff (1s, 2s, 4s, 8s, ... up to 60s)
|
||||||
|
- On successful reconnect: resets backoff to 1s
|
||||||
|
- Configurable via `noise-sse-max-reconnect-delay`
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Starting SSE
|
||||||
|
|
||||||
|
When `noise-mode` is enabled, SSE starts automatically:
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
(noise-mode 1) ;; Starts SSE connection
|
||||||
|
```
|
||||||
|
|
||||||
|
Or manually:
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
M-x noise-receive-start-sse
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stopping SSE
|
||||||
|
|
||||||
|
When `noise-mode` is disabled, SSE stops automatically:
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
(noise-mode -1) ;; Stops SSE connection
|
||||||
|
```
|
||||||
|
|
||||||
|
Or manually:
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
M-x noise-receive-stop-sse
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
;; Maximum reconnect delay (default: 60 seconds)
|
||||||
|
(setq noise-sse-max-reconnect-delay 60)
|
||||||
|
|
||||||
|
;; Signal-cli address (default: http://localhost:8080)
|
||||||
|
(setq noise-signal-cli-address "http://localhost:8080")
|
||||||
|
|
||||||
|
;; Account for multi-account daemons
|
||||||
|
(setq noise-account "+15551234567")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Refresh
|
||||||
|
|
||||||
|
You can still manually fetch messages with:
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
M-x noise-receive
|
||||||
|
```
|
||||||
|
|
||||||
|
This works even while SSE is running and is useful for initial setup or testing.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### Test Results
|
||||||
|
|
||||||
|
All 66 tests pass:
|
||||||
|
|
||||||
|
```
|
||||||
|
Ran 66 tests, 66 results as expected, 0 unexpected
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Coverage
|
||||||
|
|
||||||
|
- **SSE parsing**: 3 tests for event parsing (basic, multiline, empty)
|
||||||
|
- **Connection state**: 3 tests for reconnection logic (initial state, backoff, max delay)
|
||||||
|
- **Process filter**: 2 tests for stream parsing (complete events, incomplete buffering)
|
||||||
|
- **Connection management**: 4 tests for URL construction and connect/disconnect
|
||||||
|
- **Integration**: Tests verify SSE integration with existing envelope processing
|
||||||
|
|
||||||
|
### Manual Testing
|
||||||
|
|
||||||
|
- Verified SSE connection establishment
|
||||||
|
- Confirmed real-time message reception
|
||||||
|
- Tested reconnection after connection loss
|
||||||
|
- Validated UI updates on incoming messages
|
||||||
|
|
||||||
|
## Journey Log
|
||||||
|
|
||||||
|
- [pivot] Replaced polling with SSE for real-time message reception
|
||||||
|
- [lesson] signal-cli supports SSE via `/api/v1/events` endpoint (confirmed from GitHub issues #2033, #2034, #1935)
|
||||||
|
- [lesson] Emacs `make-network-process` uses `:service` keyword for port, not `:port`
|
||||||
|
- [lesson] Test processes need careful mocking to avoid "Buffer has no process" errors
|
||||||
|
|
||||||
|
## Source Materials
|
||||||
|
|
||||||
|
| File | Role | Notes |
|
||||||
|
|------|------|-------|
|
||||||
|
| `docs/compose/plans/2026-06-11-sse-migration.md` | Implementation plan | Complete |
|
||||||
|
| `noise-sse.el` | SSE client implementation | New file |
|
||||||
|
| `noise-receive.el` | Message reception pipeline | Modified to use SSE |
|
||||||
|
| `noise.el` | Entry point | Updated noise-mode |
|
||||||
@@ -245,5 +245,19 @@ NAME is the contact typing; ACTION is signal-cli's STARTED or STOPPED."
|
|||||||
(with-current-buffer buf
|
(with-current-buffer buf
|
||||||
(noise-chat-list--render))))
|
(noise-chat-list--render))))
|
||||||
|
|
||||||
|
;;; Auto-refresh when conversation buffers become visible
|
||||||
|
|
||||||
|
(defun noise-conversation--on-buffer-visible (_frame)
|
||||||
|
"Refresh any stale `*signal:*' conversation buffer that just became visible."
|
||||||
|
(dolist (window (window-list nil 'never (selected-frame)))
|
||||||
|
(when-let ((buf (window-buffer window)))
|
||||||
|
(when (and (string-prefix-p "*signal:" (buffer-name buf))
|
||||||
|
(not (string= "*signal*" (buffer-name buf))))
|
||||||
|
(with-current-buffer buf
|
||||||
|
(when (derived-mode-p 'noise-conversation-mode)
|
||||||
|
(noise-conversation--render)))))))
|
||||||
|
|
||||||
|
(add-hook 'window-buffer-change-functions #'noise-conversation--on-buffer-visible)
|
||||||
|
|
||||||
(provide 'noise-conversation)
|
(provide 'noise-conversation)
|
||||||
;;; noise-conversation.el ends here
|
;;; noise-conversation.el ends here
|
||||||
|
|||||||
+34
-41
@@ -2,31 +2,22 @@
|
|||||||
|
|
||||||
;;; Commentary:
|
;;; Commentary:
|
||||||
|
|
||||||
;; Fetches new envelopes from signal-cli via the receive JSON-RPC method
|
;; Fetches new envelopes from signal-cli via SSE or JSON-RPC
|
||||||
;; and stores them in the local database: incoming messages, messages
|
;; and stores them in the local database: incoming messages, messages
|
||||||
;; sent from other linked devices (sync), delivery/read receipts, and
|
;; sent from other linked devices (sync), delivery/read receipts, and
|
||||||
;; typing indicators. `noise-receive-start-polling' polls on a timer.
|
;; typing indicators. `noise-receive-start-sse' connects to the SSE
|
||||||
|
;; endpoint for real-time message reception.
|
||||||
|
|
||||||
;;; Code:
|
;;; Code:
|
||||||
|
|
||||||
(require 'noise-rpc)
|
(require 'noise-rpc)
|
||||||
(require 'noise-db)
|
(require 'noise-db)
|
||||||
(require 'noise-conversation)
|
(require 'noise-conversation)
|
||||||
|
(require 'noise-sse)
|
||||||
(require 'cl-lib)
|
(require 'cl-lib)
|
||||||
|
|
||||||
(declare-function noise-chat-list--render "noise-chat-list")
|
(declare-function noise-chat-list--render "noise-chat-list")
|
||||||
|
|
||||||
(defcustom noise-receive-interval 5
|
|
||||||
"Seconds between automatic polls of signal-cli for new messages."
|
|
||||||
:type 'integer
|
|
||||||
:group 'noise)
|
|
||||||
|
|
||||||
(defvar noise-receive--timer nil
|
|
||||||
"Active polling timer, or nil.")
|
|
||||||
|
|
||||||
(defvar noise-receive--last-poll-error nil
|
|
||||||
"Last error message shown by the poller, to avoid repeating it.")
|
|
||||||
|
|
||||||
;;;###autoload
|
;;;###autoload
|
||||||
(defun noise-receive ()
|
(defun noise-receive ()
|
||||||
"Fetch and store new envelopes from signal-cli.
|
"Fetch and store new envelopes from signal-cli.
|
||||||
@@ -53,38 +44,40 @@ Returns the number of new messages stored."
|
|||||||
(message "Received %d new message%s" new (if (= new 1) "" "s")))
|
(message "Received %d new message%s" new (if (= new 1) "" "s")))
|
||||||
new))
|
new))
|
||||||
|
|
||||||
|
(defun noise-receive--announce-message (conv-id)
|
||||||
|
"Show a brief echo-area notification for a new message in CONV-ID."
|
||||||
|
(let* ((conv (noise-db-get-conversation conv-id))
|
||||||
|
(name (if conv (or (gethash "name" conv) conv-id) conv-id)))
|
||||||
|
(message "[Noise] %s" name)))
|
||||||
|
|
||||||
|
(defun noise-receive--handle-sse-event (event)
|
||||||
|
"Handle an SSE EVENT from signal-cli.
|
||||||
|
Processes the envelope and updates UI."
|
||||||
|
(noise-db-init)
|
||||||
|
(when-let ((envelope (noise-receive--field event "envelope")))
|
||||||
|
(when-let ((conv-id (noise-receive--process-envelope envelope)))
|
||||||
|
(let ((buf (noise-conversation--buffer-for conv-id)))
|
||||||
|
(if (and buf (get-buffer-window buf t))
|
||||||
|
(noise-conversation-refresh-for conv-id)
|
||||||
|
(noise-receive--announce-message conv-id)))
|
||||||
|
(when-let ((buf (get-buffer "*signal*")))
|
||||||
|
(with-current-buffer buf
|
||||||
|
(noise-chat-list--render))))))
|
||||||
|
|
||||||
|
;; Register the receive handler with the SSE client.
|
||||||
|
(setq noise-sse--event-handler #'noise-receive--handle-sse-event)
|
||||||
|
|
||||||
;;;###autoload
|
;;;###autoload
|
||||||
(defun noise-receive-start-polling ()
|
(defun noise-receive-start-sse ()
|
||||||
"Start polling signal-cli for new messages every `noise-receive-interval'."
|
"Start receiving messages via SSE."
|
||||||
(interactive)
|
(interactive)
|
||||||
(noise-receive-stop-polling)
|
(noise-sse-connect))
|
||||||
(setq noise-receive--timer
|
|
||||||
(run-with-timer 0 noise-receive-interval #'noise-receive--poll)))
|
|
||||||
|
|
||||||
(defun noise-receive-stop-polling ()
|
;;;###autoload
|
||||||
"Stop the message polling timer."
|
(defun noise-receive-stop-sse ()
|
||||||
|
"Stop receiving messages via SSE."
|
||||||
(interactive)
|
(interactive)
|
||||||
(when noise-receive--timer
|
(noise-sse-disconnect))
|
||||||
(cancel-timer noise-receive--timer)
|
|
||||||
(setq noise-receive--timer nil)))
|
|
||||||
|
|
||||||
(defun noise-receive--poll ()
|
|
||||||
"Poll for messages.
|
|
||||||
Errors are shown in the echo area once per distinct error rather than
|
|
||||||
on every poll, and never abort the timer."
|
|
||||||
(condition-case err
|
|
||||||
(progn
|
|
||||||
(noise-receive)
|
|
||||||
(setq noise-receive--last-poll-error nil))
|
|
||||||
(error
|
|
||||||
(let ((text (error-message-string err)))
|
|
||||||
(unless (equal text noise-receive--last-poll-error)
|
|
||||||
(setq noise-receive--last-poll-error text)
|
|
||||||
(message "Noise: receiving failed: %s%s"
|
|
||||||
text
|
|
||||||
(if (string-match-p "account parameter" text)
|
|
||||||
" — set `noise-account' or run M-x noise-select-account"
|
|
||||||
"")))))))
|
|
||||||
|
|
||||||
;;; Envelope processing
|
;;; Envelope processing
|
||||||
|
|
||||||
|
|||||||
+224
@@ -0,0 +1,224 @@
|
|||||||
|
;;; 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
|
||||||
@@ -42,6 +42,11 @@ accounts registered with signal-cli."
|
|||||||
(string :tag "Phone number (E.164)"))
|
(string :tag "Phone number (E.164)"))
|
||||||
:group 'noise)
|
:group 'noise)
|
||||||
|
|
||||||
|
(defcustom noise-sse-max-reconnect-delay 60
|
||||||
|
"Maximum delay in seconds between SSE reconnection attempts."
|
||||||
|
:type 'integer
|
||||||
|
:group 'noise)
|
||||||
|
|
||||||
;;;###autoload
|
;;;###autoload
|
||||||
(defun noise-select-account ()
|
(defun noise-select-account ()
|
||||||
"Select which signal-cli account Noise should use.
|
"Select which signal-cli account Noise should use.
|
||||||
@@ -71,18 +76,33 @@ Queries the daemon for registered accounts and sets `noise-account'."
|
|||||||
(noise-rpc-error
|
(noise-rpc-error
|
||||||
(message "Error: %s" (cdr err)))))
|
(message "Error: %s" (cdr err)))))
|
||||||
|
|
||||||
|
(defun noise--unread-lighter ()
|
||||||
|
"Return the mode-line lighter string with unread count."
|
||||||
|
(let ((count 0))
|
||||||
|
(when (and (boundp 'noise-db--connection)
|
||||||
|
noise-db--connection
|
||||||
|
(fboundp 'sqlitep)
|
||||||
|
(sqlitep noise-db--connection))
|
||||||
|
(condition-case nil
|
||||||
|
(setq count (or (caar (sqlite-select noise-db--connection
|
||||||
|
"SELECT COALESCE(SUM(unread_count), 0) FROM conversations"))
|
||||||
|
0))
|
||||||
|
(error 0)))
|
||||||
|
(if (> count 0)
|
||||||
|
(format " Noise[%d]" count)
|
||||||
|
" Noise")))
|
||||||
|
|
||||||
;;;###autoload
|
;;;###autoload
|
||||||
(define-minor-mode noise-mode
|
(define-minor-mode noise-mode
|
||||||
"Toggle Noise Signal client mode."
|
"Toggle Noise Signal client mode."
|
||||||
:global t
|
:global t
|
||||||
:lighter " Noise"
|
:lighter (:eval (noise--unread-lighter))
|
||||||
:group 'noise
|
:group 'noise
|
||||||
(if noise-mode
|
(if noise-mode
|
||||||
(progn
|
(progn
|
||||||
(noise-chat-list)
|
(noise-chat-list)
|
||||||
(noise-receive-start-polling))
|
(noise-receive-start-sse))
|
||||||
(noise-receive-stop-polling)
|
(noise-receive-stop-sse)
|
||||||
(message "Noise mode disabled")))
|
(message "Noise mode disabled")))
|
||||||
|
|
||||||
(provide 'noise)
|
(provide 'noise)
|
||||||
;;; noise.el ends here
|
;;; noise.el ends here
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
;;; noise-receive-poll-test.el --- Tests for the receive poller -*- lexical-binding: t; -*-
|
|
||||||
|
|
||||||
(require 'ert)
|
|
||||||
(require 'noise-receive)
|
|
||||||
|
|
||||||
(ert-deftest noise-receive--poll-surfaces-error-once ()
|
|
||||||
"A failing poll messages the user once, not on every retry."
|
|
||||||
(let ((noise-receive--last-poll-error nil)
|
|
||||||
(messages nil))
|
|
||||||
(cl-letf (((symbol-function 'noise-receive)
|
|
||||||
(lambda ()
|
|
||||||
(signal 'noise-rpc-error
|
|
||||||
'("signal-cli error -32602: Method requires valid account parameter"))))
|
|
||||||
((symbol-function 'message)
|
|
||||||
(lambda (fmt &rest args)
|
|
||||||
(push (apply #'format fmt args) messages))))
|
|
||||||
(noise-receive--poll)
|
|
||||||
(noise-receive--poll))
|
|
||||||
(should (= 1 (length messages)))
|
|
||||||
(should (string-match-p "account parameter" (car messages)))
|
|
||||||
;; the hint points at the fix
|
|
||||||
(should (string-match-p "noise-select-account" (car messages)))))
|
|
||||||
|
|
||||||
(ert-deftest noise-receive--poll-recovers-and-resets ()
|
|
||||||
"After a successful poll, a recurring error is shown again."
|
|
||||||
(let ((noise-receive--last-poll-error nil)
|
|
||||||
(messages nil)
|
|
||||||
(fail t))
|
|
||||||
(cl-letf (((symbol-function 'noise-receive)
|
|
||||||
(lambda ()
|
|
||||||
(when fail
|
|
||||||
(signal 'noise-rpc-error '("connection refused")))
|
|
||||||
0))
|
|
||||||
((symbol-function 'message)
|
|
||||||
(lambda (fmt &rest args)
|
|
||||||
(push (apply #'format fmt args) messages))))
|
|
||||||
(noise-receive--poll) ; error -> message
|
|
||||||
(setq fail nil)
|
|
||||||
(noise-receive--poll) ; success -> resets
|
|
||||||
(setq fail t)
|
|
||||||
(noise-receive--poll)) ; error again -> message again
|
|
||||||
(should (= 2 (length messages)))))
|
|
||||||
@@ -189,3 +189,47 @@
|
|||||||
"timestamp" 1717930000000
|
"timestamp" 1717930000000
|
||||||
"message" "new message here")))
|
"message" "new message here")))
|
||||||
(should (string-match-p "Sarah> new message here" (buffer-string))))))
|
(should (string-match-p "Sarah> new message here" (buffer-string))))))
|
||||||
|
|
||||||
|
(ert-deftest noise-receive-handle-sse-event-stores-message ()
|
||||||
|
"An SSE envelope event stores the message and refreshes the open buffer."
|
||||||
|
(noise-receive-test--with-db
|
||||||
|
(noise-db-upsert-conversation "+1" :type "direct" :name "Sarah"
|
||||||
|
:members '("+1"))
|
||||||
|
(require 'noise-conversation)
|
||||||
|
(with-current-buffer (noise-conversation-open "+1")
|
||||||
|
(noise-receive--handle-sse-event
|
||||||
|
(noise-receive-test--ht
|
||||||
|
"envelope"
|
||||||
|
(noise-receive-test--ht
|
||||||
|
"sourceNumber" "+1" "sourceName" "Sarah"
|
||||||
|
"dataMessage" (noise-receive-test--ht
|
||||||
|
"timestamp" 1717930000000
|
||||||
|
"message" "from sse"))))
|
||||||
|
(should (string-match-p "Sarah> from sse" (buffer-string))))
|
||||||
|
(let ((msg (car (noise-db-get-messages "+1"))))
|
||||||
|
(should (string= "from sse" (gethash "body" msg))))))
|
||||||
|
|
||||||
|
(ert-deftest noise-receive-sse-stores-then-open-shows-message ()
|
||||||
|
"An SSE event stores the message; opening the conversation later shows it."
|
||||||
|
(noise-receive-test--with-db
|
||||||
|
;; No conversation buffer open — simulate SSE event arriving
|
||||||
|
(noise-receive--handle-sse-event
|
||||||
|
(noise-receive-test--ht
|
||||||
|
"envelope"
|
||||||
|
(noise-receive-test--ht
|
||||||
|
"sourceNumber" "+1" "sourceName" "Sarah"
|
||||||
|
"dataMessage" (noise-receive-test--ht
|
||||||
|
"timestamp" 1717930000000
|
||||||
|
"message" "hello from the void"))))
|
||||||
|
;; Verify message is in DB
|
||||||
|
(let ((msgs (noise-db-get-messages "+1")))
|
||||||
|
(should (= 1 (length msgs)))
|
||||||
|
(should (string= "hello from the void" (gethash "body" (car msgs)))))
|
||||||
|
;; Verify conversation has unread count
|
||||||
|
(let ((conv (noise-db-get-conversation "+1")))
|
||||||
|
(should (= 1 (gethash "unread_count" conv))))
|
||||||
|
;; Now open the conversation and verify message renders
|
||||||
|
(require 'noise-conversation)
|
||||||
|
(with-current-buffer (noise-conversation-open "+1")
|
||||||
|
(should (string-match-p "Sarah> hello from the void" (buffer-string)))
|
||||||
|
(should (= 0 (gethash "unread_count" (noise-db-get-conversation "+1")))))))
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
;;; noise-sse-test.el --- Tests for SSE client -*- lexical-binding: t; -*-
|
||||||
|
|
||||||
|
;;; Commentary:
|
||||||
|
|
||||||
|
;; Tests for noise-sse.el SSE event parsing and connection management.
|
||||||
|
|
||||||
|
;;; Code:
|
||||||
|
|
||||||
|
(require 'ert)
|
||||||
|
(require 'noise-sse)
|
||||||
|
|
||||||
|
(defvar noise-signal-cli-address)
|
||||||
|
(defvar noise-account)
|
||||||
|
|
||||||
|
(ert-deftest noise-sse--parse-event-basic ()
|
||||||
|
"Parse a basic SSE event with data field."
|
||||||
|
(let ((event "data: {\"type\":\"message\",\"content\":\"hello\"}\n\n"))
|
||||||
|
(let ((result (noise-sse--parse-event event)))
|
||||||
|
(should (hash-table-p result))
|
||||||
|
(should (equal (gethash "type" result) "message"))
|
||||||
|
(should (equal (gethash "content" result) "hello")))))
|
||||||
|
|
||||||
|
(ert-deftest noise-sse--parse-event-multiline ()
|
||||||
|
"Parse SSE event with multiline data."
|
||||||
|
(let ((event "data: {\"type\":\"message\",\ndata: \"content\":\"hello\"}\n\n"))
|
||||||
|
(let ((result (noise-sse--parse-event event)))
|
||||||
|
(should (hash-table-p result))
|
||||||
|
(should (equal (gethash "type" result) "message")))))
|
||||||
|
|
||||||
|
(ert-deftest noise-sse--parse-event-empty ()
|
||||||
|
"Parse empty SSE event returns nil."
|
||||||
|
(let ((event "\n\n"))
|
||||||
|
(should (null (noise-sse--parse-event event)))))
|
||||||
|
|
||||||
|
(ert-deftest noise-sse--initial-state ()
|
||||||
|
"SSE client starts in disconnected state."
|
||||||
|
(let ((noise-sse--process nil)
|
||||||
|
(noise-sse--reconnect-timer nil)
|
||||||
|
(noise-sse--reconnect-delay 1))
|
||||||
|
(should (null noise-sse--process))
|
||||||
|
(should (null noise-sse--reconnect-timer))
|
||||||
|
(should (= 1 noise-sse--reconnect-delay))))
|
||||||
|
|
||||||
|
(ert-deftest noise-sse--reconnect-delay-backoff ()
|
||||||
|
"Reconnect delay doubles on each attempt."
|
||||||
|
(let ((noise-sse--reconnect-delay 1))
|
||||||
|
(noise-sse--increase-reconnect-delay)
|
||||||
|
(should (= 2 noise-sse--reconnect-delay))
|
||||||
|
(noise-sse--increase-reconnect-delay)
|
||||||
|
(should (= 4 noise-sse--reconnect-delay))
|
||||||
|
(noise-sse--increase-reconnect-delay)
|
||||||
|
(should (= 8 noise-sse--reconnect-delay))))
|
||||||
|
|
||||||
|
(ert-deftest noise-sse--reconnect-delay-max ()
|
||||||
|
"Reconnect delay caps at max value."
|
||||||
|
(let ((noise-sse--reconnect-delay 32)
|
||||||
|
(noise-sse-max-reconnect-delay 60))
|
||||||
|
(noise-sse--increase-reconnect-delay)
|
||||||
|
(should (= 60 noise-sse--reconnect-delay))))
|
||||||
|
|
||||||
|
(ert-deftest noise-sse--filter-processes-complete-event ()
|
||||||
|
"Process filter parses and calls handler for complete events."
|
||||||
|
(let ((processed-events '())
|
||||||
|
(noise-sse--buffer "")
|
||||||
|
(noise-sse--http-buffer "")
|
||||||
|
(noise-sse--chunk-buffer "")
|
||||||
|
(noise-sse--headers-received t)
|
||||||
|
(noise-sse--chunked-response-p nil))
|
||||||
|
(let ((noise-sse--event-handler
|
||||||
|
(lambda (event) (push event processed-events))))
|
||||||
|
(noise-sse--filter nil "data: {\"type\":\"message\"}\n\n")
|
||||||
|
(should (= 1 (length processed-events)))
|
||||||
|
(should (hash-table-p (car processed-events))))))
|
||||||
|
|
||||||
|
(ert-deftest noise-sse--filter-buffers-incomplete-event ()
|
||||||
|
"Process filter buffers incomplete events until newline."
|
||||||
|
(let ((processed-events '())
|
||||||
|
(noise-sse--buffer "")
|
||||||
|
(noise-sse--http-buffer "")
|
||||||
|
(noise-sse--chunk-buffer "")
|
||||||
|
(noise-sse--headers-received t)
|
||||||
|
(noise-sse--chunked-response-p nil))
|
||||||
|
(let ((noise-sse--event-handler
|
||||||
|
(lambda (event) (push event processed-events))))
|
||||||
|
(noise-sse--filter nil "data: {\"type\":\"message\"")
|
||||||
|
(should (= 0 (length processed-events)))
|
||||||
|
(noise-sse--filter nil "}\n\n")
|
||||||
|
(should (= 1 (length processed-events))))))
|
||||||
|
|
||||||
|
(ert-deftest noise-sse--filter-skips-http-headers ()
|
||||||
|
"HTTP response headers should not block the first SSE event."
|
||||||
|
(let ((processed-events '())
|
||||||
|
(noise-sse--buffer "")
|
||||||
|
(noise-sse--http-buffer "")
|
||||||
|
(noise-sse--chunk-buffer "")
|
||||||
|
(noise-sse--headers-received nil)
|
||||||
|
(noise-sse--chunked-response-p nil))
|
||||||
|
(let ((noise-sse--event-handler
|
||||||
|
(lambda (event) (push event processed-events))))
|
||||||
|
(noise-sse--filter nil
|
||||||
|
(concat "HTTP/1.1 200 OK\r\n"
|
||||||
|
"Content-Type: text/event-stream\r\n\r\n"
|
||||||
|
"data: {\"type\":\"message\"}\n\n"))
|
||||||
|
(should (= 1 (length processed-events)))
|
||||||
|
(should (equal "message" (gethash "type" (car processed-events)))))))
|
||||||
|
|
||||||
|
(ert-deftest noise-sse--filter-decodes-chunked-http-body ()
|
||||||
|
"Chunked HTTP SSE responses should be decoded before parsing events."
|
||||||
|
(let ((processed-events '())
|
||||||
|
(noise-sse--buffer "")
|
||||||
|
(noise-sse--http-buffer "")
|
||||||
|
(noise-sse--chunk-buffer "")
|
||||||
|
(noise-sse--headers-received nil)
|
||||||
|
(noise-sse--chunked-response-p nil))
|
||||||
|
(let ((noise-sse--event-handler
|
||||||
|
(lambda (event) (push event processed-events))))
|
||||||
|
(noise-sse--filter nil
|
||||||
|
(concat "HTTP/1.1 200 OK\r\n"
|
||||||
|
"Transfer-Encoding: chunked\r\n"
|
||||||
|
"Content-Type: text/event-stream\r\n\r\n"
|
||||||
|
"12\r\n"
|
||||||
|
"data: {\"type\":\"msg"
|
||||||
|
"\r\n"
|
||||||
|
"4\r\n"
|
||||||
|
"\"}\n\n"
|
||||||
|
"\r\n"
|
||||||
|
"0\r\n\r\n"))
|
||||||
|
(should (= 1 (length processed-events)))
|
||||||
|
(should (equal "msg" (gethash "type" (car processed-events)))))))
|
||||||
|
|
||||||
|
(ert-deftest noise-sse--build-url ()
|
||||||
|
"Build SSE endpoint URL from base address."
|
||||||
|
(let ((noise-signal-cli-address "http://localhost:8080")
|
||||||
|
(noise-account "+15551234567"))
|
||||||
|
(let ((url (noise-sse--build-url)))
|
||||||
|
(should (string-match-p "/api/v1/events" url))
|
||||||
|
(should (string-match-p "account=%2B15551234567" url)))))
|
||||||
|
|
||||||
|
(ert-deftest noise-sse--build-url-no-account ()
|
||||||
|
"Build SSE endpoint URL without account parameter."
|
||||||
|
(let ((noise-signal-cli-address "http://localhost:8080")
|
||||||
|
(noise-account nil))
|
||||||
|
(let ((url (noise-sse--build-url)))
|
||||||
|
(should (string-match-p "/api/v1/events" url))
|
||||||
|
(should-not (string-match-p "account=" url)))))
|
||||||
|
|
||||||
|
(ert-deftest noise-sse-connect-is-interactive ()
|
||||||
|
"noise-sse-connect is an interactive command."
|
||||||
|
(should (commandp 'noise-sse-connect)))
|
||||||
|
|
||||||
|
(ert-deftest noise-sse-disconnect-is-interactive ()
|
||||||
|
"noise-sse-disconnect is an interactive command."
|
||||||
|
(should (commandp 'noise-sse-disconnect)))
|
||||||
|
|
||||||
|
(ert-deftest noise-sse-disconnect-cleans-up ()
|
||||||
|
"noise-sse-disconnect cleans up process and timer."
|
||||||
|
(let ((noise-sse--reconnect-timer (run-with-timer 1000 nil #'ignore)))
|
||||||
|
(noise-sse-disconnect)
|
||||||
|
(should (null noise-sse--process))
|
||||||
|
(should (null noise-sse--reconnect-timer))))
|
||||||
|
|
||||||
|
(provide 'noise-sse-test)
|
||||||
|
;;; noise-sse-test.el ends here
|
||||||
Reference in New Issue
Block a user