#+TITLE: Triage Intake — Telegram Source #+AUTHOR: Craig Jennings & Claude #+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: 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 segfaults outside docker mode (2026-06-09), and Craig's daemon currently has telega-use-docker nil. Wait ~2s for Ready, then (telega--loadChats 'main) 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 ) \"\")" 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 (docker image missing, server crash, daemon unreachable), the sweep reports it as SCAN FAILED at the top of the summary per the engine's failure rule — never as a silent skip. Craig gets real traffic here. ** 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 (the stable # path — see the SEGFAULT gotcha) reconnects the persisted ~/.telega session in # ~2s. Then load the main chat list so telega--chats populates. emacsclient -e "(progn (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: emacsclient -e "(progn (ignore-errors (telega--loadChats 'main)) (ignore-errors (telega--loadChats 'main)) 'loaded)" #+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. *** Step 2 — read unread, classified by last-message type The single most important filter: =messageContactRegistered=. Telegram counts a " 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 # from the maphash key (the scan can also return ids alongside titles) emacsclient -e "(let ((c (gethash 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 — crashes are spontaneous; treat server death as routine.* The dockerized =telega-server= (=zevlg/telega-server:latest=, image built 2026-06-04, tdlib 1.8.64) SIGSEGVs (exit 139) *on its own*, minutes-to-hours into a session — 11 host coredumps between 2026-06-09 and 2026-06-11, several at times when no triage verb was running. The 2026-06-11 investigation reproduced the crash-free verbs and the spontaneous deaths side by side: coredump backtraces show a corrupted stack (memory corruption in the musl build), and no newer image exists upstream. Earlier theories — "native mode is the trigger", "toggle-read is the trigger" — were timing coincidences; the verbs are sound. 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. A mid-sweep death is recoverable, not an abort: restart, confirm Ready, resume. Durable-fix candidates if the crashing gets worse: pin a pre-2026-06 image digest, build =telega-server= natively against tdlib, or report upstream to zevlg with the coredumps (=coredumpctl list /usr/bin/telega-server=). 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).** - Action: - 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 ) \"\")"= — 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 ))) (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 ))"= — pop the chat buffer for Craig to read/handle by hand (useful when a real DM needs a considered reply).