diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | docs/design/signal-client.org | 88 | ||||
| -rw-r--r-- | init.el | 1 | ||||
| -rw-r--r-- | modules/signal-config.el | 131 | ||||
| -rw-r--r-- | tests/test-signal-config.el | 141 |
5 files changed, 362 insertions, 0 deletions
@@ -52,6 +52,7 @@ auto-save-list/ /multisession/ /browser-choice.el /calendar-sync.local.el +/signal-config.local.el /client_secret_491339091045-sjje1r54s22vn2ugh45khndjafp89vto.apps.googleusercontent.com.json # reveal.js local clone (managed by scripts/setup-reveal.sh) diff --git a/docs/design/signal-client.org b/docs/design/signal-client.org new file mode 100644 index 00000000..c115c027 --- /dev/null +++ b/docs/design/signal-client.org @@ -0,0 +1,88 @@ +#+TITLE: Design: Signal client in Emacs (forked signel) +#+DATE: 2026-05-26 +#+STATUS: Draft + +* Problem +I want a Signal chat client inside Emacs: link it as a secondary device to my phone, pick a contact from my contact list, hold a text 1:1 conversation (read and send), and get a desktop notification on incoming messages, with an optional sound. Signal has no official API, so this is built on =signal-cli=, the mature headless CLI, driven over JSON-RPC. + +* Non-Goals +- Groups, attachments, stickers, reactions, read receipts, typing indicators in the first version (text 1:1 only). The fork base already supports several of these, so they are deferred, not forbidden. +- Replacing the phone as primary. This is a *linked secondary device*, like Signal Desktop. +- Registering a phone number standalone. +- Notifying for the conversation I'm actively viewing. + +* Assumptions +- *Researched fact:* signal-cli (AsamK) is mature, headless, and exposes JSON-RPC; it runs as =signal-cli -a ACCOUNT jsonRpc=. Source: https://github.com/AsamK/signal-cli +- *Researched fact:* signel (keenban) is GPL-3, single-file (642 lines), on MELPA, and already implements the signal-cli JSON-RPC process loop, a read-only chat buffer with guarded prompt, send, sync handling, media rendering, and an active-chats dashboard. Source: https://github.com/keenban/signel +- *Researched fact:* signel is stale — all 40 commits in a ~10-day burst in Jan 2026, nothing since 2026-02-04, an unattended issue tracker (5 open, filed Mar 2026, none answered), no PRs ever, ~26 stars. Highest-severity open bug is #2 (incoming messages clobber in-progress input); no crashes, no signal-cli incompatibility, no data loss. So: fork-and-own, not depend-and-track. +- *Researched fact:* signal-cli is AUR-only on Arch (=yay -S signal-cli=), needs a JRE (OpenJDK 17+, satisfied by the installed OpenJDK 26). +- *Assumption (to confirm before building):* signal-cli must be updated roughly every three months or Signal-Server rejects the client version. (Widely reported; confirm cadence against the project's NEWS when it bites.) +- *Assumption:* signal-cli =listContacts= returns the contact list in a shape usable for a completing-read picker. Confirm against a live linked account. + +* Approaches Considered + +** Recommended: fork signel into ~/code/signel and own it +Clone is already at =~/code/signel=. Wire via =use-package :load-path= like the org-drill and auto-dim-other-buffers forks. The clean 642-line core handles the hard plumbing; layer three focused changes plus integration on top. +- Pros: full control over the exact spec (contact picker, notify-when-not-viewing, sound toggle) in cj/ idioms; the hard JSON-RPC/receive/buffer/media work is already done; upstream is dead-quiet so there is no divergence cost to forking. +- Cons: own the maintenance (the signal-cli update treadmill, reconnect/resync) and signel's existing bugs. + +** Rejected: install signel from MELPA + advice the internals +=(use-package signel :ensure t)=, add the contact picker and link command as additive config, advice =signel--handle-receive= for the notify behavior. +- Why not: the notification change and the #2 input-clobber fix are internal edits; advising them is fragile and ugly. With upstream dead, forking loses nothing and keeps those edits clean. + +** Rejected: custom Emacs client from scratch on signal-cli +- Why not: rewrites the JSON-RPC loop, buffer management, and media that signel already does cleanly. "Read signel as reference then retype it" is forking with extra steps. + +** Rejected: signal-cli-rest-api (Docker) +- Why not: a Docker dependency for a personal Emacs feature is heavy; two moving parts instead of one daemon. + +** Rejected (tail): Signal-as-MCP-tool via gptel +- Why not: agent-mediated messaging, not a chat client; undershoots "pick a contact and chat"; foxl-ai MCP server is v0.1.1 and unproven. + +** Rejected (tail): bridge to ERC via a Signal↔IRC gateway +- Why not: a second daemon plus a bridge to keep alive; double the breakage surface; bridge maturity unverified. + +** Rejected (tail): org-backed (receive-hook writes per-contact org) +- Why not: org is not a live chat surface; reframes the picked option into note-taking. + +* Design + +** Fork integration +- Fork lives at =~/code/signel= (already cloned). New module =modules/signal-config.el= wires it with =use-package signel :load-path "~/code/signel" :ensure nil=, mirroring the org-drill and auto-dim forks. +- Keybindings under a dedicated prefix (candidate =C-; M= for Messages, since =C-; S= is Slack). Commands: start/link, contact picker, dashboard, toggle sound. +- =signel-account= set from a defcustom or authinfo, not hardcoded. + +** Three changes on top of the fork +1. *Contact picker.* New command =cj/signel-pick-contact= (or rename signel's =signel-chat=): call signal-cli =listContacts= over JSON-RPC, cache name→number, present a =completing-read= of names, open the chat buffer for the chosen contact. signel today opens by raw phone number and only lists chats that already received a message. +2. *Linking / auth.* New command =cj/signel-link= wrapping =signal-cli link -n "Emacs"=, capturing the =tsdevice:= URI and rendering it as a scannable QR (via =qrencode= to an image buffer, or a CLI QR) so the phone's Linked Devices can scan it. signel assumes an already-linked account. +3. *Notification behavior.* Edit =signel--handle-receive='s notify block: (a) suppress the notification when the message's chat buffer is the selected window's buffer (actively viewing); (b) route through Craig's =notify= script instead of bare =notifications-notify=; (c) sound off by default behind a defcustom toggle (=cj/signel-notify-sound=, default nil). + +** Folded-in upstream fix +- Issue #2 (incoming messages clobber in-progress input): the redraw in =signel--insert-msg= / =signel--draw-prompt= replaces the prompt region while the user may be mid-type. Preserve and restore any unsent input across the insert. Fix it in the fork since it sits right next to the notification edit. + +** Data flow +signal-cli (linked secondary device) ⇄ JSON-RPC over the subprocess stdio ⇄ signel process filter → dispatch → receive handler → chat buffer insert + notify. Send: chat-buffer prompt → =send= RPC. No persistence beyond what signal-cli stores; Emacs holds session state (contact cache, active chats) in memory. + +** Error handling +- signal-cli not installed / not linked → =user-error= with the remedy (install, or =cj/signel-link=). signel already guards the missing executable and unset account. +- RPC errors map to the originating chat buffer (signel already does this). +- Process death → sentinel logs; add a visible message and a restart hint. + +** Testing +- Pure helpers (contact-list parsing from a fixture JSON, the notify-suppression predicate given a buffer/window state, the input-preserve logic) get ERT unit tests with mocked signal-cli output — no live account needed. +- The live loop (link, receive, send, notify) is verified manually against a linked account (scripted manual checklist), since it needs the phone and a real signal-cli. + +** Observability +- signel already logs RPC traffic to =*signel-log*=. Keep it; it's the diagnostic surface for the update-treadmill breakages. + +* Open Questions +- [ ] Fork-vs-MELPA+advice is decided (fork). Record as an ADR via =arch-decide= if a formal record is wanted. +- [ ] Keybinding prefix: =C-; M= (Messages) vs another free chord — confirm against the existing =C-;= map. +- [ ] Account source: defcustom vs authinfo lookup (mirror the Slack token pattern in slack-config.el?). +- [ ] Whether to push the fork to cjennings.net as a tracked remote (like org-drill) or keep it a local checkout. + +* Next Steps +1. Install signal-cli: =yay -S signal-cli= (interactive, Craig). +2. Link as a secondary device (=cj/signel-link= once built, or =signal-cli link= directly) — scan the QR from the phone. +3. Implement on the fork against the live engine (TDD the pure helpers, manual-verify the live loop) via =/start-work=. +4. archsetup request to add signal-cli to the standard install — sent 2026-05-26. @@ -75,6 +75,7 @@ (require 'slack-config) ;; slack client via emacs-slack (require 'linear-config) ;; Linear.app issue tracking (deepsat workspace) (require 'telega-config) ;; telegram client via telega.el (TDLib in docker) +(require 'signal-config) ;; signal client via forked signel + signal-cli (require 'eshell-config) ;; emacs shell configuration (require 'vterm-config) ;; vterm + F12 toggle + tmux history copy (require 'ai-vterm) ;; in-Emacs Claude launcher (vertical-split vterm) diff --git a/modules/signal-config.el b/modules/signal-config.el new file mode 100644 index 00000000..0b6668b1 --- /dev/null +++ b/modules/signal-config.el @@ -0,0 +1,131 @@ +;;; signal-config.el --- Signal client (forked signel) configuration -*- lexical-binding: t -*- + +;;; Commentary: +;; cj/-namespaced configuration and helpers layered on the forked `signel' +;; package, a Signal client that drives signal-cli over JSON-RPC. +;; +;; This file currently holds the pure, signal-cli-independent helper layer +;; that the fork edits and `use-package' wiring build on: +;; - contact-list parsing for a completing-read contact picker, and +;; - the predicate that suppresses a notification for the chat the user +;; is actively viewing. +;; Both are unit-tested without a linked account. The use-package wiring, +;; keybindings, and the signel fork edits that call these helpers land once +;; signal-cli is installed and the device is linked. + +;;; Code: + +(require 'seq) + +(defun cj/signal--jstr (value) + "Return VALUE if it is a non-blank string, else nil. +Normalizes a JSON field that may arrive as nil, the empty string, or a +null sentinel symbol into a plain string-or-nil." + (and (stringp value) + (not (string-empty-p (string-trim value))) + value)) + +(defun cj/signal--combine-name (given family) + "Join GIVEN and FAMILY name parts into a trimmed full name, or nil. +Either part may be nil, the empty string, or a JSON null sentinel." + (let ((parts (delq nil (list (cj/signal--jstr given) (cj/signal--jstr family))))) + (cj/signal--jstr (mapconcat #'identity parts " ")))) + +(defun cj/signal--contact-display-name (contact) + "Return a display name for CONTACT, or nil when none is set. +CONTACT is one entry alist from signal-cli `listContacts'. Picks the +first set source in priority order: the nickname (combined nickName, or +nickGivenName+nickFamilyName), the stored contact name, the top-level +givenName+familyName, the profile givenName+familyName, then username. +signal-cli 0.14 puts givenName/familyName at the top level; the profile +sub-object's name fields are usually null, so it is the deeper fallback." + (let ((profile (alist-get 'profile contact))) + (seq-find + #'cj/signal--jstr + (list (cj/signal--jstr (alist-get 'nickName contact)) + (cj/signal--combine-name (alist-get 'nickGivenName contact) + (alist-get 'nickFamilyName contact)) + (cj/signal--jstr (alist-get 'name contact)) + (cj/signal--combine-name (alist-get 'givenName contact) + (alist-get 'familyName contact)) + (cj/signal--combine-name (alist-get 'givenName profile) + (alist-get 'familyName profile)) + (cj/signal--jstr (alist-get 'username contact)))))) + +(defun cj/signal--parse-contacts (result) + "Parse RESULT from signal-cli `listContacts' into a completing-read alist. +RESULT is the JSON-RPC result value: a sequence (list or vector) of +contact alists. Returns an alist of (LABEL . RECIPIENT) sorted by LABEL, +where RECIPIENT is the contact's phone number (falling back to its UUID) +and LABEL is \"Name (recipient)\" when a name is known, or the bare +recipient otherwise. Contacts with no usable recipient are dropped." + (let (pairs) + (dolist (contact (append result nil)) + (let ((recipient (or (cj/signal--jstr (alist-get 'number contact)) + (cj/signal--jstr (alist-get 'uuid contact)))) + (name (cj/signal--contact-display-name contact))) + (when recipient + (push (cons (if name (format "%s (%s)" name recipient) recipient) + recipient) + pairs)))) + (sort pairs (lambda (a b) (string-lessp (car a) (car b)))))) + +(defun cj/signal--chat-buffer-name (id) + "Return the chat buffer name `signel' uses for chat ID." + (format "*Signel: %s*" id)) + +(defun cj/signal--suppress-notify-p (chat-id viewing-buffer-name frame-focused) + "Return non-nil when a notification for CHAT-ID should be suppressed. +Suppress only while the user is actively viewing that chat: the chat +buffer named by `cj/signal--chat-buffer-name' is VIEWING-BUFFER-NAME and +FRAME-FOCUSED is non-nil. A nil VIEWING-BUFFER-NAME or an unfocused +frame never suppresses." + (and frame-focused + (stringp viewing-buffer-name) + (string= viewing-buffer-name (cj/signal--chat-buffer-name chat-id)))) + +(defun cj/signal--frame-focused-p () + "Return non-nil when the selected frame currently has input focus. +Treats an unknown focus state as focused." + (if (fboundp 'frame-focus-state) + (let ((state (frame-focus-state))) + (if (eq state 'unknown) t state)) + t)) + +(defun cj/signal--should-notify-p (chat-id) + "Return non-nil when an incoming message for CHAT-ID should notify. +Notify unless the user is actively viewing that chat in the selected +window of a focused frame." + (not (cj/signal--suppress-notify-p + chat-id + (buffer-name (window-buffer (selected-window))) + (cj/signal--frame-focused-p)))) + +;;; signel — fork integration + +(defcustom cj/signal-private-config-file + (expand-file-name "signal-config.local.el" user-emacs-directory) + "Private signal-config file, loaded when readable. +This is the place to set `signel-account' to the linked phone number so +the number stays out of the version-controlled (and publicly mirrored) +config. A phone number is an identifier rather than a credential, so it +lives here rather than in authinfo, which avoids a GPG prompt at connect +time." + :type 'file + :group 'signel) + +(use-package signel + :load-path "~/code/signel" + :ensure nil + :commands (signel-start signel-stop signel-chat signel-dashboard) + :custom + ;; Don't let an incoming message steal a window by auto-popping its chat + ;; buffer; surface arrivals through notifications instead (see child task + ;; "Notify only for the unviewed conversation"). + (signel-auto-open-buffer nil) + :config + (when (file-readable-p cj/signal-private-config-file) + (load cj/signal-private-config-file nil t))) + +(provide 'signal-config) +;;; signal-config.el ends here diff --git a/tests/test-signal-config.el b/tests/test-signal-config.el new file mode 100644 index 00000000..40879342 --- /dev/null +++ b/tests/test-signal-config.el @@ -0,0 +1,141 @@ +;;; test-signal-config.el --- Tests for signal-config -*- lexical-binding: t -*- + +;;; Commentary: +;; ERT tests for the pure helper layer of `signal-config': contact-list +;; parsing for the contact picker, and the notify-when-not-viewing +;; predicate. These need neither signal-cli nor a linked account. + +;;; Code: + +(require 'ert) +(require 'json) +(require 'signal-config) + +;;; cj/signal--jstr + +(ert-deftest test-signal-config-jstr-string () + "Normal: a non-blank string passes through unchanged." + (should (equal (cj/signal--jstr "hi") "hi"))) + +(ert-deftest test-signal-config-jstr-rejects-nonstrings () + "Boundary/Error: nil, empty, whitespace, and non-string sentinels become nil." + (should-not (cj/signal--jstr nil)) + (should-not (cj/signal--jstr "")) + (should-not (cj/signal--jstr " ")) + (should-not (cj/signal--jstr :null)) + (should-not (cj/signal--jstr 42))) + +;;; cj/signal--contact-display-name +;; Field priority: nickName, then nickGiven+nickFamily, then top-level name, +;; then top-level given+family, then profile given+family, then username. + +(ert-deftest test-signal-config-display-name-prefers-name () + "Normal: the top-level combined name wins over the given/family parts." + (should (equal (cj/signal--contact-display-name + '((name . "Alice Anderson") (givenName . "Ali") (familyName . "A"))) + "Alice Anderson"))) + +(ert-deftest test-signal-config-display-name-nickname-wins () + "Normal: a nickName overrides the contact name." + (should (equal (cj/signal--contact-display-name + '((nickName . "Edster") (name . "Eve Edwards") + (givenName . "Eve") (familyName . "Edwards"))) + "Edster"))) + +(ert-deftest test-signal-config-display-name-nickname-parts () + "Boundary: nickGivenName+nickFamilyName combine when nickName is unset." + (should (equal (cj/signal--contact-display-name + '((nickGivenName . "DJ") (nickFamilyName . "Cool") (name . "Daniel"))) + "DJ Cool"))) + +(ert-deftest test-signal-config-display-name-toplevel-given-family () + "Boundary: with no name, top-level givenName+familyName combine." + (should (equal (cj/signal--contact-display-name + '((name) (givenName . "Bob") (familyName . "Brown"))) + "Bob Brown"))) + +(ert-deftest test-signal-config-display-name-profile-fallback () + "Boundary: with no name or top-level parts, profile given/family is the fallback." + (should (equal (cj/signal--contact-display-name + '((name) (givenName) (familyName) + (profile . ((givenName . "Carol") (familyName))))) + "Carol"))) + +(ert-deftest test-signal-config-display-name-username-fallback () + "Boundary: username is the last name source." + (should (equal (cj/signal--contact-display-name + '((name) (username . "dave.42"))) + "dave.42"))) + +(ert-deftest test-signal-config-display-name-none () + "Error: no usable name yields nil." + (should-not (cj/signal--contact-display-name + '((name) (givenName) (familyName) + (profile . ((givenName) (familyName)))))) + (should-not (cj/signal--contact-display-name '((number . "+15551112222"))))) + +;;; cj/signal--parse-contacts + +(defconst test-signal-config--contacts-json + "[ + {\"number\":\"+15551112222\",\"uuid\":\"uuid-a\",\"name\":\"Alice Anderson\",\"givenName\":\"Alice\",\"familyName\":\"Anderson\",\"nickName\":null,\"nickGivenName\":null,\"nickFamilyName\":null,\"username\":null,\"profile\":{\"givenName\":null,\"familyName\":null}}, + {\"number\":\"+15553334444\",\"uuid\":null,\"name\":null,\"givenName\":\"Bob\",\"familyName\":\"Brown\",\"nickName\":null,\"username\":null,\"profile\":{\"givenName\":null,\"familyName\":null}}, + {\"number\":\"+15555556666\",\"uuid\":\"uuid-c\",\"name\":null,\"givenName\":null,\"familyName\":null,\"nickName\":null,\"username\":null,\"profile\":{\"givenName\":\"Carol\",\"familyName\":null}}, + {\"number\":null,\"uuid\":\"uuid-d\",\"name\":null,\"givenName\":null,\"familyName\":null,\"username\":null,\"profile\":{\"givenName\":null,\"familyName\":null}}, + {\"number\":\"+15557778888\",\"uuid\":\"uuid-e\",\"name\":\"Eve Edwards\",\"givenName\":\"Eve\",\"familyName\":\"Edwards\",\"nickName\":\"Edster\",\"username\":null,\"profile\":{\"givenName\":null,\"familyName\":null}} +]" + "Synthetic fixture mirroring the signal-cli 0.14.4.1 `listContacts' shape: +top-level name/givenName/familyName and nickName fields, with a profile +sub-object whose name fields are usually null. Field layout was confirmed +against a live linked account on 2026-05-26; the values here are fake.") + +(ert-deftest test-signal-config-parse-contacts-normal () + "Normal: top-level name, top-level parts, profile fallback, uuid fallback, nickname." + (let* ((result (json-read-from-string test-signal-config--contacts-json)) + (pairs (cj/signal--parse-contacts result))) + (should (equal pairs + '(("Alice Anderson (+15551112222)" . "+15551112222") + ("Bob Brown (+15553334444)" . "+15553334444") + ("Carol (+15555556666)" . "+15555556666") + ("Edster (+15557778888)" . "+15557778888") + ("uuid-d" . "uuid-d")))))) + +(ert-deftest test-signal-config-parse-contacts-empty () + "Boundary: an empty result yields nil for both vector and nil input." + (should-not (cj/signal--parse-contacts [])) + (should-not (cj/signal--parse-contacts nil))) + +(ert-deftest test-signal-config-parse-contacts-accepts-list-and-vector () + "Boundary: vector and list result sequences parse identically." + (let ((entry '((number . "+15551112222") (name . "Al")))) + (should (equal (cj/signal--parse-contacts (vector entry)) + (cj/signal--parse-contacts (list entry)))))) + +(ert-deftest test-signal-config-parse-contacts-drops-recipientless () + "Error: a contact with neither number nor uuid is dropped." + (should-not (cj/signal--parse-contacts + (list '((name . "Ghost") (number) (uuid)))))) + +;;; cj/signal--suppress-notify-p + +(ert-deftest test-signal-config-suppress-when-viewing-focused () + "Normal: viewing the chat buffer with focus suppresses the notification." + (should (cj/signal--suppress-notify-p + "+15551112222" "*Signel: +15551112222*" t))) + +(ert-deftest test-signal-config-no-suppress-other-buffer () + "Boundary: a different selected buffer does not suppress." + (should-not (cj/signal--suppress-notify-p + "+15551112222" "*scratch*" t))) + +(ert-deftest test-signal-config-no-suppress-unfocused () + "Boundary: viewing the chat but with the frame unfocused still notifies." + (should-not (cj/signal--suppress-notify-p + "+15551112222" "*Signel: +15551112222*" nil))) + +(ert-deftest test-signal-config-no-suppress-nil-viewing () + "Error: a nil viewing-buffer name does not suppress." + (should-not (cj/signal--suppress-notify-p "+15551112222" nil t))) + +(provide 'test-signal-config) +;;; test-signal-config.el ends here |
