Files
noise/noise-receive.el
T
2026-06-10 12:43:24 -04:00

225 lines
9.1 KiB
EmacsLisp

;;; noise-receive.el --- Receive messages from signal-cli -*- lexical-binding: t; -*-
;;; Commentary:
;; Fetches new envelopes from signal-cli via the receive JSON-RPC method
;; and stores them in the local database: incoming messages, messages
;; sent from other linked devices (sync), delivery/read receipts, and
;; typing indicators. `noise-receive-start-polling' polls on a timer.
;;; Code:
(require 'noise-rpc)
(require 'noise-db)
(require 'noise-conversation)
(require 'cl-lib)
(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
(defun noise-receive ()
"Fetch and store new envelopes from signal-cli.
Returns the number of new messages stored."
(interactive)
(noise-db-init)
(let ((entries (noise-rpc-call-for-account "receive"))
(new 0)
(touched nil))
(seq-do
(lambda (entry)
(when-let ((envelope (noise-receive--field entry "envelope")))
(when-let ((conv-id (noise-receive--process-envelope envelope)))
(cl-incf new)
(cl-pushnew conv-id touched :test #'equal))))
entries)
(when touched
(dolist (conv-id touched)
(noise-conversation-refresh-for conv-id))
(when-let ((buf (get-buffer "*signal*")))
(with-current-buffer buf
(noise-chat-list--render))))
(when (called-interactively-p 'interactive)
(message "Received %d new message%s" new (if (= new 1) "" "s")))
new))
;;;###autoload
(defun noise-receive-start-polling ()
"Start polling signal-cli for new messages every `noise-receive-interval'."
(interactive)
(noise-receive-stop-polling)
(setq noise-receive--timer
(run-with-timer 0 noise-receive-interval #'noise-receive--poll)))
(defun noise-receive-stop-polling ()
"Stop the message polling timer."
(interactive)
(when noise-receive--timer
(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
(defun noise-receive--field (obj key)
"Get KEY from hash table OBJ, treating JSON null as nil."
(when (hash-table-p obj)
(let ((v (gethash key obj)))
(unless (eq v :null) v))))
(defun noise-receive--process-envelope (envelope)
"Process one ENVELOPE from signal-cli.
Returns the conversation ID when a new message was stored, else nil."
(let* ((sender-id (or (noise-receive--field envelope "sourceNumber")
(noise-receive--field envelope "source")
(noise-receive--field envelope "sourceUuid")))
(sender-name (or (noise-receive--field envelope "sourceName")
sender-id)))
(cond
((noise-receive--field envelope "dataMessage")
(noise-receive--store-incoming
(noise-receive--field envelope "dataMessage") sender-id sender-name))
((noise-receive--field envelope "syncMessage")
(noise-receive--store-sync
(noise-receive--field envelope "syncMessage")))
((noise-receive--field envelope "receiptMessage")
(noise-receive--apply-receipt
(noise-receive--field envelope "receiptMessage") sender-id)
nil)
((noise-receive--field envelope "typingMessage")
(noise-receive--apply-typing
(noise-receive--field envelope "typingMessage") sender-id sender-name)
nil))))
(defun noise-receive--timestamp (ms)
"Convert a signal-cli millisecond timestamp MS to float seconds."
(/ ms 1000.0))
(defun noise-receive--attachment-name (data-message)
"Return the filename of the first attachment in DATA-MESSAGE, or nil."
(let ((attachments (noise-receive--field data-message "attachments")))
(when (and attachments (> (length attachments) 0))
(let ((a (elt attachments 0)))
(or (noise-receive--field a "filename")
(noise-receive--field a "id"))))))
(defun noise-receive--store-incoming (dm sender-id sender-name)
"Store an incoming data message DM from SENDER-ID / SENDER-NAME.
Returns the conversation ID, or nil if there was nothing to store."
(let* ((body (or (noise-receive--field dm "message") ""))
(group (noise-receive--field dm "groupInfo"))
(group-id (and group (noise-receive--field group "groupId")))
(attachment (noise-receive--attachment-name dm))
(conv-id (or group-id sender-id))
(ts (noise-receive--timestamp
(noise-receive--field dm "timestamp"))))
(when (and conv-id (or (not (string-empty-p body)) attachment))
(when sender-id
(noise-db-upsert-contact sender-id :name sender-name))
(unless (noise-db-get-conversation conv-id)
(noise-db-upsert-conversation
conv-id
:type (if group-id "group" "direct")
:name (if group-id
(or (noise-receive--field group "groupName") "")
sender-name)
:members (and sender-id (list sender-id))))
(noise-db-insert-message conv-id
:timestamp ts
:source sender-name
:body body
:has-attachment (and attachment t)
:attachment-path (or attachment ""))
;; Direct chats show the preview without a sender prefix (the chat
;; list only prefixes group senders and "me").
(unless group-id
(noise-db-set-last-message-sender conv-id ""))
(when (and attachment (string-empty-p body))
(noise-db-upsert-conversation
conv-id :last-message-preview (format "photo: %s" attachment)))
(let ((buf (noise-conversation--buffer-for conv-id)))
(if (and buf (get-buffer-window buf t))
(noise-db-mark-conversation-read conv-id)
(noise-db-increment-unread conv-id)))
conv-id)))
(defun noise-receive--store-sync (sync)
"Store a message sent from another linked device, found in SYNC.
Returns the conversation ID, or nil."
(when-let ((sent (noise-receive--field sync "sentMessage")))
(let* ((body (or (noise-receive--field sent "message") ""))
(group (noise-receive--field sent "groupInfo"))
(group-id (and group (noise-receive--field group "groupId")))
(dest (or (noise-receive--field sent "destinationNumber")
(noise-receive--field sent "destination")
(noise-receive--field sent "destinationUuid")))
(conv-id (or group-id dest))
(attachment (noise-receive--attachment-name sent))
(ts (noise-receive--timestamp
(noise-receive--field sent "timestamp"))))
(when (and conv-id (or (not (string-empty-p body)) attachment))
(unless (noise-db-get-conversation conv-id)
(noise-db-upsert-conversation
conv-id
:type (if group-id "group" "direct")
:name (if group-id "" (or dest ""))
:members (and dest (not group-id) (list dest))))
(noise-db-insert-message conv-id
:timestamp ts
:source "me"
:body body
:receipt-state "sent"
:has-attachment (and attachment t)
:attachment-path (or attachment ""))
conv-id))))
(defun noise-receive--apply-receipt (rm sender-id)
"Apply receipt message RM from SENDER-ID to stored messages."
(let ((state (cond ((eq (noise-receive--field rm "isRead") t) "read")
((eq (noise-receive--field rm "isDelivery") t) "delivered")))
(timestamps (noise-receive--field rm "timestamps")))
(when (and state timestamps)
(seq-do (lambda (ts-ms)
(noise-db-update-receipt-state
(noise-receive--timestamp ts-ms) state))
timestamps)
(when sender-id
(noise-conversation-refresh-for sender-id)))))
(defun noise-receive--apply-typing (tm sender-id sender-name)
"Apply typing message TM from SENDER-ID / SENDER-NAME."
(let ((conv-id (or (noise-receive--field tm "groupId") sender-id))
(action (noise-receive--field tm "action")))
(when conv-id
(noise-conversation-set-typing conv-id sender-name action))))
(provide 'noise-receive)
;;; noise-receive.el ends here