pi-braid-telegram 0.0.0-stage → 0.1.5
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 +21 -0
- package/README.md +221 -2
- package/build-info.json +9 -0
- package/docs/live-update.md +190 -0
- package/docs/pause-control.md +151 -0
- package/docs/turn-complete-notify.md +110 -0
- package/package.json +62 -4
- package/src/index.ts +1853 -0
- package/src/lib/audio-normalize.ts +91 -0
- package/src/lib/decision-buttons.ts +574 -0
- package/src/lib/document.ts +203 -0
- package/src/lib/format.ts +822 -0
- package/src/lib/html-check.ts +280 -0
- package/src/lib/inbound-poller.ts +1248 -0
- package/src/lib/inbound-queue.ts +157 -0
- package/src/lib/local-time.ts +181 -0
- package/src/lib/notify/agent-delivery.ts +29 -0
- package/src/lib/notify/craft.ts +157 -0
- package/src/lib/notify/live-update.ts +716 -0
- package/src/lib/notify/model.ts +197 -0
- package/src/lib/notify/rate-limit.ts +44 -0
- package/src/lib/notify/turn-notify.ts +138 -0
- package/src/lib/pause-control.ts +425 -0
- package/src/lib/photo.ts +182 -0
- package/src/lib/poller-lock.ts +127 -0
- package/src/lib/session-role.ts +32 -0
- package/src/lib/state-paths.ts +164 -0
- package/src/lib/stt-keys.ts +211 -0
- package/src/lib/telegram-client.ts +699 -0
- package/src/lib/telegram-inbox.ts +203 -0
- package/src/lib/telegram-settings.ts +92 -0
- package/src/lib/tool-logic.ts +604 -0
- package/src/lib/transcribe.ts +574 -0
- package/src/lib/tts.ts +451 -0
- package/src/lib/venice-catalog-base-url.ts +45 -0
- package/src/lib/voice.ts +192 -0
- package/src/lib/watchdog-tunnel.ts +102 -0
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,222 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-braid-telegram
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<!-- status: current -->
|
|
4
|
+
|
|
5
|
+
Bidirectional Telegram channel for pi-braid.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:pi-braid-telegram
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
One command, no clone, no build: `pi install` registers the package with pi and installs it under pi's npm directory.
|
|
12
|
+
|
|
13
|
+
- **Outbound**: `telegram_send` tool - the agent sends text messages to the
|
|
14
|
+
owner (or voice notes once a TTS source is configured). Messages are LONG by
|
|
15
|
+
default: plain text sends verbatim up to the cap (`braid.telegram.sendMaxChars`
|
|
16
|
+
in `~/.pi/agent/settings.json`, default 4096 = Telegram's per-message limit);
|
|
17
|
+
there is no tweet-length default. Over the cap, the crafting model distills
|
|
18
|
+
first and `rich: true` splits-to-fit instead. Returns the delivery
|
|
19
|
+
result (message_id or error). Outbound sends the raw trimmed
|
|
20
|
+
text verbatim - the `[tg-end]` marker is an INBOUND parse boundary only and
|
|
21
|
+
never appears on outbound messages.
|
|
22
|
+
- **Turn-complete auto-notify (opt-in)**: with
|
|
23
|
+
`BRAID_TG_NOTIFY_ENABLED=1`, when a turn ends with a completed plain-text
|
|
24
|
+
answer (no trailing tool call), a second LLM call (key-ring-backed via
|
|
25
|
+
`ctx.modelRegistry`, never the bot token) distills the answer into an owner
|
|
26
|
+
update capped at `braid.telegram.notifyMaxChars` (default 1200) and sends it
|
|
27
|
+
through the same outbound seam. Rate-limited (default 4/min,
|
|
28
|
+
`BRAID_TG_NOTIFY_MAX_PER_MIN`); model override via `BRAID_TG_NOTIFY_MODEL`.
|
|
29
|
+
Default OFF - no unsolicited messages.
|
|
30
|
+
- **Live-update tracked message (default-on)**: on every escalated
|
|
31
|
+
inbound owner message the bot immediately sends one "⚙️ Working…" message,
|
|
32
|
+
edits it in place (Telegram's edit affordance pins it at its position) as work
|
|
33
|
+
progresses, then on a completed turn edits it to "✅ Done" and sends a SEPARATE
|
|
34
|
+
beautifully formatted full report via rich Markdown + split-to-fit
|
|
35
|
+
(`sendMessageChunks`). Progress edits are rate-limited (default 4/min);
|
|
36
|
+
`finish()` is never dropped. Default ON - set
|
|
37
|
+
`BRAID_TG_LIVE_UPDATE_ENABLED=0` to disable.
|
|
38
|
+
- **Rich / split-to-fit outbound**: `telegram_send` gained optional
|
|
39
|
+
`rich: true` and `parse_mode: "HTML"|"Markdown"`. `rich` bypasses the send
|
|
40
|
+
cap and the craft hook, delivering the text verbatim split on
|
|
41
|
+
paragraph→line→word boundaries with a `(i/N)` marker; bodies that ship as
|
|
42
|
+
HTML are split tag-aware (no cut inside a tag/entity; a chunk closes the tags
|
|
43
|
+
it opens, the next chunk reopens them). A Markdown balance guard
|
|
44
|
+
(`isBalancedMarkdown`) drops `parse_mode` to plain text (never an API error)
|
|
45
|
+
when Telegram would reject unbalanced emphasis/link delimiters, and the
|
|
46
|
+
MarkdownV2→HTML conversion is validated against Telegram's HTML parser
|
|
47
|
+
(`checkTelegramHtml`): a conversion that would cross delimiter pairs, emit an
|
|
48
|
+
unbalanced/broken author tag or cross the cap is refused, and the body ships
|
|
49
|
+
as authored. Default path (no `rich`) is plain text with the
|
|
50
|
+
settings-driven cap.
|
|
51
|
+
- **Decision buttons (tap-to-answer)**: `telegram_send` gained optional
|
|
52
|
+
`options` - 1-2 rows of up to 4 `{ label, value }` buttons attached to the
|
|
53
|
+
message as an inline keyboard - and `decisionToken`, their correlation id
|
|
54
|
+
(generated when omitted, echoed in the tool result). A tap is acknowledged
|
|
55
|
+
with a toast and reaches the agent as a normal inbound message
|
|
56
|
+
`[decision] <token> → <label> (<value>)` (through the same coalescing
|
|
57
|
+
window and steer handoff as a text message). The owner can always answer
|
|
58
|
+
in words;
|
|
59
|
+
malformed rows refuse the send (nothing delivered); `pause`/`resume`/`status`
|
|
60
|
+
control taps keep their own handler.
|
|
61
|
+
- **Inbound (agentic pull, default)**: `telegram_inbox` tool - the agent calls
|
|
62
|
+
it mid-turn to fetch pending messages from whitelisted chats via `getUpdates`
|
|
63
|
+
(offset confirm-on-read, persisted across restarts). Results come back as
|
|
64
|
+
braid-whisper envelopes (context, NOT synthetic user turns) with sender
|
|
65
|
+
provenance + a per-message `fromOwner` flag; only owner messages may be
|
|
66
|
+
escalated to a turn.
|
|
67
|
+
- **Inbound (background poller, opt-in only)**: the legacy long-poll injector
|
|
68
|
+
is OFF by default. Explicitly enable via `BRAID_TG_POLL_ENABLED=1` at load,
|
|
69
|
+
`/telegram enable`, or the `telegram_poller` tool (`action:"enable"`). At
|
|
70
|
+
most ONE poller per process (singleton). Owner messages are delivered as
|
|
71
|
+
`[braid-whisper - telegram] ... [braid-whisper-end]` envelopes.
|
|
72
|
+
|
|
73
|
+
Zero npm runtime dependencies: the client is built on Node 22's global fetch.
|
|
74
|
+
No live credentials, no real network calls in the test suite (recorded-fixture
|
|
75
|
+
fake fetch).
|
|
76
|
+
|
|
77
|
+
## Install
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pi install npm:pi-braid-telegram
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Or register the workspace in `~/.pi/agent/settings.json`:
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"packages": ["file:~/pi-braid/packages/telegram"]
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## What this does not do
|
|
92
|
+
|
|
93
|
+
- It does not poll or push by default. The background poller is OFF, turn-complete auto-notify is
|
|
94
|
+
opt-in, and the tracked in-progress message fires only on an escalated inbound owner message.
|
|
95
|
+
- It does not accept strangers. Only whitelisted chats/users are read, and only owner messages may
|
|
96
|
+
be escalated to a turn.
|
|
97
|
+
- It does not bundle a Telegram client library - the Bot API calls use Node 22's global fetch.
|
|
98
|
+
|
|
99
|
+
## Activation preconditions
|
|
100
|
+
|
|
101
|
+
- `BRAID_TG_BOT_TOKEN` set - this is what enables the channel.
|
|
102
|
+
- A bot token from @BotFather; optionally `BRAID_TG_CHAT_ID` / `BRAID_TG_ALLOWED_USERS` for the
|
|
103
|
+
allow-list.
|
|
104
|
+
|
|
105
|
+
## Settings cadence
|
|
106
|
+
|
|
107
|
+
Settings live in `~/.pi/agent/settings.json`. `braid.telegram.pauseStateFile`
|
|
108
|
+
is captured as the callback write target when the extension loads. `getPauseState()`
|
|
109
|
+
resolves its read path per call, so restart after changing the setting to keep
|
|
110
|
+
reads and writes aligned. `braid.telegram.tts` is loaded into the voice hook at
|
|
111
|
+
extension load; restart after changing its source, rate, or voices.
|
|
112
|
+
The send and notification caps (`braid.telegram.sendMaxChars` and
|
|
113
|
+
`braid.telegram.notifyMaxChars`) are re-read per operation and change without
|
|
114
|
+
a restart.
|
|
115
|
+
|
|
116
|
+
## Environment variables
|
|
117
|
+
|
|
118
|
+
| Var | Purpose | Required |
|
|
119
|
+
| ---------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------- |
|
|
120
|
+
| `BRAID_TG_BOT_TOKEN` | Telegram bot token from @BotFather | **enables the channel** (if-has-token guard) |
|
|
121
|
+
| `BRAID_TG_CHAT_ID` | Owner chat/user id for outbound + allow-list | optional (bootstrap fallback) |
|
|
122
|
+
| `BRAID_TG_ALLOWED_USERS` | Extra allow-list ids/@usernames (comma-separated) | optional |
|
|
123
|
+
| `BRAID_TG_STATE_FILE` | Offset/state file path | optional, default `~/.pi/agent/braid/telegram-state.json` |
|
|
124
|
+
| `BRAID_TG_POLL_INTERVAL_MS` | Poll cadence (background poller) | optional, default 1500 |
|
|
125
|
+
| `BRAID_TG_POLL_ENABLED` | Explicit opt-in: auto-start the background poller at load (`1`) | optional; default OFF (`TELEGRAM_POLL_ENABLED` alias) |
|
|
126
|
+
| `BRAID_TG_POLL_DISABLED` | Legacy opt-out flag (superseded by default-off; still honored) | optional |
|
|
127
|
+
| `BRAID_TG_COALESCE_WINDOW_MS` | Coalescing window for inbound owner bursts (ms; `0` = next tick) | optional, default 2000 |
|
|
128
|
+
| `BRAID_TG_VERBOSE` | Route debug/info to the notify sink (still never console.\*) | optional |
|
|
129
|
+
| `BRAID_TG_NOTIFY_ENABLED` | Opt-in: turn-complete auto-notify, default OFF | optional |
|
|
130
|
+
| `BRAID_TG_NOTIFY_MODEL` | Crafting model override (`provider/model-id`); default = session model | optional |
|
|
131
|
+
| `BRAID_TG_NOTIFY_MAX_PER_MIN` | Auto-notify rate cap (sliding window), default 4 | optional |
|
|
132
|
+
| `BRAID_TG_LIVE_UPDATE_ENABLED` | Live-update tracked message, default ON (`0` disables) | optional |
|
|
133
|
+
| `BRAID_TG_LIVE_UPDATE_MAX_PER_MIN` | Live-update progress-edit rate cap (sliding window), default 4 | optional |
|
|
134
|
+
| `BRAID_TG_LIVE_UPDATE_STATE_FILE` | Tracked-message state file path | optional, default `~/.pi/agent/braid/telegram-live-update.json` |
|
|
135
|
+
|
|
136
|
+
All `BRAID_TG_*` names have `TELEGRAM_*` owner-side aliases; `BRAID_TG_*` wins
|
|
137
|
+
when both are set. No token → the extension factory returns immediately; no
|
|
138
|
+
tools, no command, no poller, nothing crashes (debug log only).
|
|
139
|
+
|
|
140
|
+
## Tools & command
|
|
141
|
+
|
|
142
|
+
| Surface | Purpose |
|
|
143
|
+
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
144
|
+
| `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 |
|
|
145
|
+
| `telegram_inbox` | Agentic pull - fetch pending messages, confirm-on-read, `fromOwner` flag |
|
|
146
|
+
| `telegram_poller` | Enable/disable the singleton background poller (agent tool) |
|
|
147
|
+
| `/telegram enable` | Start the singleton background poller |
|
|
148
|
+
| `/telegram disable` | Stop the singleton background poller |
|
|
149
|
+
| `/telegram send <text>` | Send a message from the shell (raw trimmed text, no marker) |
|
|
150
|
+
| `/telegram status` | Show channel + poller state |
|
|
151
|
+
|
|
152
|
+
## Components
|
|
153
|
+
|
|
154
|
+
| Path | Purpose |
|
|
155
|
+
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
156
|
+
| `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 |
|
|
157
|
+
| `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 |
|
|
158
|
+
| `src/lib/inbound-poller.ts` | Long-poll loop: allow-list + bootstrap filtering, group/non-text rejection, confirm-on-read offset persistence, injection retry buffer |
|
|
159
|
+
| `src/lib/telegram-inbox.ts` | `fetchInbox` agentic-pull core: fetch → filter → return with `fromOwner` → confirm offset; braid-whisper envelope formatting |
|
|
160
|
+
| `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 |
|
|
161
|
+
| `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 |
|
|
162
|
+
| `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) |
|
|
163
|
+
| `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 |
|
|
164
|
+
| `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 |
|
|
165
|
+
| `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 |
|
|
166
|
+
| `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) |
|
|
167
|
+
| `src/lib/notify/rate-limit.ts` | Sliding-window rate limiter (max N per minute, deterministic timestamps) |
|
|
168
|
+
| `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 |
|
|
169
|
+
| `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 |
|
|
170
|
+
| `src/lib/notify/turn-notify.ts` | `turn_end` handler orchestration: detect → rate-limit → craft → deliver (injectable for tests) |
|
|
171
|
+
| `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) |
|
|
172
|
+
|
|
173
|
+
## Tests
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
npm test # emits src -> dist (gitignored), runs all test files
|
|
177
|
+
npm test -w pi-braid-telegram # via the monorepo workspace
|
|
178
|
+
npx tsc --noEmit # typecheck (guardrail build)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Coverage: client method extraction & error mapping, param validation, cap
|
|
182
|
+
enforcement (settings-driven default + explicit limit), allow-list filtering,
|
|
183
|
+
bootstrap, offset confirm-on-read
|
|
184
|
+
persistence across restarts and fetch calls, retry-on-injection-failure,
|
|
185
|
+
group/non-text rejection, if-has-token guard, inbound/outbound envelope
|
|
186
|
+
formats (inbound carries the `[tg-end]` end marker; outbound carries NONE),
|
|
187
|
+
default-off poller, poll-enabled opt-in, singleton
|
|
188
|
+
poller, `/telegram` command verbs, telegram settings caps (defaults,
|
|
189
|
+
overrides, invalid-input degradation, per-call re-read), voice TTS
|
|
190
|
+
(`braid.telegram.tts` defaults / `source: "none"` refusal / invalid-input
|
|
191
|
+
fallback, script→voice, edge-tts retry, gated say fallback, handoff into the
|
|
192
|
+
send path), turn-complete
|
|
193
|
+
auto-notify (detection,
|
|
194
|
+
rate limit, guard fallback, cap + token-budget injection, key-ring model
|
|
195
|
+
resolver, no marker on outbound),
|
|
196
|
+
live-update tracked message (start/update/finish idempotency, restart re-pin,
|
|
197
|
+
rate limit, finish-not-rate-limited, default-on + `=0` opt-out, trailing-tool gating,
|
|
198
|
+
state-file persistence), rich/split-to-fit (paragraph→line→word boundaries,
|
|
199
|
+
never mid-word, `(i/N)` markers, 4096 cap sequence, honored parse_mode,
|
|
200
|
+
Markdown-balance guard, craft bypass when `rich`),
|
|
201
|
+
decision buttons (row/token validation, malformed-row refusal, 64-byte
|
|
202
|
+
callback_data cap, generated-token echo, dec: parse/label/format, tap handler
|
|
203
|
+
ack + delivery payload, multi-question keyboards keep the open rows after a
|
|
204
|
+
tap, voice-path refusal, keyboard on the last rich chunk),
|
|
205
|
+
callback routing through the real poller (dec: tap delivered as a whisper,
|
|
206
|
+
pause tap persists state, unknown data answered without a crash),
|
|
207
|
+
format corpus (`test/corpus.test.mjs` + `corpus-runner.mjs`: 76 hostile bodies ×
|
|
208
|
+
3 requested modes, recorded live parse verdicts vs the local checker, the 21-cell
|
|
209
|
+
resolution matrix, cap-crossing bodies; `test/live-html-probe.mjs` re-verifies
|
|
210
|
+
the checker against the live API, opt-in and never run by `npm test`).
|
|
211
|
+
|
|
212
|
+
## Live E2E (postponed)
|
|
213
|
+
|
|
214
|
+
Configure the env vars above, add `packages/telegram` to the pi `packages`
|
|
215
|
+
list (like `packages/providers`), then: `testToken` → `telegram_send` a text →
|
|
216
|
+
`telegram_inbox` to pull replies (or explicitly `BRAID_TG_POLL_ENABLED=1` /
|
|
217
|
+
`/telegram enable` for the background injector). `voice: true` synthesizes
|
|
218
|
+
through `braid.telegram.tts` (default: latest edge-tts via uvx, rate +20%,
|
|
219
|
+
voices picked by script) → ffmpeg ogg/opus → `sendVoice`; `source: "none"`
|
|
220
|
+
disables synthesis, and `voice: true` then returns a clear "no TTS source is
|
|
221
|
+
configured" error instead of silently sending text. macOS `say` is the
|
|
222
|
+
explicit-config emergency fallback (`source: "say"` or `"edge-tts+say"`).
|
package/build-info.json
ADDED
|
@@ -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.
|