aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/telega-config.el112
-rw-r--r--tests/test-telega-config--docker-pin.el93
-rw-r--r--tests/test-telega-config--server-death.el131
3 files changed, 336 insertions, 0 deletions
diff --git a/modules/telega-config.el b/modules/telega-config.el
index acc9e482..5a20d430 100644
--- a/modules/telega-config.el
+++ b/modules/telega-config.el
@@ -48,6 +48,7 @@
;;; Code:
(require 'keybindings)
+(require 'system-lib) ; cj/log-silently, used by the death alert
(use-package telega
:defer t
@@ -63,6 +64,117 @@
;; routing through a shared messenger notifier is the unification task.
(telega-notifications-mode 1))
+;; --------------------------- telega Docker Image Pin -------------------------
+;; telega picks its container image in `telega-docker--image-name', which only
+;; pins to a version tag when `telega-tdlib-min-version' equals
+;; `telega-tdlib-max-version' and the version ends in ".0". Here min is
+;; "1.8.64" and max is nil, so that test never passes and the image is always
+;; "zevlg/telega-server:latest" -- a floating tag. The elpa package is fixed
+;; at whatever version was installed, so the server can be replaced underneath
+;; a static elisp without anything announcing it.
+;;
+;; Pinning by digest names one immutable image. Set to nil to hand the choice
+;; back to telega.
+
+(defcustom cj/telega-docker-image
+ "zevlg/telega-server@sha256:a4b88e029ba381eca7c37c9618c9e3ad73aa9db2097fe07a0c6684d40d32b84e"
+ "Container image reference for `telega-server', or nil for telega's default.
+Pin by digest rather than tag: a tag can be re-pushed upstream, a digest
+cannot. The default is the image carrying libtdjson 1.8.64, which matches
+this telega's `telega-tdlib-min-version'."
+ :type '(choice (const :tag "Let telega infer the image" nil)
+ (string :tag "Image reference"))
+ :group 'telega-docker)
+
+(defun cj/--telega-docker-pinned-image ()
+ "Return the configured image pin, or nil when none is usable.
+A blank or non-string setting yields nil rather than reaching the docker
+command line, where it would fail in a way that looks unrelated to this."
+ (when (stringp cj/telega-docker-image)
+ (let ((pin (string-trim cj/telega-docker-image)))
+ (unless (string-empty-p pin) pin))))
+
+(defun cj/--telega-docker-image-name (orig-fun &rest args)
+ "Return the pinned telega-server image, else call ORIG-FUN with ARGS.
+`:around' advice on `telega-docker--image-name', so clearing the pin
+restores telega's own inference instead of breaking the image name."
+ (or (cj/--telega-docker-pinned-image)
+ (apply orig-fun args)))
+
+(with-eval-after-load 'telega-util
+ (advice-add 'telega-docker--image-name :around #'cj/--telega-docker-image-name))
+
+;; ------------------------- telega-server Death Alert -------------------------
+;; telega's own sentinel reports an abnormal server exit with `message', which
+;; scrolls out of the echo area unseen. That is how a dead server reads as a
+;; quiet Telegram: the scan stops partway and the unscanned chats look like
+;; chats with nothing in them. It has happened twice (2026-07-10, 2026-07-27),
+;; caught both times only because something downstream noticed. A desktop
+;; notification makes the death itself visible.
+
+(declare-function notifications-notify "notifications")
+
+(defun cj/--telega-server-death-p (status)
+ "Return non-nil when exit STATUS means the server died abnormally.
+Any non-zero integer counts, which covers both a non-zero exit code and a
+fatal signal number (`process-exit-status' reports SIGSEGV as 11). A
+non-integer STATUS returns nil rather than signalling: this runs inside
+telega's sentinel, where an error would abort telega's own cleanup."
+ (and (integerp status)
+ (not (zerop status))))
+
+(defun cj/--telega-server-exit-status (proc)
+ "Return PROC's exit status, or nil when it can't be determined.
+Guarded because the sentinel hands over whatever process object it has,
+and a bad one must not break telega's status handling."
+ (condition-case nil
+ (and (processp proc) (process-exit-status proc))
+ (error nil)))
+
+(defun cj/--telega-server-death-body (status event)
+ "Build the notification body for a server death with STATUS and EVENT.
+EVENT is the sentinel's event string, which carries a trailing newline that
+would render as dead space in a desktop notification."
+ (let ((detail (string-trim (or event ""))))
+ (concat (format "telega-server died (status %s). " status)
+ (unless (string-empty-p detail) (concat detail ". "))
+ "Telegram coverage is down until it restarts.")))
+
+(defun cj/--telega-server-send-notification (title body)
+ "Deliver a desktop notification with TITLE and BODY.
+Prefers the external notify script (persistent, so it waits rather than
+auto-dismissing while away), falling back to `notifications-notify'.
+Mirrors `cj/slack--send-notification'."
+ (let ((script (executable-find "notify")))
+ (if script
+ (start-process "telega-death-notify" nil script "fail" title body "--persist")
+ (unless (fboundp 'notifications-notify)
+ (require 'notifications))
+ (notifications-notify :title title :body body))))
+
+(defun cj/--telega-server-notify-death (proc event)
+ "Notify when the telega-server PROC dies abnormally. EVENT is its event string.
+Installed as `:after' advice on `telega-server--sentinel'. Silent on a
+clean exit, so quitting telega deliberately never pages.
+
+The whole body is guarded: a notifier failure here would otherwise escape
+into telega's sentinel and abort its status handling and relogin path."
+ (condition-case err
+ (let ((status (cj/--telega-server-exit-status proc)))
+ (when (cj/--telega-server-death-p status)
+ (cj/--telega-server-send-notification
+ "Telegram: telega-server died"
+ (cj/--telega-server-death-body status event))))
+ (error
+ (cj/log-silently
+ (format "telega death notify failed: %s" (error-message-string err))))))
+
+;; Named, never a lambda: anonymous advice can't be `advice-remove'd by
+;; reference, so a live daemon keeps running it after the source stops
+;; installing it.
+(with-eval-after-load 'telega-server
+ (advice-add 'telega-server--sentinel :after #'cj/--telega-server-notify-death))
+
(defun cj/telega ()
"Launch telega.el with a helpful message when it isn't installed yet.
diff --git a/tests/test-telega-config--docker-pin.el b/tests/test-telega-config--docker-pin.el
new file mode 100644
index 00000000..894dbfdd
--- /dev/null
+++ b/tests/test-telega-config--docker-pin.el
@@ -0,0 +1,93 @@
+;;; test-telega-config--docker-pin.el --- Tests for the telega docker image pin -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for pinning the telega-server container image.
+;;
+;; telega infers its image from `telega-tdlib-min-version' and only pins to a
+;; version tag when min and max versions are equal and the version ends in
+;; ".0". This config has min "1.8.64" and max nil, so the inference always
+;; falls through to "zevlg/telega-server:latest" -- a moving tag that can
+;; swap the server out from under a fixed elisp version without notice.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'telega-config)
+
+;; -- cj/--telega-docker-pinned-image -----------------------------------------
+
+(ert-deftest test-telega-config-pin-returns-configured-reference ()
+ "Normal: a configured pin is returned verbatim."
+ (let ((cj/telega-docker-image "zevlg/telega-server@sha256:abc123"))
+ (should (equal (cj/--telega-docker-pinned-image)
+ "zevlg/telega-server@sha256:abc123"))))
+
+(ert-deftest test-telega-config-pin-nil-when-unset ()
+ "Boundary: no pin returns nil so telega's own inference stays in charge."
+ (let ((cj/telega-docker-image nil))
+ (should-not (cj/--telega-docker-pinned-image))))
+
+(ert-deftest test-telega-config-pin-nil-for-empty-string ()
+ "Boundary: an empty or whitespace pin is not a reference.
+An empty string would otherwise reach the docker command line as a blank
+image argument, which fails in a way that looks unrelated to this setting."
+ (let ((cj/telega-docker-image ""))
+ (should-not (cj/--telega-docker-pinned-image)))
+ (let ((cj/telega-docker-image " "))
+ (should-not (cj/--telega-docker-pinned-image))))
+
+(ert-deftest test-telega-config-pin-nil-for-non-string ()
+ "Error: a non-string pin is ignored rather than passed to the shell."
+ (let ((cj/telega-docker-image 'latest))
+ (should-not (cj/--telega-docker-pinned-image)))
+ (let ((cj/telega-docker-image 42))
+ (should-not (cj/--telega-docker-pinned-image))))
+
+(ert-deftest test-telega-config-pin-trims-surrounding-whitespace ()
+ "Boundary: a pin with stray whitespace is trimmed, not rejected.
+A trailing newline is easy to introduce when pasting a digest from docker."
+ (let ((cj/telega-docker-image " zevlg/telega-server@sha256:abc123\n"))
+ (should (equal (cj/--telega-docker-pinned-image)
+ "zevlg/telega-server@sha256:abc123"))))
+
+;; -- cj/--telega-docker-image-name (the advice) ------------------------------
+
+(ert-deftest test-telega-config-image-advice-prefers-the-pin ()
+ "Normal: with a pin set, the advice returns it instead of calling telega."
+ (let ((cj/telega-docker-image "zevlg/telega-server@sha256:abc123")
+ (called nil))
+ (should (equal (cj/--telega-docker-image-name
+ (lambda () (setq called t) "zevlg/telega-server:latest"))
+ "zevlg/telega-server@sha256:abc123"))
+ (should-not called)))
+
+(ert-deftest test-telega-config-image-advice-delegates-without-a-pin ()
+ "Boundary: with no pin, telega's own inference is used unchanged.
+Removing the pin must restore stock behavior rather than break the image
+name, so this stays a reversible setting."
+ (let ((cj/telega-docker-image nil))
+ (should (equal (cj/--telega-docker-image-name
+ (lambda () "zevlg/telega-server:latest"))
+ "zevlg/telega-server:latest"))))
+
+(ert-deftest test-telega-config-image-advice-is-named-and-removable ()
+ "Normal: the advice is a named function so it can be removed by reference."
+ (should (fboundp 'cj/--telega-docker-image-name)))
+
+(ert-deftest test-telega-config-pin-default-is-a-digest-reference ()
+ "Normal: the shipped default pins by digest, not by a floating tag.
+A tag pin (:latest, or even a version tag upstream can re-push) still
+moves; a digest names one immutable image.
+
+Reads the defcustom's standard value rather than the live variable, so
+customizing the pin (including to nil, handing the choice back to telega)
+is not a test failure -- only changing the shipped default is."
+ (let ((default (eval (car (get 'cj/telega-docker-image 'standard-value)) t)))
+ (should (stringp default))
+ (should (string-match-p "@sha256:[0-9a-f]\\{64\\}\\'" default))))
+
+(provide 'test-telega-config--docker-pin)
+;;; test-telega-config--docker-pin.el ends here
diff --git a/tests/test-telega-config--server-death.el b/tests/test-telega-config--server-death.el
new file mode 100644
index 00000000..1151fa7e
--- /dev/null
+++ b/tests/test-telega-config--server-death.el
@@ -0,0 +1,131 @@
+;;; test-telega-config--server-death.el --- Tests for the telega-server death alert -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the telega-server death sentinel. telega's own sentinel only
+;; calls `message' on an abnormal exit, which scrolls away unseen -- so a
+;; dead server reads as a quiet Telegram. These pin the alert that replaces
+;; that silence.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'telega-config)
+
+;; -- cj/--telega-server-death-p ----------------------------------------------
+
+(ert-deftest test-telega-config-death-p-nonzero-status-is-death ()
+ "Normal: a non-zero exit status is an abnormal death."
+ (should (cj/--telega-server-death-p 1))
+ (should (cj/--telega-server-death-p 139)))
+
+(ert-deftest test-telega-config-death-p-segv-signal-is-death ()
+ "Normal: a signal number (SIGSEGV is 11) counts as death.
+This is the observed failure -- the server segfaults rather than exiting."
+ (should (cj/--telega-server-death-p 11)))
+
+(ert-deftest test-telega-config-death-p-zero-is-clean-exit ()
+ "Boundary: exit status 0 is a clean shutdown and must not alert.
+Quitting telega deliberately would otherwise page on every exit."
+ (should-not (cj/--telega-server-death-p 0)))
+
+(ert-deftest test-telega-config-death-p-nil-is-not-death ()
+ "Boundary: nil status (no status available) is not a death."
+ (should-not (cj/--telega-server-death-p nil)))
+
+(ert-deftest test-telega-config-death-p-non-integer-is-not-death ()
+ "Error: a non-integer status returns nil rather than signalling.
+`process-exit-status' is documented to return an integer, but this runs
+inside telega's sentinel where an error would disrupt telega's own cleanup."
+ (should-not (cj/--telega-server-death-p "segfault"))
+ (should-not (cj/--telega-server-death-p 'exited)))
+
+;; -- cj/--telega-server-exit-status ------------------------------------------
+
+(ert-deftest test-telega-config-exit-status-nil-for-non-process ()
+ "Error: a nil or non-process argument yields nil, not a signal.
+The sentinel hands us whatever it has; a bad object must not break telega."
+ (should-not (cj/--telega-server-exit-status nil))
+ (should-not (cj/--telega-server-exit-status "not-a-process")))
+
+;; -- cj/--telega-server-death-body -------------------------------------------
+
+(ert-deftest test-telega-config-death-body-names-status-and-effect ()
+ "Normal: the body carries the status and says coverage is affected.
+The status distinguishes a segfault from a clean kill; the effect is why
+the reader should care."
+ (let ((body (cj/--telega-server-death-body 11 "segmentation fault\n")))
+ (should (string-match-p "11" body))
+ (should (string-match-p "Telegram" body))))
+
+(ert-deftest test-telega-config-death-body-strips-trailing-newline ()
+ "Boundary: a sentinel event string ends in a newline; it must not survive.
+A trailing newline in a desktop notification body renders as dead space."
+ (let ((body (cj/--telega-server-death-body 11 "segmentation fault\n")))
+ (should-not (string-suffix-p "\n" body))))
+
+(ert-deftest test-telega-config-death-body-handles-empty-event ()
+ "Boundary: an empty or nil event still produces a usable body."
+ (should (stringp (cj/--telega-server-death-body 11 nil)))
+ (should (stringp (cj/--telega-server-death-body 11 "")))
+ (should (string-match-p "11" (cj/--telega-server-death-body 11 nil))))
+
+(ert-deftest test-telega-config-death-body-handles-unicode-event ()
+ "Boundary: a non-ASCII event string passes through intact."
+ (let ((body (cj/--telega-server-death-body 1 "ошибка сервера\n")))
+ (should (string-match-p "ошибка" body))))
+
+;; -- cj/--telega-server-notify-death (the advice) ----------------------------
+
+(defmacro test-telega-config--with-status (status captured &rest body)
+ "Run BODY with the exit status forced to STATUS.
+Notification calls are captured into CAPTURED as (TITLE . BODY) pairs."
+ (declare (indent 2))
+ `(let ((,captured nil))
+ (cl-letf (((symbol-function 'cj/--telega-server-exit-status)
+ (lambda (&rest _) ,status))
+ ((symbol-function 'cj/--telega-server-send-notification)
+ (lambda (title body) (push (cons title body) ,captured))))
+ ,@body)))
+
+(ert-deftest test-telega-config-notify-fires-on-abnormal-exit ()
+ "Normal: an abnormal exit sends exactly one notification."
+ (test-telega-config--with-status 11 sent
+ (cj/--telega-server-notify-death nil "segmentation fault\n")
+ (should (= 1 (length sent)))
+ (should (string-match-p "telega" (downcase (car (car sent)))))))
+
+(ert-deftest test-telega-config-notify-silent-on-clean-exit ()
+ "Boundary: a clean exit sends nothing.
+Deliberately quitting telega must not page."
+ (test-telega-config--with-status 0 sent
+ (cj/--telega-server-notify-death nil "finished\n")
+ (should-not sent)))
+
+(ert-deftest test-telega-config-notify-silent-without-status ()
+ "Boundary: no recoverable status sends nothing rather than a bare alert."
+ (test-telega-config--with-status nil sent
+ (cj/--telega-server-notify-death nil "gone\n")
+ (should-not sent)))
+
+(ert-deftest test-telega-config-notify-contains-notifier-errors ()
+ "Error: a failing notifier must not escape into telega's sentinel.
+This runs as :after advice on `telega-server--sentinel'; an error here
+would abort telega's own status handling and its relogin path."
+ (cl-letf (((symbol-function 'cj/--telega-server-exit-status)
+ (lambda (&rest _) 11))
+ ((symbol-function 'cj/--telega-server-send-notification)
+ (lambda (&rest _) (error "notifier unavailable"))))
+ (should (progn (cj/--telega-server-notify-death nil "segfault\n") t))))
+
+(ert-deftest test-telega-config-notify-advice-is-named-and-removable ()
+ "Normal: the sentinel advice is installed by name, so it can be removed.
+An anonymous lambda can't be `advice-remove'd by reference, which strands
+the old advice in a live daemon after the source stops installing it."
+ (should (fboundp 'cj/--telega-server-notify-death))
+ (should (symbolp 'cj/--telega-server-notify-death)))
+
+(provide 'test-telega-config--server-death)
+;;; test-telega-config--server-death.el ends here