pi-braid-telegram 0.0.0-stage → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kifirkin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,205 @@
1
- # Temporary Holding Version
1
+ # pi-braid-telegram
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ <!-- status: current -->
4
+
5
+ Bidirectional Telegram channel for pi-braid.
6
+
7
+ - **Outbound**: `telegram_send` tool - the agent sends text messages to the
8
+ owner (or voice notes once a TTS source is configured). Messages are LONG by
9
+ default: plain text sends verbatim up to the cap (`braid.telegram.sendMaxChars`
10
+ in `~/.pi/agent/settings.json`, default 4096 = Telegram's per-message limit);
11
+ there is no tweet-length default. Over the cap, the crafting model distills
12
+ first and `rich: true` splits-to-fit instead. Returns the delivery
13
+ result (message_id or error). Outbound sends the raw trimmed
14
+ text verbatim - the `[tg-end]` marker is an INBOUND parse boundary only and
15
+ never appears on outbound messages.
16
+ - **Turn-complete auto-notify (opt-in)**: with
17
+ `BRAID_TG_NOTIFY_ENABLED=1`, when a turn ends with a completed plain-text
18
+ answer (no trailing tool call), a second LLM call (key-ring-backed via
19
+ `ctx.modelRegistry`, never the bot token) distills the answer into an owner
20
+ update capped at `braid.telegram.notifyMaxChars` (default 1200) and sends it
21
+ through the same outbound seam. Rate-limited (default 4/min,
22
+ `BRAID_TG_NOTIFY_MAX_PER_MIN`); model override via `BRAID_TG_NOTIFY_MODEL`.
23
+ Default OFF - no unsolicited messages.
24
+ - **Live-update tracked message (default-on)**: on every escalated
25
+ inbound owner message the bot immediately sends one "⚙️ Working…" message,
26
+ edits it in place (Telegram's edit affordance pins it at its position) as work
27
+ progresses, then on a completed turn edits it to "✅ Done" and sends a SEPARATE
28
+ beautifully formatted full report via rich Markdown + split-to-fit
29
+ (`sendMessageChunks`). Progress edits are rate-limited (default 4/min);
30
+ `finish()` is never dropped. Default ON - set
31
+ `BRAID_TG_LIVE_UPDATE_ENABLED=0` to disable.
32
+ - **Rich / split-to-fit outbound**: `telegram_send` gained optional
33
+ `rich: true` and `parse_mode: "HTML"|"Markdown"`. `rich` bypasses the send
34
+ cap and the craft hook, delivering the text verbatim split on
35
+ paragraph→line→word boundaries with a `(i/N)` marker; bodies that ship as
36
+ HTML are split tag-aware (no cut inside a tag/entity; a chunk closes the tags
37
+ it opens, the next chunk reopens them). A Markdown balance guard
38
+ (`isBalancedMarkdown`) drops `parse_mode` to plain text (never an API error)
39
+ when Telegram would reject unbalanced emphasis/link delimiters, and the
40
+ MarkdownV2→HTML conversion is validated against Telegram's HTML parser
41
+ (`checkTelegramHtml`): a conversion that would cross delimiter pairs, emit an
42
+ unbalanced/broken author tag or cross the cap is refused, and the body ships
43
+ as authored. Default path (no `rich`) is plain text with the
44
+ settings-driven cap.
45
+ - **Decision buttons (tap-to-answer)**: `telegram_send` gained optional
46
+ `options` - 1-2 rows of up to 4 `{ label, value }` buttons attached to the
47
+ message as an inline keyboard - and `decisionToken`, their correlation id
48
+ (generated when omitted, echoed in the tool result). A tap is acknowledged
49
+ with a toast and reaches the agent as a normal inbound message
50
+ `[decision] <token> → <label> (<value>)` (through the same coalescing
51
+ window and steer handoff as a text message). The owner can always answer
52
+ in words;
53
+ malformed rows refuse the send (nothing delivered); `pause`/`resume`/`status`
54
+ control taps keep their own handler.
55
+ - **Inbound (agentic pull, default)**: `telegram_inbox` tool - the agent calls
56
+ it mid-turn to fetch pending messages from whitelisted chats via `getUpdates`
57
+ (offset confirm-on-read, persisted across restarts). Results come back as
58
+ braid-whisper envelopes (context, NOT synthetic user turns) with sender
59
+ provenance + a per-message `fromOwner` flag; only owner messages may be
60
+ escalated to a turn.
61
+ - **Inbound (background poller, opt-in only)**: the legacy long-poll injector
62
+ is OFF by default. Explicitly enable via `BRAID_TG_POLL_ENABLED=1` at load,
63
+ `/telegram enable`, or the `telegram_poller` tool (`action:"enable"`). At
64
+ most ONE poller per process (singleton). Owner messages are delivered as
65
+ `[braid-whisper - telegram] ... [braid-whisper-end]` envelopes.
66
+
67
+ Zero npm runtime dependencies: the client is built on Node 22's global fetch.
68
+ No live credentials, no real network calls in the test suite (recorded-fixture
69
+ fake fetch).
70
+
71
+ ## Install
72
+
73
+ ```bash
74
+ pi install npm:pi-braid-telegram
75
+ ```
76
+
77
+ Or register the workspace in `~/.pi/agent/settings.json`:
78
+
79
+ ```json
80
+ {
81
+ "packages": ["file:~/pi-braid/packages/telegram"]
82
+ }
83
+ ```
84
+
85
+ ## What this does not do
86
+
87
+ - It does not poll or push by default. The background poller is OFF, turn-complete auto-notify is
88
+ opt-in, and the tracked in-progress message fires only on an escalated inbound owner message.
89
+ - It does not accept strangers. Only whitelisted chats/users are read, and only owner messages may
90
+ be escalated to a turn.
91
+ - It does not bundle a Telegram client library - the Bot API calls use Node 22's global fetch.
92
+
93
+ ## Activation preconditions
94
+
95
+ - `BRAID_TG_BOT_TOKEN` set - this is what enables the channel.
96
+ - A bot token from @BotFather; optionally `BRAID_TG_CHAT_ID` / `BRAID_TG_ALLOWED_USERS` for the
97
+ allow-list.
98
+
99
+ ## Environment variables
100
+
101
+ | Var | Purpose | Required |
102
+ | ---------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------- |
103
+ | `BRAID_TG_BOT_TOKEN` | Telegram bot token from @BotFather | **enables the channel** (if-has-token guard) |
104
+ | `BRAID_TG_CHAT_ID` | Owner chat/user id for outbound + allow-list | optional (bootstrap fallback) |
105
+ | `BRAID_TG_ALLOWED_USERS` | Extra allow-list ids/@usernames (comma-separated) | optional |
106
+ | `BRAID_TG_STATE_FILE` | Offset/state file path | optional, default `~/.pi/agent/braid/telegram-state.json` |
107
+ | `BRAID_TG_POLL_INTERVAL_MS` | Poll cadence (background poller) | optional, default 1500 |
108
+ | `BRAID_TG_POLL_ENABLED` | Explicit opt-in: auto-start the background poller at load (`1`) | optional; default OFF (`TELEGRAM_POLL_ENABLED` alias) |
109
+ | `BRAID_TG_POLL_DISABLED` | Legacy opt-out flag (superseded by default-off; still honored) | optional |
110
+ | `BRAID_TG_COALESCE_WINDOW_MS` | Coalescing window for inbound owner bursts (ms; `0` = next tick) | optional, default 2000 |
111
+ | `BRAID_TG_VERBOSE` | Route debug/info to the notify sink (still never console.\*) | optional |
112
+ | `BRAID_TG_NOTIFY_ENABLED` | Opt-in: turn-complete auto-notify, default OFF | optional |
113
+ | `BRAID_TG_NOTIFY_MODEL` | Crafting model override (`provider/model-id`); default = session model | optional |
114
+ | `BRAID_TG_NOTIFY_MAX_PER_MIN` | Auto-notify rate cap (sliding window), default 4 | optional |
115
+ | `BRAID_TG_LIVE_UPDATE_ENABLED` | Live-update tracked message, default ON (`0` disables) | optional |
116
+ | `BRAID_TG_LIVE_UPDATE_MAX_PER_MIN` | Live-update progress-edit rate cap (sliding window), default 4 | optional |
117
+ | `BRAID_TG_LIVE_UPDATE_STATE_FILE` | Tracked-message state file path | optional, default `~/.pi/agent/braid/telegram-live-update.json` |
118
+
119
+ All `BRAID_TG_*` names have `TELEGRAM_*` owner-side aliases; `BRAID_TG_*` wins
120
+ when both are set. No token → the extension factory returns immediately; no
121
+ tools, no command, no poller, nothing crashes (debug log only).
122
+
123
+ ## Tools & command
124
+
125
+ | Surface | Purpose |
126
+ | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
127
+ | `telegram_send` | Outbound text/voice to the owner (raw trimmed text, no end marker); optional `rich: true` for verbatim split-to-fit delivery, `parse_mode: "HTML" or "Markdown"`, `options` for decision buttons (1-2 rows of up to 4 `{ label, value }` buttons; a tap returns `[decision] <token> → <label> (<value>)`), `decisionToken` correlation id |
128
+ | `telegram_inbox` | Agentic pull - fetch pending messages, confirm-on-read, `fromOwner` flag |
129
+ | `telegram_poller` | Enable/disable the singleton background poller (agent tool) |
130
+ | `/telegram enable` | Start the singleton background poller |
131
+ | `/telegram disable` | Stop the singleton background poller |
132
+ | `/telegram send <text>` | Send a message from the shell (raw trimmed text, no marker) |
133
+ | `/telegram status` | Show channel + poller state |
134
+
135
+ ## Components
136
+
137
+ | Path | Purpose |
138
+ | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
139
+ | `src/index.ts` | Extension factory: if-has-token guard, registers `telegram_send`/`telegram_inbox`/`telegram_poller`, `/telegram` command, singleton poller (OFF by default), TUI-safe notify sink, turn-complete auto-notify (`turn_end` listener, opt-in), live-update tracked message (`start` on inbound / `finish` on `turn_end`, default-on), LLM-crafted + rich/split-to-fit outbound |
140
+ | `src/lib/telegram-client.ts` | Low-level `api.telegram.org/bot<token>/<method>` client (sendMessage, editMessageText, sendVoiceNote, sendChatAction, getUpdates, getMe, testToken) with injectable fetch; `parse_mode` on text sends/edits |
141
+ | `src/lib/inbound-poller.ts` | Long-poll loop: allow-list + bootstrap filtering, group/non-text rejection, confirm-on-read offset persistence, injection retry buffer |
142
+ | `src/lib/telegram-inbox.ts` | `fetchInbox` agentic-pull core: fetch → filter → return with `fromOwner` → confirm offset; braid-whisper envelope formatting |
143
+ | `src/lib/tool-logic.ts` | `telegram_send` validation, settings-driven cap enforcement (craft hook replaces dumb truncate; `braid.telegram.sendMaxChars` default 4096), rich/split-to-fit + parse_mode + Markdown-balance guard, text/voice delivery orchestration |
144
+ | `src/lib/tts.ts` | Outbound voice synthesis: `braid.telegram.tts` source/rate/voice reader (default edge-tts, rate +20%, RU/EN/HE voices by script), edge-tts → ffmpeg ogg/opus with retry, gated macOS `say` fallback, `source: "none"` → voice refusal; injectable spawn seam |
145
+ | `src/lib/format.ts` | `splitLongText` (paragraph→line→word boundaries, `(i/N)` markers), `splitHtmlText` (tag-aware chunks: tags close/reopen across cuts, entities stay whole; an over-cap tag refuses the body instead of being trimmed), `sendMessageChunks` (split-to-fit 4096, parseMode-propagation, replyMarkup on the last chunk), `isBalancedMarkdown` (pre-send guard), `convertMarkdownV2ToHtml` (refuses conversions Telegram would reject), `resolveDeliveryFormat` (mode decision point) |
146
+ | `src/lib/html-check.ts` | `checkTelegramHtml`: strict local mirror of Telegram's HTML parser (tag set, attribute quoting, end-tag attributes, unquoted-value delimiters, span/tg-emoji attribute rules, LIFO nesting), calibrated against the live API; gates every conversion and every HTML wire chunk |
147
+ | `src/lib/decision-buttons.ts` | Decision-button protocol: `options` rows → `dec:<token>:<value>` inline keyboard (64-byte callback_data cap enforced), tap parse + label recovery, the answered row is dropped from the message while open rows stay (a multi-question keyboard survives a tap), `[decision] <token> → <label> (<value>)` delivery payload |
148
+ | `src/lib/telegram-settings.ts` | `braid.telegram` caps reader (`sendMaxChars` 4096, `notifyMaxChars` 1200) from `~/.pi/agent/settings.json`: per-call, safe defaults on missing/invalid input |
149
+ | `src/lib/notify/craft.ts` | Turn-complete notify: pure detection (`shouldNotifyTurnEnd`), final-text extraction, distilling prompt (cap injected from settings), guard-fallback at the same cap; `buildRichSummaryPrompts` (live-update rich-report prompt) |
150
+ | `src/lib/notify/rate-limit.ts` | Sliding-window rate limiter (max N per minute, deterministic timestamps) |
151
+ | `src/lib/notify/model.ts` | Key-ring-backed crafting model call (completeSimple via `ctx.modelRegistry`, never the bot token) + text-craft hooks for `telegram_send`/`/telegram send` and rich summaries |
152
+ | `src/lib/notify/live-update.ts` | Live-update tracker: `start` (send + persist tracked message, idempotent restart re-pin) / `update` (edit in place) / `finish` (edit → Done, then separate rich summary) / `isActive` / `getState` + state-file persistence |
153
+ | `src/lib/notify/turn-notify.ts` | `turn_end` handler orchestration: detect → rate-limit → craft → deliver (injectable for tests) |
154
+ | `test/*.test.mjs` | Unit tests against recorded fixtures (fake fetch / stubbed client), no network; `test/corpus-runner.mjs` replays the committed adversarial corpus (wire validity, fidelity changes, cap contracts) |
155
+
156
+ ## Tests
157
+
158
+ ```bash
159
+ npm test # emits src -> dist (gitignored), runs all test files
160
+ npm test -w pi-braid-telegram # via the monorepo workspace
161
+ npx tsc --noEmit # typecheck (guardrail build)
162
+ ```
163
+
164
+ Coverage: client method extraction & error mapping, param validation, cap
165
+ enforcement (settings-driven default + explicit limit), allow-list filtering,
166
+ bootstrap, offset confirm-on-read
167
+ persistence across restarts and fetch calls, retry-on-injection-failure,
168
+ group/non-text rejection, if-has-token guard, inbound/outbound envelope
169
+ formats (inbound carries the `[tg-end]` end marker; outbound carries NONE),
170
+ default-off poller, poll-enabled opt-in, singleton
171
+ poller, `/telegram` command verbs, telegram settings caps (defaults,
172
+ overrides, invalid-input degradation, per-call re-read), voice TTS
173
+ (`braid.telegram.tts` defaults / `source: "none"` refusal / invalid-input
174
+ fallback, script→voice, edge-tts retry, gated say fallback, handoff into the
175
+ send path), turn-complete
176
+ auto-notify (detection,
177
+ rate limit, guard fallback, cap + token-budget injection, key-ring model
178
+ resolver, no marker on outbound),
179
+ live-update tracked message (start/update/finish idempotency, restart re-pin,
180
+ rate limit, finish-not-rate-limited, default-on + `=0` opt-out, trailing-tool gating,
181
+ state-file persistence), rich/split-to-fit (paragraph→line→word boundaries,
182
+ never mid-word, `(i/N)` markers, 4096 cap sequence, honored parse_mode,
183
+ Markdown-balance guard, craft bypass when `rich`),
184
+ decision buttons (row/token validation, malformed-row refusal, 64-byte
185
+ callback_data cap, generated-token echo, dec: parse/label/format, tap handler
186
+ ack + delivery payload, multi-question keyboards keep the open rows after a
187
+ tap, voice-path refusal, keyboard on the last rich chunk),
188
+ callback routing through the real poller (dec: tap delivered as a whisper,
189
+ pause tap persists state, unknown data answered without a crash),
190
+ format corpus (`test/corpus.test.mjs` + `corpus-runner.mjs`: 76 hostile bodies ×
191
+ 3 requested modes, recorded live parse verdicts vs the local checker, the 21-cell
192
+ resolution matrix, cap-crossing bodies; `test/live-html-probe.mjs` re-verifies
193
+ the checker against the live API, opt-in and never run by `npm test`).
194
+
195
+ ## Live E2E (postponed)
196
+
197
+ Configure the env vars above, add `packages/telegram` to the pi `packages`
198
+ list (like `packages/providers`), then: `testToken` → `telegram_send` a text →
199
+ `telegram_inbox` to pull replies (or explicitly `BRAID_TG_POLL_ENABLED=1` /
200
+ `/telegram enable` for the background injector). `voice: true` synthesizes
201
+ through `braid.telegram.tts` (default: latest edge-tts via uvx, rate +20%,
202
+ voices picked by script) → ffmpeg ogg/opus → `sendVoice`; `source: "none"`
203
+ disables synthesis, and `voice: true` then returns a clear "no TTS source is
204
+ configured" error instead of silently sending text. macOS `say` is the
205
+ explicit-config emergency fallback (`source: "say"` or `"edge-tts+say"`).
@@ -0,0 +1,190 @@
1
+ <!-- status: current -->
2
+
3
+ # Telegram LIVE-UPDATE tracked message + rich formatting: Design Decisions
4
+
5
+ Status: **current (implemented)**. This list is the decision surface; the fixture
6
+ tests in `packages/telegram/test/live-update.test.mjs`, `test/format.test.mjs`,
7
+ and the live-update additions to `test/index.test.mjs` / `test/tool-logic.test.mjs`
8
+ pin the implemented defaults. Any owner change below is a config/env change, not code.
9
+
10
+ ## Feature recap (the "editing workaround")
11
+
12
+ The owner's request: _"make tg bot handler to provide live updates via workaround
13
+ with ability to edit telegram message constantly. So when pi receives my message
14
+ it would immediately send that tracked message and update it constantly, then when
15
+ done edit finally that message to 'done' and then send final beautiful message with
16
+ summary and report."_
17
+
18
+ Telegram has **no streaming API** - the workaround is message _editing_: instead of
19
+ sending a new progress message per update (which reorders and spams the chat), the
20
+ bot sends ONE message immediately on inbound ("⚙️ Working… received your message"),
21
+ then re-edits that same message in place (`editMessageText`) as work progresses -
22
+ Telegram's edit affordance keeps the message pinned at its original position - and
23
+ finally edits it to "✅ Done" plus sends a SEPARATE beautiful markdown summary.
24
+
25
+ ## Decision 1: opt-in vs always-on
26
+
27
+ **Implemented: default ON; `BRAID_TG_LIVE_UPDATE_ENABLED=0` disables.**
28
+ When disabled there is NO live-update behavior at all: no tracked message send, no
29
+ `turn_end` finish listener, no state file, no rate limiter. The telegram extension
30
+ already requires `BRAID_TG_BOT_TOKEN`; live-update is the only extra gate and it is
31
+ on by default.
32
+
33
+ ## Decision 2: the tracked message lifecycle
34
+
35
+ **Implemented: `start()` → `update()`\* → `finish()`.**
36
+
37
+ - `start()` (called on every escalated inbound owner message): sends the immediate
38
+ "⚙️ Working… received your message" via `sendMessage`, records the returned
39
+ `message_id` + `chat_id` in-memory AND persists them to
40
+ `~/.pi/agent/braid/telegram-live-update.json` (mirrors `telegram-state.json`
41
+ conventions; `BRAID_HOME` overrides the root) so
42
+ a process reload can resume editing the same message.
43
+ - `start()` is **idempotent**: when a tracked message already exists (incl. a
44
+ persisted one from a prior run), it is REUSED via an edit-re-pin - never a
45
+ duplicate send (the "no new-message spam" invariant).
46
+ - `update(text)` - public tracker API - edits the tracked message in place,
47
+ idempotent (no-op with no tracked message), clipped to 4096 chars.
48
+
49
+ _`update()` wiring note:_ the brief explicitly wires `start()` (on inbound) and
50
+ `finish()` (on `turn_end`). Periodic mid-turn `update()` calls need a verified
51
+ streaming hook that isn't on pi-core's documented extension event surface yet;
52
+ `update()` is shipped, tested, and ready to be wired to such a hook later (see
53
+ **Follow-ups**).
54
+
55
+ ## Decision 3: finish(): Done-edit + SEPARATE final summary
56
+
57
+ **Implemented: two steps, ordered.**
58
+
59
+ 1. Edit the tracked message to "✅ Done — full report below." (`TRACKED_DONE_TEXT`).
60
+ 2. Send a SEPARATE final summary message (not an edit) via `sendMessageChunks`
61
+ (split-to-fit 4096). The summary is **crafted** via the existing key-ring-backed
62
+ craft path (`createRichCraftHook` - `completeSimple` through
63
+ `ctx.modelRegistry`, never the bot token) using a NEW rich prompt
64
+ (`buildRichSummaryPrompts`, rich-markdown variant) that FLAGS the model to use
65
+ Telegram Markdown structure. When no craft hook is available/fit, the raw final
66
+ answer text is delivered instead (still split-to-fit).
67
+
68
+ The final summary always goes out even if no tracked message survived - the report
69
+ is the deliverable.
70
+
71
+ ## Decision 4: finish() gating on turn_end
72
+
73
+ **Implemented: reuse `shouldNotifyTurnEnd`** (the same predicate the turn-complete
74
+ auto-notify already uses, `notify/craft.ts`): finish fires only when the turn ended
75
+ with a completed plain-text answer (`stopReason !== "toolUse"`, non-empty text). A
76
+ trailing-tool-call turn keeps the tracked message OPEN (work continues next turn).
77
+
78
+ Note the two features are independent (live-update is default ON, notify is
79
+ opt-in, both `turn_end` listeners): `BRAID_TG_NOTIFY_ENABLED` sends its own
80
+ separate update capped at `braid.telegram.notifyMaxChars` (default 1200),
81
+ `BRAID_TG_LIVE_UPDATE_ENABLED` (default ON)
82
+ sends the tracked-message + full report. If the owner enables both, a turn_end
83
+ delivers two messages (notify + full report) - an intentional,
84
+ documented consequence of the two features staying independent.
85
+
86
+ ## Decision 5: rate limit
87
+
88
+ **Implemented: sliding window, default 4/min** (`DEFAULT_LIVE_UPDATE_MAX_PER_MIN`),
89
+ reusing `SlidingWindowLimiter` (`notify/rate-limit.ts`). Applied to `start()` and
90
+ `update()` (progress edits). **`finish()` is NOT rate-limited** - it is terminal and
91
+ always attempts its done-edit + summary; a saturated window must not strand the owner
92
+ on a stuck "Working…" message. Override via `BRAID_TG_LIVE_UPDATE_MAX_PER_MIN`.
93
+
94
+ ## Decision 6: owner-gate
95
+
96
+ The tracked message is only ever started from `deliverOwnerMessage` - the SINGLE
97
+ inbound escalation seam in `src/index.ts` (shared by the background poller, the
98
+ `telegram_poller` tool, and `/telegram enable`), which fires only for allow-listed
99
+ owner messages. The inbox agentic-pull tool (`telegram_inbox`) does NOT itself send
100
+ messages - it returns structured results for the agent to escalate via core's
101
+ `sendUserMessage`, which this package cannot intercept - so wiring there would need a
102
+ core-side hook (out of scope; see Follow-ups).
103
+
104
+ ## Decision 7: rich / split-to-fit telegram_send
105
+
106
+ **Implemented (no breaking change to the default path):**
107
+
108
+ - `telegram_send` gained optional `rich: boolean` and `parse_mode: "HTML"|"Markdown"`.
109
+ - `rich: true` **bypasses** the settings-driven send cap
110
+ (`braid.telegram.sendMaxChars`, default 4096) and the craft hook entirely:
111
+ the text is delivered verbatim via `sendMessageChunks`, split on paragraph→line→word
112
+ boundaries (never mid-word), every chunk carrying a `(i/N)` continuity marker.
113
+ Bodies that ship with `parse_mode` HTML are split **tag-aware**
114
+ (`splitHtmlText`): a cut never lands inside a tag or entity, the tags open at
115
+ the cut are closed at the end of the chunk and reopened at the start of the
116
+ next one, so every chunk parses on its own (the same splitter serves the
117
+ plain-path split when a converted body exceeds Telegram's 4096 cap). A tag
118
+ longer than the cap cannot be represented without cutting it: such a body is
119
+ refused with an explicit over-cap error and nothing is sent.
120
+ - Default path (`rich` unset): plain text up to the cap (+ craft hook above it), and
121
+ `parse_mode` is honored on the single send. The conversion is capped at the
122
+ effective limit: when the HTML rendering of a MarkdownV2 body would cross the
123
+ cap (`braid.telegram.sendMaxChars` or an explicit `limit`), the conversion is
124
+ refused and the authored text ships as-is, so the wire never exceeds the cap.
125
+ - **Markdown safety net** (`isBalancedMarkdown` in `format.ts`): Telegram's
126
+ `Markdown` parse*mode rejects unbalanced emphasis/link delimiters. When the payload
127
+ has unbalanced `**`/`*`/backtick/`[` `]`, `parse_mode`is dropped and the text is
128
+ delivered plain (with a`warn` in the outcome) instead of surfacing an API error.
129
+ HTML has no paired-delimiter rule and passes through. The MarkdownV2→HTML
130
+ conversion is validated against Telegram's HTML parser (`checkTelegramHtml`)
131
+ before it ships: a conversion whose output would cross delimiter pairs or
132
+ emit unbalanced/broken author tags is refused (the authored text ships on
133
+ the unconverted path), so the converter cannot turn a deliverable body into
134
+ an API error.
135
+
136
+ ## Env flags (BRAID*TG*\_ canonical; TELEGRAM\_\_ owner-side aliases)
137
+
138
+ | Var | Purpose | Default |
139
+ | ---------------------------------- | ------------------------------------------------------- | --------------------------------------------- |
140
+ | `BRAID_TG_LIVE_UPDATE_ENABLED` | Live-update tracked message (default ON; `=0` disables) | ON |
141
+ | `BRAID_TG_LIVE_UPDATE_MAX_PER_MIN` | Progress-edit rate cap (sliding window) | 4 |
142
+ | `BRAID_TG_LIVE_UPDATE_STATE_FILE` | Tracked-message state file path | `~/.pi/agent/braid/telegram-live-update.json` |
143
+
144
+ ## API surface (new/changed)
145
+
146
+ - `telegram-client.ts`: `editMessageText(token, chatId, messageId, text, opts?)`,
147
+ `sendChatAction(token, chatId, action?)`, `parseMode` option (`parse_mode` in the
148
+ body) on `sendMessage` + `editMessageText`; `TelegramClient` interface + factory
149
+ updated.
150
+ - `format.ts`: `splitLongText(text, maxLen?, reserved?)`,
151
+ `splitHtmlText(text, maxLen?, reserved?)`, `isBalancedMarkdown(text)`,
152
+ `convertMarkdownV2ToHtml(text, {renderedCap?})`, `resolveDeliveryFormat(requested, text, detectMarkdown?, renderedCap?)`,
153
+ `sendMessageChunks(client, {token, chatId, text, parseMode?})`.
154
+ - `html-check.ts`: `checkTelegramHtml(text)`, a local mirror of Telegram's HTML
155
+ parser rules (tag set, attribute quoting, span/tg-emoji attribute requirements,
156
+ strict nesting), calibrated against the live API by `test/live-html-probe.mjs`.
157
+ - `notify/live-update.ts`: `createLiveUpdateTracker` (start/update/finish/isActive/
158
+ getState) + `loadLiveState`/`saveLiveState`/`clearLiveState` + `defaultLiveUpdateStateFile`.
159
+ - `notify/craft.ts`: `buildRichSummaryPrompts` (rich-markdown variant).
160
+ - `notify/model.ts`: `createRichCraftHook` (rich-prompt text-craft hook).
161
+ - `tool-logic.ts`: `TelegramSendToolParams.rich` / `.parse_mode`; `chunks` in outcome.
162
+ - `src/index.ts`: live-update section (tracker + `turn_end` finish listener),
163
+ shared `deliverOwnerMessage` seam, new ENV consts + aliases, updated telegram_send
164
+ tool params + guidelines.
165
+
166
+ ## Tests
167
+
168
+ Fixture-based, zero network: live-update tracker (start/update/finish/restart/rate-
169
+ limit/failure paths), format (split boundaries, markdown guard, chunk markers, cap),
170
+ client (editMessageText body + `true` result, parse_mode, sendChatAction), tool-logic
171
+ (rich split/honored parse_mode/unbalanced fallback), index (env aliases, disabled-no-
172
+ behavior, enabled pins Working… + finishes, trailing tool call keeps open).
173
+ Run: `cd packages/telegram && node test/run-tests.mjs` (all files green) and
174
+ `npx tsc --noEmit` (clean).
175
+
176
+ ## Forbidden-files check
177
+
178
+ - No changes to packages/core or packages/providers (incl. provider catalogs).
179
+ - No package-lock.json diff, no npm install, no git checkout/reset/stash.
180
+ - Scope is entirely inside packages/telegram.
181
+
182
+ ## Follow-ups
183
+
184
+ - Wire periodic `update()` to a verified streaming hook when one is available, so the
185
+ tracked message updates mid-turn rather than only at start/finish.
186
+ - Delivered: an HTML-tag-aware splitter (`splitHtmlText`) keeps rich messages
187
+ with markup spanning chunk boundaries parseable per chunk; a converted body
188
+ that would cross the cap is refused instead of split.
189
+ - If agent-driven `telegram_inbox` escalation should also trigger `start()`, that
190
+ needs a core-side hook (sendUserMessage is called from core, not this package).
@@ -0,0 +1,151 @@
1
+ <!-- status: current -->
2
+
3
+ # Telegram inline-keyboard pause/resume control: Design Decisions
4
+
5
+ Status: **current (implemented)**. Fixture tests: `packages/telegram/test/pause-control.test.mjs`
6
+ (handler, keyboard, persistence), callback-routing + **pause-gate** additions to
7
+ `test/inbound-poller.test.mjs`, and `reply_markup`/`answerCallbackQuery` client additions in
8
+ `test/telegram-client.test.mjs`. Pause-gate consumer behavior (inbound held + «⏸ пауза» ack while
9
+ paused) is implemented in `lib/inbound-poller.ts` (Decision 6).
10
+
11
+ ## Feature recap
12
+
13
+ The owner asked: _"Seems need a button for pause or whatever control this through tg"_ - a
14
+ button to control the agent from Telegram. This adds an inline keyboard (⏸ Pause / ▶ Resume /
15
+ 📊 Status) and `callback_query` handling in the inbound poller: **pause** sets the persisted flag
16
+ to true, **resume** to false, **status** replies with the current state. Every tap is answered via
17
+ `answerCallbackQuery` (toast with the state) and the keyboard is updated in place via
18
+ `editMessageText` - paused shows ▶ Resume, running shows ⏸ Pause.
19
+
20
+ ## Decision 1: the pause flag is a CONSUMER signal, not a gate
21
+
22
+ The module only persists the flag and keeps the keyboard in sync. It **never** blocks the poller,
23
+ drops inbound, or changes the core loop. The orchestrator reads the flag via the exported
24
+ `getPauseState()` and decides how to honor it. This package stays delivery-neutral.
25
+
26
+ ## Decision 6: the consumer gate lives in the poller
27
+
28
+ The orchestrator-side consumer behavior is the **pause gate on non-callback inbound**: in
29
+ `inbound-poller.ts`, every owner/bootstrap message (text/photo/voice) checks the persisted pause
30
+ flag via an injected `getPauseState` reader BEFORE `onOwnerMessage` is called. While paused:
31
+
32
+ - the message is **not injected** - `onOwnerMessage` / `pi.sendUserMessage` are never called (no
33
+ turn is triggered); the owner is told once with `«⏸ пауза»` + a ▶️ Resume hint;
34
+ - the ack is sent **once per paused period** (reply-storm guard), keyed on the period's `updatedAt`:
35
+ `pauseAckUpdatedAt` holds the `updatedAt` of the last acked period, so messages inside the same
36
+ period are held silently and any resume/re-pause transition changes `updatedAt` and re-arms the
37
+ ack for the next period. Not persisted - a poller restart re-acks once (fresh session);
38
+ - the held message is still confirmed (offset advances, confirm-on-read) - the owner was acked,
39
+ and after ▶️ Resume the next message flows normally;
40
+ - retry-buffer messages are gated too (a queued owner message stays queued while paused);
41
+ - **callback_query taps are NEVER gated** - pause/resume/status buttons are the owner's control
42
+ path and are routed to `handlePauseCallbackQuery` even while paused.
43
+
44
+ DI mirrors the callback handler: `InboundPollerOptions.getPauseState` (default: never-paused, so
45
+ unwired pollers are deterministic in tests) is injected by `index.ts` with the real
46
+ `getPauseState()` at every `startSingletonPoller` site, so the gate is ON in production whenever
47
+ the flag is paused. `sendMessage` (with the poller's `fetchFn`) emits the ack. Config-free: no new
48
+ settings/env keys; the gate activates purely on `getPauseState().paused`.
49
+
50
+ ## Decision 2: config via settings, not process.env
51
+
52
+ No new `BRAID_TG_*` env keys. The state file defaults to
53
+ `<braidDir>/telegram/pause-state.json` (`~/.pi/agent/braid/telegram/pause-state.json`, relocated
54
+ by `BRAID_HOME`) and can be overridden via `braid.telegram.pauseStateFile` in
55
+ `~/.pi/agent/settings.json` (mirrors `packages/core/src/lib/config/braid-settings.ts`).
56
+ `resolvePauseStateFile()` is the write target; `resolvePauseStateReadFile()` / `getPauseState()`
57
+ prefer the braid-root file and fall back to the pre-move `~/.pi/agent/telegram-pause-state.json`
58
+ until the first post-move write.
59
+
60
+ ## Decision 3: atomic persistence
61
+
62
+ `savePauseState` writes `JSON + "\n"` to `<stateFile>.tmp` then `rename()`s over the target
63
+ (write-temp-rename), so a crash mid-write can never leave a truncated state file. `loadPauseState`
64
+ reads safely and treats a missing/corrupt file as "not paused". Shape: `{ paused: boolean, updatedAt: number }`.
65
+
66
+ ## Decision 4: callback routing lives in the poller
67
+
68
+ `getUpdates` already returns `callback_query` updates. The poller routes allow-listed taps to a new
69
+ `onCallbackQuery` option (wired in `index.ts` to the pause handler); foreign taps are dropped and
70
+ counted (`callbackRejected`), never handed to `onOwnerMessage` - a button tap must not trigger a turn.
71
+ Handler failures are requeued and retried next cycle (the retry buffer is now kind-aware:
72
+ `"message" | "callback"`). No handler → taps are dropped (offset still advances, confirm-on-read).
73
+
74
+ ## Decision 5: keyboard refresh, idempotent
75
+
76
+ `handlePauseCallbackQuery` re-reads the persisted state after a transition so idempotent re-taps
77
+ (pause while already paused) don't rewrite `updatedAt`, but are still answered and refresh the
78
+ keyboard. API failures (answer/edit) are reported in the outcome, never thrown; the flag is
79
+ persisted first, so a failed answer never loses the command.
80
+
81
+ ## Decision 7: control-keyboard placement lives at the POLLER-START seam
82
+
83
+ The owner must actually SEE the ⏸ Pause / ▶ Resume / 📊 Status control - not just have the code exist.
84
+ `sendControlKeyboard` was exported but NOBODY called it (code-only). The natural lifecycle seam is
85
+ `startSingletonPoller` in `src/index.ts` - the orchestrator-side wiring that constructs and starts the
86
+ inbound poller (the entity that owns the poller). On EVERY poller start (env `BRAID_TG_POLL_ENABLED=1`
87
+ auto-start at boot, `/telegram enable`, `telegram_poller enable`) it fires a new `placeControlKeyboard`
88
+ helper: reads the persisted pause flag via the SAME `getPauseState` reader the poller gate uses, then
89
+ `sendControlKeyboard` places the control in the owner chat (`resolveChatId()` - `BRAID_TG_CHAT_ID`, else
90
+ the first allow-listed sender, else captured from the first owner message).
91
+
92
+ Delivery-neutrality is kept: nothing self-sends on import - the FACTORY (the consumer that owns the
93
+ poller) places, via a new `onStart` hook on `startSingletonPoller`. When the owner chat is not yet known
94
+ at start (no chat-id env and no allow-list - pure first-message bootstrap), placement is DEFERRED
95
+ (`placeControlKeyboard` returns `deferred: true`, nothing sent) and fired once when the first owner
96
+ message captures `ownerChatId` (`deliverOwnerMessage` → `flushDeferredControlPlacement`, one-shot).
97
+ Post-resume correctness is already cheap and unchanged: `handlePauseCallbackQuery` edits the SAME
98
+ keyboard in place (`editMessageText`) on every tap, so pause/resume refresh the existing message - no
99
+ per-transition re-send/spam.
100
+
101
+ ## Decision 8: placement is a once-per-poller-start, best-effort, DI-able helper
102
+
103
+ `placeControlKeyboard(chatId, deps)` in `lib/pause-control.ts` reads the flag (DI-able `getPauseState`;
104
+ default never-paused) and delegates to `sendControlKeyboard`. Failures are reported, never thrown; a
105
+ failed placement is NOT retried (one-shot, logged - avoids side-effect storms). A no-op re-enable (the
106
+ singleton is already running) does NOT re-place. Test coverage: `placeControlKeyboard` unit tests
107
+ (correct chatId + paused→▶ Resume / running→⏸ Pause, defer on unknown chat, failure reporting) in
108
+ `test/pause-control.test.mjs`, plus end-to-end wiring tests in `test/index.test.mjs` (boot auto-start
109
+ places into the owner chat; `/telegram enable` places; no-chat bootstrap defers to the first owner
110
+ message, one-shot). The boot placement shifts the exact `sendMessage` counts of the live-update factory
111
+ tests (they now see the control keyboard as `sent[0]`) - those tests were updated accordingly.
112
+
113
+ ## API surface (new)
114
+
115
+ - `telegram-client.ts`: `answerCallbackQuery(token, cqId, { text?, showAlert? })`,
116
+ `replyMarkup` option (`reply_markup` in the body) on `sendMessage`/`editMessageText`,
117
+ `TelegramReplyMarkup`/`TelegramSendOptions` types, `TelegramCallbackQuery` on `TelegramUpdate`,
118
+ factory merges per-call options with bound ones.
119
+ - `lib/pause-control.ts`: `getPauseState(stateFile?)`, `defaultPauseStateFile()`,
120
+ `resolvePauseStateFile()`, `loadPauseState`/`savePauseState`, `buildControlKeyboard(paused)`,
121
+ `sendControlKeyboard(client, token, chatId, paused)`, `placeControlKeyboard(chatId, deps)`,
122
+ `handlePauseCallbackQuery(deps, cq)`.
123
+ - `lib/inbound-poller.ts`: `onCallbackQuery` option; `getPauseState` / `pauseAckText` options
124
+ (pause gate); `PAUSED_INBOUND_ACK_TEXT`; `PollOnceResult.callbacks` / `.callbackRejected` /
125
+ `.paused` (held by the gate).
126
+ - `src/index.ts`: wires `onCallbackQuery` into every poller start site (tool command, `/telegram
127
+ enable`, env auto-start); wires the real `getPauseState()` as the poller's pause-gate reader;
128
+ re-exports the pause-control public surface for the orchestrator.
129
+
130
+ ## Tests
131
+
132
+ Zero network: keyboard builder (running/paused buttons), persistence incl. atomic no-`.tmp`-left-behind,
133
+ `getPauseState`, `sendControlKeyboard`, handler pause/resume/status/unknown/idempotent/restart/API-failure,
134
+ poller routing (owner vs foreign, no-handler drop, retry), pause gate (held + «⏸ пауза» ack + no
135
+ injection, once-per-paused-period in-batch and cross-cycle, resume callback ungated + post-resume
136
+ delivery, normal flow while running, re-arm on a new paused period, non-fatal ack failure),
137
+ `placeControlKeyboard` (running/paused/deferred/failure), poller-start placement wiring (boot auto-start,
138
+ `/telegram enable`, no-chat bootstrap defer one-shot), client `answerCallbackQuery` + `reply_markup`
139
+ bodies + factory merge.
140
+ Run: `cd packages/telegram && node test/run-tests.mjs` (14 files green) and `npx tsc --noEmit` (clean).
141
+
142
+ ## Forbidden-files check
143
+
144
+ - No changes to packages/providers (incl. provider catalogs), no package-lock diff, no npm install,
145
+ no git checkout/reset/stash, no push.
146
+ - Scope is entirely inside packages/telegram.
147
+
148
+ ## Follow-ups
149
+
150
+ - Telemetry: the gate currently holds (does not queue) paused inbound by design - the owner is acked
151
+ and re-sends after resume. Queue-until-resume would be a behavior change; not planned.