aboutsummaryrefslogtreecommitdiff
path: root/modules/telega-config.el
blob: 5a20d430a4d95135d1330268d8ec2824516650f6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
;;; telega-config.el --- Telega Telegram client config -*- lexical-binding: t; coding: utf-8; -*-
;; author: Craig Jennings <c@cjennings.net>

;;; Commentary:
;;
;; Layer: 4 (Optional).
;; Category: O/D/P.
;; Load shape: eager.
;; Eager reason: none; optional Telegram client that registers a keymap, a
;;   command-loaded deferral candidate.
;; Top-level side effects: registers a telega keymap under cj/custom-keymap,
;;   package config.
;; Runtime requires: keybindings.
;; Direct test load: yes (requires keybindings explicitly).
;;
;; Configures telega.el (https://github.com/zevlg/telega.el) as an
;; in-Emacs Telegram client.
;;
;; TDLib (Telegram Database Library) runs in a docker container via
;; `telega-use-docker' so a fresh-clone install does not need a
;; system-level TDLib build.  =scripts/setup-telega.sh= prepares the
;; container the first time; afterwards telega.el reattaches
;; automatically.
;;
;; First-run auth (phone number + Telegram verification code) is
;; interactive and happens inside `M-x telega'.  This module does not
;; script it.
;;
;; Install:
;;
;;   M-x package-refresh-contents
;;   M-x package-install RET telega
;;
;; The refresh is important.  MELPA rotates dated snapshot tarballs out
;; from under the cached archive index periodically, so if the local
;; archive-contents file points at a snapshot that no longer exists on
;; the server the install fails with a 404.  Refreshing pulls a current
;; index.  This module deliberately sets `:ensure nil' so a stale
;; archive doesn't take Emacs init down at startup; if the package
;; isn't installed yet, `C-; T' surfaces a clear "install telega" error
;; until the install runs once.
;;
;; Launcher: =C-; T= (mnemonic: Telegram).  Previously `C-; G' because
;; `T' was contested between org-table and transcription menus -- both
;; have been moved (org-table flattened under `C-; O', transcription
;; cleared to M-x), so `T' is now telega's outright.

;;; Code:

(require 'keybindings)
(require 'system-lib)                   ; cj/log-silently, used by the death alert

(use-package telega
  :defer t
  :ensure nil
  :commands (telega)
  :custom
  (telega-use-docker t)
  :config
  ;; Without this, incoming Telegram messages are invisible unless their
  ;; buffer is on screen -- telega ships desktop notifications but leaves
  ;; the mode off by default.  Runs at telega load (M-x telega), respects
  ;; telega's own per-chat mute settings.  From the 2026-06 config audit;
  ;; 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.

The =telega= Emacs package uses =:ensure nil= in this config so a
stale MELPA archive index can't take startup down with a 404.  The
trade-off: a fresh clone needs a one-time install before this
launcher works.  Without this wrapper, the autoload stub fails with
the cryptic =Cannot open load file: telega=; with it, the user gets
pointed at =scripts/setup-telega.sh= and the manual fallback."
  (interactive)
  (if (or (featurep 'telega)
          (locate-library "telega"))
      (telega)
    (user-error
     (concat "telega not installed -- run scripts/setup-telega.sh, "
             "or `M-x package-install RET telega'"))))

(cj/register-command "T" #'cj/telega)

(with-eval-after-load 'which-key
  (which-key-add-key-based-replacements
    "C-; T" "telegram (telega)"))

(provide 'telega-config)
;;; telega-config.el ends here