aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--docs/design/signal-client.org88
-rw-r--r--init.el1
-rw-r--r--modules/signal-config.el131
-rw-r--r--tests/test-signal-config.el141
5 files changed, 362 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
index 14f1d7f1..5aff18d0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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.
diff --git a/init.el b/init.el
index 27f4adb9..fe1acd35 100644
--- a/init.el
+++ b/init.el
@@ -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