aboutsummaryrefslogtreecommitdiff
path: root/working/triage-telegram-down-launch/triage-intake.telegram.org.proposed
diff options
context:
space:
mode:
Diffstat (limited to 'working/triage-telegram-down-launch/triage-intake.telegram.org.proposed')
-rw-r--r--working/triage-telegram-down-launch/triage-intake.telegram.org.proposed290
1 files changed, 290 insertions, 0 deletions
diff --git a/working/triage-telegram-down-launch/triage-intake.telegram.org.proposed b/working/triage-telegram-down-launch/triage-intake.telegram.org.proposed
new file mode 100644
index 0000000..42d46fe
--- /dev/null
+++ b/working/triage-telegram-down-launch/triage-intake.telegram.org.proposed
@@ -0,0 +1,290 @@
+#+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 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 <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 (the stable
+# path — see the SEGFAULT gotcha) 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 segfaults in native mode (SEGFAULT gotcha below), 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 and the gotcha both require it —
+# a session that started telega without it on a native-mode daemon would crash the
+# server, surfacing as a triage SCAN FAILED. Match the Quick Reference exactly.
+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:
+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
+"<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 — 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).** <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).