aboutsummaryrefslogtreecommitdiff
path: root/claude-templates
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-09 14:15:48 -0500
committerCraig Jennings <c@cjennings.net>2026-06-09 14:15:48 -0500
commit75ab07df65351f9c96738cd7efe5edacc523ac80 (patch)
treec43fb89ddcbec9dc6f207ec04e6187d867f521bb /claude-templates
parent627f521a2e1cc6830dc26a97251772084b708db4 (diff)
downloadrulesets-75ab07df65351f9c96738cd7efe5edacc523ac80.tar.gz
rulesets-75ab07df65351f9c96738cd7efe5edacc523ac80.zip
feat(triage-intake): add Telegram source plugin
I added a Telegram source plugin so the triage-intake sweep covers Telegram alongside Signal, cmail, Gmail, calendar, and PRs. Telegram is personal messaging, so it's a general plugin that syncs to every project. Unlike signal-cli, Telegram has no headless CLI here, so the plugin drives telega.el inside the running Emacs daemon over emacsclient. It records whether telega was already live and shuts it down only if the scan started it, leaving an active session alone. Two sharp edges are documented in the plugin: the tdlib server can SIGSEGV on the initial sync, where docker mode is the fix, and the scan reads the cached telega--chats hash so a dead server still reports unread state instead of going blank. I also added Telegram to the engine's general-plugin list.
Diffstat (limited to 'claude-templates')
-rw-r--r--claude-templates/.ai/workflows/triage-intake.org2
-rw-r--r--claude-templates/.ai/workflows/triage-intake.telegram.org198
2 files changed, 199 insertions, 1 deletions
diff --git a/claude-templates/.ai/workflows/triage-intake.org b/claude-templates/.ai/workflows/triage-intake.org
index e4d433d..265b883 100644
--- a/claude-templates/.ai/workflows/triage-intake.org
+++ b/claude-templates/.ai/workflows/triage-intake.org
@@ -48,7 +48,7 @@ The engine has no sources baked in. It discovers them by globbing *two* director
ls .ai/workflows/triage-intake.*.org .ai/project-workflows/triage-intake.*.org 2>/dev/null
#+end_src
-- =.ai/workflows/triage-intake.*.org= — *general* source plugins, template-synced (personal Gmail, personal calendar, cmail/Proton, Signal, personal GitHub PRs).
+- =.ai/workflows/triage-intake.*.org= — *general* source plugins, template-synced (personal Gmail, personal calendar, cmail/Proton, Signal, Telegram, personal GitHub PRs).
- =.ai/project-workflows/triage-intake.*.org= — *PROJECT-SPECIFIC* source plugins, never synced, owned by this project (e.g. a work project's Linear, work Gmail, work Slack, enterprise-GitHub PRs).
⚠ *THE #1 FAILURE MODE — read this twice.* Globbing only =.ai/workflows/= and silently missing every project plugin. If you skip =.ai/project-workflows/=, the sweep runs with *half its sources* and Craig never learns what it dropped — the omission is invisible, because a missing source looks identical to a quiet source in the output. There is no error, no empty block, no warning. The sweep just lies by omission. *Glob both directories. Always.*
diff --git a/claude-templates/.ai/workflows/triage-intake.telegram.org b/claude-templates/.ai/workflows/triage-intake.telegram.org
new file mode 100644
index 0000000..68604bc
--- /dev/null
+++ b/claude-templates/.ai/workflows/triage-intake.telegram.org
@@ -0,0 +1,198 @@
+#+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:
+
+** 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
+"<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 — docker mode is the fix.* The tdlib =telega-server= can exit
+abnormally (code 139, SIGSEGV) during or just after the initial sync. Observed
+on the 2026-06-09 first run; the cure was *docker mode* — =telega-use-docker= = t
+with the =zevlg/telega-server:latest= image (already pulled on Craig's box).
+Restarted under docker, the server reached "Ready" in ~2s, synced 73 chats, and
+held through a live re-scan with no crash. So: *ensure docker mode is on before
+the sweep.* If =telega-use-docker= is nil, set it (=(setq telega-use-docker t)=)
+and restart telega.
+
+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.
+
+- *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.
+- *FYI:* unread in dev-community groups Craig follows — =GNU Emacs=, =zed=,
+ =Kitty=, and similar. Worth awareness, no response owed. List the group +
+ unread count; don't enumerate the messages.
+- *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>
+- FYI: <dev groups — name + count>
+- Noise: K joined-Telegram notices, J spam/bot/deleted (tally only)
+#+end_example
+
+Omit the block if there are zero unread chats. If the only unread is
+join-notices + spam + groups (the common case), render the one-line summary plus
+the Noise tally and skip Action entirely.
+
+** 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 :: =emacsclient -e "(telega-chat--mark-read (telega-chat-get <CHAT-ID>))"= — clears a specific chat's unread. Never mark the whole account read blindly; the join-notice noise is fine to leave or clear per Craig's call.
+- 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).
+
+No blanket mark-read verb: the account's unread is mostly join-notices and spam,
+and a real DM should be handled deliberately, not swept.