aboutsummaryrefslogtreecommitdiff
path: root/.ai/workflows/triage-intake.telegram.org
blob: 1319da512c5321c221342402d45952ed0d821d60 (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
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
#+TITLE: Triage Intake — Telegram Source
#+AUTHOR: Craig Jennings
#+DATE: 2026-06-09

# Source plugin for the triage-intake engine. See triage-intake.org for the
# contract and the Phase A-D orchestration. This file declares ONE source.
#
# General (personal) source: Telegram via the Emacs telega.el package (tdlib
# backend). It lives in .ai/workflows/ and is template-synced, sitting with the
# other general personal sources (personal-gmail, cmail, personal-calendar,
# signal, github-prs) — not the project plugins. Telegram is personal
# messaging, not project-specific.
#
# Unlike signal-cli (a standalone CLI), Telegram has no headless CLI here. The
# client is telega.el running inside Craig's long-lived `emacs --daemon`, so the
# plugin drives it over `emacsclient -e`. tdlib keeps a persisted session in
# ~/.telega (td.binlog), so a started telega reconnects without re-auth.

* Source: telegram
:PROPERTIES:
:ORDER:         24
:ENABLED:       command -v emacsclient && emacsclient -e "(or (featurep 'telega) (fboundp 'telega))" | grep -q t
:ANCHOR:        none
:SUBAGENT_OVER: 40
:END:

** Quick reference — full lifecycle

Telega does not autostart with the Emacs daemon. "Down" is its normal state
unless Craig has Telegram open in Emacs. The scan therefore runs the full
lifecycle every time, never skips because the server is down:

⚠ *DOWN / not-loaded is the TRIGGER to launch, never a reason to skip or fail.*
This is the exact mistake two projects (work + home, 2026-07-24) made: they
probed telega, saw =(telega-server-live-p)= nil or telega not =featurep=, and
reported =SCAN FAILED: telegram — not loaded= or a silent SKIP — a *blind*
sweep — instead of running Step 1 to start it. A down or unloaded telega is the
normal entry state; =(telega t)= both LOADS the package and STARTS the docker
server (work confirmed: down → =(telega t)= → Ready, 18 chats). So the plugin
MUST run Step 1's launch whenever telega is down/unloaded, wait for Ready, then
scan. =SCAN FAILED= is reserved for a launch that was actually ATTEMPTED and did
not reach Ready (image missing, server crash on start, daemon unreachable) —
never for the pre-launch down state itself. The =:ENABLED:= guard above tests
whether telega is INSTALLED (=fboundp=), not whether the server is up; a down
server never disables the source.

1. Record prior state: TELEGA_WAS_RUNNING via (telega-server-live-p).
2. Launch (only if not running):
   emacsclient -e "(progn (setq telega-use-docker t) (telega t) 'started)"
   The setq is mandatory defense: tdlib crashed in native mode when this was
   set up (2026-06-09) — a separate matter from the SEGFAULT gotcha, which is
   about the loadChats argument — and Craig's daemon defaults to nil.
   Wait ~2s for Ready, then (telega--loadChats '(:@type "chatListMain")) until telega--chats
   is populated.
3. Check messages: the maphash unread scan in ** Scan Step 2 (filters the
   messageContactRegistered join-notice noise).
4. Send (needs the server live; /voice personal first — Telegram
   occasionally carries WORK communication to Kostya and Vrezh, so treat
   sends with the same care as Slack):
   emacsclient -e "(telega-chat-send-msg (telega-chat-get <CHAT-ID>) \"<body>\")"
5. Shutdown (ONLY if step 1 recorded not-running):
   emacsclient -e "(progn (telega-server-kill) (ignore-errors (telega-kill t)) 'stopped)"
   Verify: telega-server-live-p → nil, no zevlg/telega-server container in
   docker ps. If Craig had it running, leave it untouched.

If any lifecycle step fails *after the launch was attempted* (docker image
missing, server crash on start, daemon unreachable, Ready never reached), the
sweep reports it as SCAN FAILED at the top of the summary per the engine's
failure rule — never as a silent skip. This does NOT cover the ordinary
pre-launch down state: a down server means "run Step 1," not "SCAN FAILED."
Craig gets real traffic here, so a blind sweep that skipped the launch is worse
than a clean failure — it hides real unread messages behind a false all-clear.

** Scan

Telegram direct messages and groups via telega.el in the running Emacs daemon.
=ANCHOR: none= because telega reports live unread *state* (each chat's
=:unread_count=), not a since-window — the engine substitutes no cutoff. Phase B
uses each message's timestamp only to order and label recency.

The scan reads the =telega--chats= hash table (chat-id → chat plist), which
telega populates as chats sync. *This is robust to the tdlib server crashing
mid-session* (see the SEGFAULT gotcha below): the hash retains the last-synced
unread counts and =:last_message= even after the server dies, so a scan reading
the hash still returns the most recent known state.

*** Leave-no-trace lifecycle (start only if needed, shut down only if we started it)

telega is a long-lived client inside Craig's daemon. If he already has it
running, the scan must leave it running. If it's *not* running, the scan starts
it for the read and shuts it down cleanly afterward, restoring the daemon to its
prior state. The discipline: *record the prior liveness, branch on it at the
end.*

*** Step 0 — record prior state

#+begin_src bash
# t if telega's tdlib server was ALREADY live before this scan, nil otherwise.
# Hold this value; Step 3 reads it to decide whether to shut telega down.
TELEGA_WAS_RUNNING=$(emacsclient -e "(and (fboundp 'telega-server-live-p) (telega-server-live-p) t)" 2>/dev/null)
#+end_src

*** Step 1 — start (docker mode) if not already running, wait for Ready

#+begin_src bash
# `(telega t)` starts without popping the root buffer. Docker mode reconnects the
# persisted ~/.telega session in ~2s. Then load the main chat list so
# telega--chats populates.
#
# The `(setq telega-use-docker t)` is mandatory and must come BEFORE `(telega t)`:
# tdlib crashed in native mode when this was first set up (2026-06-09), and the
# daemon's default is nil unless something (e.g. an Emacs-config :custom) has
# already forced it. It was missing here while the Quick Reference required it —
# a session that started telega without it on a native-mode daemon would take the
# untested path. Match the Quick Reference exactly.
#
# Note this is a SEPARATE concern from the SEGFAULT gotcha below: that gotcha is
# about the `loadChats` argument, and the deaths it explains happened in docker
# mode. Docker mode is not a defense against it, and it is not evidence for
# docker mode. Keep both.
emacsclient -e "(progn
  (setq telega-use-docker t)
  (unless (and (fboundp 'telega-server-live-p) (telega-server-live-p)) (telega t))
  'started)"
# Poll until Ready with chats synced, or a crash/timeout. Background this with an
# until-loop so the wait doesn't block; exit on Ready-with-chats OR an abnormal
# server exit. Then force a chat-list load if the hash is thin:
# NOTE: the chat-list argument must be a TL object, not the symbol 'main.
# `telega--loadChats' puts it straight into the request as :chat_list, and a
# bare symbol kills the server outright (see the SEGFAULT gotcha below).
#
# The liveness check on the tail is the load's only failure signal. `ignore-errors'
# catches nothing here, because a bad argument kills the server process rather than
# signalling in elisp, so without this the call returns 'loaded either way.
# The `fboundp' guard matches Step 0: if the launch failed outright telega is not
# loaded, and that should read as 'server-died like any other failure rather than
# signalling void-function.
emacsclient -e "(progn (ignore-errors (telega--loadChats '(:@type \"chatListMain\"))) (ignore-errors (telega--loadChats '(:@type \"chatListMain\"))) (if (and (fboundp 'telega-server-live-p) (telega-server-live-p)) 'loaded 'server-died))"
#+end_src

On a persisted session telega reaches status "Ready" within ~2s; the chat list
loads over a few more. If =(hash-table-count telega--chats)= is 0 or thin,
re-issue =telega--loadChats= and poll until it stabilizes.

⚠ *=server-died= is SCAN FAILED, never a quiet account.* A server that dies
during the load leaves a thin =telega--chats= hash, and a thin hash reads exactly
like an account with little unread. That is the same false all-clear the
down/not-loaded rule exists to prevent, arriving one step later in the lifecycle.
It also fits the SCAN FAILED definition above: the launch was attempted and did
not hold. So on =server-died=, report SCAN FAILED rather than scanning, and never
report a low unread count from that run.

This is the independent evidence the SEGFAULT gotcha asks for when it says to
treat a short chat list as a real short list. Without the check there is no way
to tell the two apart, which is how the =loadChats= crash stayed invisible
through two investigations.

*** Step 2 — read unread, classified by last-message type

The single most important filter: =messageContactRegistered=. Telegram counts a
"<name> joined Telegram" service notice as one unread message, so every contact
from Craig's old address book who ever joined shows as a 1-unread "DM" *that
person never actually sent*. On the 2026-06-09 first scan this was 30 of ~50
unread chats. Drop them entirely (tally only).

#+begin_src bash
emacsclient -e "(let (real svc other)
  (when (boundp 'telega--chats)
    (maphash (lambda (id chat)
      (let* ((uc (or (plist-get chat :unread_count) 0))
             (lm (plist-get chat :last_message))
             (ctype (when lm (plist-get (plist-get lm :content) :@type)))
             (title (or (ignore-errors (substring-no-properties (telega-chat-title chat))) \"?\")))
        (when (> uc 0)
          (cond
           ((equal ctype \"messageContactRegistered\") (push title svc))
           ((member ctype '(\"messageText\" \"messagePhoto\" \"messageVideo\" \"messageDocument\" \"messageVoiceNote\" \"messageSticker\" \"messageAnimation\"))
            (push (list title uc ctype) real))
           (t (push (list title uc (or ctype \"nil\")) other))))))
      telega--chats))
  (list (cons 'real (nreverse real))
        (cons 'joined-telegram-count (length svc))
        (cons 'other (nreverse other))))"
#+end_src

For a chat that survives as Action-worthy, pull the last message's text to
classify and summarize:

#+begin_src bash
# <CHAT-ID> from the maphash key (the scan can also return ids alongside titles)
emacsclient -e "(let ((c (gethash <CHAT-ID> telega--chats)))
  (substring-no-properties
    (or (telega--tl-get c :last_message :content :text :text) \"\")))"
#+end_src

*** Step 3 — restore prior state (shut down only if we started it)

#+begin_src bash
# If telega was NOT running before this scan, shut it down cleanly to leave the
# daemon as we found it. If Craig already had it running, leave it alone.
if [ "$TELEGA_WAS_RUNNING" != "t" ]; then
  emacsclient -e "(progn (ignore-errors (telega-server-kill)) (ignore-errors (telega-kill t)) 'killed)"
fi
#+end_src

⚠ *In docker mode, =telega-kill= alone is not enough.* =telega-kill= buries the
telega buffers but leaves the dockerized tdlib server running (=telega-server-live-p=
stays non-nil). =telega-server-kill= is what actually stops the server. Call
*both* — server-kill then kill — for a clean teardown. Verified clean afterward:
=telega-server-live-p= → nil, root buffer gone, no =zevlg/telega-server= container
left in =docker ps=. Skipping this whole branch when =TELEGA_WAS_RUNNING= is t is
the point of Step 0: never tear down a session Craig is actively using.

⚠ *SEGFAULT GOTCHA — this was our bug, not tdlib's. Root-caused 2026-07-28.*
=telega-server= dies with =Unexpected char 'm' in plist value= followed by
=Assertion failed: false (telega-dat.c: tdat_plist_value: 500)=. The cause was
this workflow: Step 1 called =(telega--loadChats 'main)=.

The chain. =telega--loadChats= is a raw TL wrapper — it drops its argument into
the request as =:chat_list= with no conversion. =telega-server--send= then
=prin1='s the whole plist, and =telega--tl-pack= passes atoms through untouched,
so the symbol goes out on the wire bare as =main=. The C parser
(=server/telega-dat.c=, =tdat_plist_value=) accepts only =(=, =[=, ="=, =-=, a
digit, =t=, =:=, or =n= to start a value. It hits =m=, prints that line, and
calls =assert(false)=, which aborts the process. The =m= in the error is
literally the first character of =main=.

The symbol shorthand is real but belongs to a different layer:
=telega-filter.el= and =telega-folders.el= convert =(eq cl-fspec 'main)= into
='(:@type "chatListMain")=. The raw TL layer never does. telega's own callers
always pass the object (=telega.el:290=, =telega-tdlib-events.el:516=).

Proved by experiment, not inference (2026-07-28): from a live Ready server,
=(telega--loadChats 'main)= killed it within seconds and added one coredump,
with that exact assertion; a restart plus =(telega--loadChats '(:@type
"chatListMain"))= survived three consecutive calls with no new coredump and no
assertion.

*The previous entry here was wrong and cost real time.* It recorded the deaths
as spontaneous musl memory corruption and declared "the verbs are sound", which
sent later investigations at the docker image and tdlib versions instead of at
this file. The corrupted stack in the backtraces is what an =assert= abort looks
like, not independent evidence of a memory bug. If crashes are ever seen again
with *no* triage verb running, that is a genuinely separate cause and needs its
own investigation — do not reuse the old spontaneous-crash story to explain it.

*This crash kills a scan; it does not silently shorten one.* An earlier draft of
this section claimed the reported "19 chats of ~50" was truncation caused by the
bad call. That was wrong, and work disproved it at the wire level on 2026-07-28:
with the corrected call their count is 19 before the first load and 19 after five
(four on =chatListMain=, one on =chatListArchive=). Nineteen is the real size of
that account. The same reading here — 19 stable across three corrected loads —
was already sitting in the evidence and should have retired the claim before it
was written down. Treat a short chat list as a real short list unless something
independently shows the server died mid-sync.

=ignore-errors= around the call never helped — the failure is the server process
dying, not an elisp signal, so there is nothing for it to catch. That is why the
death is easy to miss from inside elisp, and why a caller should check
=(process-live-p (telega-server--proc))= after a load rather than trusting a
returned value.

Operationally: docker mode stays mandatory (=telega-use-docker= = t; the setq
before =(telega t)= is still the right defense), and *every action batch checks
the server first* — =(process-live-p (telega-server--proc))= — restarting via
=(telega t)= when dead and re-checking Ready before firing verbs. Any argument
handed to a =telega--*= TL wrapper must be a TL object or a plain
string/number/list, never a bare symbol.

Defense in depth: even if the server does die, the scan still works because it
reads the cached =telega--chats= hash, not a live query. A dead server is
*scan-only* — you can still report unread state, but cannot read new bodies, mark
read, or reply until it restarts. Treat that as "scan-only, no actions this run"
and say so.

** Classify

Bias: Craig's personal Telegram is *spam-dominated* with a thin layer of real
signal. The opposite of Signal (high signal/low volume) — here the volume is
high and almost all noise. Filter aggressively; surface only the few real
threads. Kostya and Vrezh occasionally reach Craig here, so a real DM from a
work contact is Action, full stop.

- *Noise-trash (tally only, never itemized):*
  - =messageContactRegistered= "joined Telegram" notices — always noise, no
    matter whose name is on them. The real contacts Craig knows live here; a
    join notice is not a message from them.
  - Romance/crypto spam DMs — the signature is an emoji-laden handle or a
    two-word "RealName + FantasyWord" suffix (=Gayle ⚾🤎RoyalVineyard=,
    =Cherie🌷🏰 InfiniteRhapsody=, =Jane 🍒🔥=, =Luna Skye=). One unread,
    unsolicited, no prior thread.
  - =Deleted Account-NNNN= threads, blank-title chats, bot channels
    (=Z-Library Official=), Telegram's own =✔️Telegram= service notices.
- *Noise-keep (never reported):* unread in dev-community groups Craig follows —
  =GNU Emacs=, =zed=, =Kitty=, and similar. Skipped in sweep reports entirely —
  not even a name + count line — unless Craig specifically asks about them
  (Craig's ruling, 2026-06-11, via the work project's handoff). Leave them
  unread; they're reading material, not signal.
- *Action:* a real text/voice/media message from a *known personal contact* in
  an existing one-to-one thread — an explicit ask, a question, a reply owed. On
  a spam-heavy account these are rare; when one appears, surface it prominently
  with the sender + gist, because it's the needle in the haystack.

The 2026-06-09 calibration run: 30 join-notices + ~10 spam/deleted/bot + 3 dev
groups + 0 real personal DMs. Expect most sweeps to look like this — a clean
"nothing real" is the common, correct result.

** Render

#+begin_example
**Telegram — N unread chats (M real after filtering).** <one-line summary>
- Action: <real DMs from known contacts, sender + gist, reply owed called out>
- Noise: K joined-Telegram notices, J spam/bot/deleted (tally only)
#+end_example

Dev-community group traffic never appears here — no FYI line, no name + count —
unless Craig asks for it in that sweep (2026-06-11 ruling). Real DMs from known
contacts still surface as Action.

Omit the block entirely when there's nothing but group traffic, join-notices,
and spam — under the engine's deltas-only rule that's a no-change source. Render
the block only when there's an Action item or a Noise tally worth a state-change
suggestion (e.g. a trash batch).

** Actions

Actions need the tdlib server *live* (see the SEGFAULT gotcha — a dead server is
scan-only). All run through telega in the daemon:

- reply     :: =emacsclient -e "(telega-chat-send-msg (telega-chat-get <CHAT-ID>) \"<body>\")"= — public-facing (goes out under Craig's name), so run =/voice personal= before sending. Prefer a body file for multi-line.
- mark-read :: verified 2026-06-11 (the previously documented =telega-chat--mark-read= never existed in telega). The idempotent per-chat verb:

  #+begin_example
  emacsclient -e "(let ((chat (telega-chat-get <CHAT-ID>)))
                    (telega--viewMessages chat (list (plist-get chat :last_message))
                      :source '(:@type \"messageSourceChatList\") :force t)
                    (telega--readAllChatMentions chat)
                    (telega--readAllChatReactions chat))"
  #+end_example

  =telega-chat-toggle-read= also works but *toggles*: on a chat with zero unread it marks the chat UNREAD, so scripting must guard on =(> (plist-get chat :unread_count) 0)=. Never mark the whole account read blindly; a real DM is handled deliberately, not swept.
- delete-join-notice :: standing policy (Craig, 2026-06-11): a chat whose *newest* message is a =messageContactRegistered= "joined Telegram" notice is a chat Craig never responded to and doesn't want to keep — *delete it* rather than mark it read. The bulk sweep (returns the count deleted):

  #+begin_example
  emacsclient -e "(let ((n 0))
                    (maphash (lambda (_id chat)
                               (when (equal (plist-get (plist-get (plist-get chat :last_message) :content) :@type)
                                            \"messageContactRegistered\")
                                 (telega--deleteChatHistory chat t nil)
                                 (setq n (1+ n))))
                             telega--chats)
                    n)"
  #+end_example

  =telega--deleteChatHistory chat t nil= removes the chat from the list on Craig's side only (no revoke). First run 2026-06-11 deleted 41 such chats and cut the unread-chat count from 48 to 16.
- open      :: =emacsclient -e "(telega-chat-with (telega-chat-get <CHAT-ID>))"= — pop the chat buffer for Craig to read/handle by hand (useful when a real DM needs a considered reply).