@llblab/pi-telegram 0.20.4 → 0.20.6

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/BACKLOG.md CHANGED
@@ -2,6 +2,50 @@
2
2
 
3
3
  _This backlog tracks only open release-relevant work: live promoted-follower verification, evidence-gated Telegram client/runtime follow-ups, and upstream Pi API blockers. Completed validation evidence belongs in `CHANGELOG.md`, not in this queue._
4
4
 
5
+ ## P1 — Guest Media Live Follow-Ups
6
+
7
+ Context: 0.20.5 shipped deterministic Guest Mode file/audio delivery coverage. Post-release private-DM smoke confirmed that one local document reaches the remote conversation through `answerGuestQuery`; remaining checks validate Telegram client behavior rather than gate the implemented transport.
8
+
9
+ Open work:
10
+
11
+ - [x] Confirm one local document in a private Guest Mode DM after extension reload.
12
+ - [x] Confirm one synthesized paired-comment voice result in a private Guest Mode DM; Telegram delivered it to the correctly attributed remote peer without visible fallback text.
13
+ - [ ] Confirm one local document and one synthesized voice/audio result in group Guest Mode.
14
+ - [ ] Record a focused client/API caveat only if live behavior contradicts the one-result and staging contracts.
15
+
16
+ Done when: private and group Guest Mode each have direct document and voice/audio delivery evidence, or a confirmed Telegram limitation is documented.
17
+
18
+ ## P1 — Compaction Status Ownership And Native Activity
19
+
20
+ Context: Pi already renders its own compaction lifecycle, while pi-telegram currently overrides its terminal status row with `compacting` whenever the shared compaction flag is set. This duplicates Pi-owned state and hides the distinction between Telegram-owned activity and unrelated automatic/session compaction. Manual `/compact` already calls the typing-loop port and automatic compaction starts typing only when an active Telegram turn exists, so the reported absence of Telegram `…typing` needs transport-level and live verification rather than an assumed rewrite.
21
+
22
+ Planned work:
23
+
24
+ - [x] Remove `compacting` as a pi-telegram terminal status label while retaining the internal compaction flag for queue/dispatch safety and explicit diagnostics.
25
+ - [x] Track compaction origin for status projection: confirmed Telegram `/compact` and auto-compaction inside a Telegram-owned turn render normal `Active`; local/autonomous/background compaction keeps the stable `connected`, `leader`, or `follower` role.
26
+ - [x] Define and verify the native activity matrix: Telegram-owned compaction targets the invoking/active thread plus `All`; non-Telegram compaction uses the connected instance target plus `All` without changing terminal role semantics.
27
+ - [x] Trace manual confirmation, `session_before_compact`, `session_compact`, completion, error, timeout, abort, and shutdown ordering to ensure one keyed typing loop remains active for the whole compaction window and always stops.
28
+ - [x] Add transport-level regressions that assert actual `sendChatAction(typing)` targets and keepalive lifecycle, not only invocation of a mocked `startTypingLoop` callback.
29
+ - [x] Replace status tests that currently require `compacting` with Telegram-owned `Active` and non-Telegram stable-role cases; preserve `/telegram-status` compaction diagnostics where operationally useful.
30
+ - [ ] Capture live evidence for manual Telegram compaction, auto-compaction during a Telegram turn, and non-Telegram auto-compaction before finalizing the activity contract.
31
+
32
+ Done when: Pi remains the only terminal owner of the `compacting` label, pi-telegram status reflects Telegram ownership rather than generic compaction, and Telegram native `…typing` remains visible and correctly targeted throughout every confirmed compaction class without leaking afterward.
33
+
34
+ ## P1 — Leader Endpoint Loss Recovery
35
+
36
+ Context: live evidence showed a process retaining a fresh transport lock and active polling while its Threaded Mode Unix socket path was absent. The likely trigger was external removal of the shared Telegram temp directory while the owner process remained alive. The local server keeps listening on the unlinked Unix socket but `start()` treats its in-memory server handle as sufficient, leader health checks only Bot API transport, and a new instance therefore exhausts follower-registration retries with `ENOENT`. This is a real diagnosable recovery gap, but not yet evidence for a broad readiness protocol or automatic takeover; force-acquiring while the old owner may still run `getUpdates` would risk split-brain.
37
+
38
+ Planned work:
39
+
40
+ - [x] Reproduce deterministically by unlinking only the active Unix leader socket while its process, polling runtime, and in-memory server remain live. Native Windows named pipes have no equivalent filesystem path to unlink, so recovery remains Unix-specific unless separate named-pipe evidence appears.
41
+ - [x] Let the owning Threaded Mode runtime detect an externally missing Unix endpoint during its existing health/prune cadence and restart only the local bus server without changing lock ownership, leader epoch, polling, or thread bindings.
42
+ - [x] Make initial follower registration report `live owner / unreachable bus endpoint` after bounded retries, with direct operator guidance; do not add automatic or force takeover without separate evidence that the old owner cannot still poll.
43
+ - [x] Keep intentional classic ownership unchanged because classic mode does not require a bus endpoint.
44
+ - [x] Add focused regressions for Unix endpoint unlink/rebind, bounded follower diagnosis, leader reload overlap, and no duplicate `getUpdates` ownership; add Windows coverage only for behavior the named-pipe transport can reproduce.
45
+ - [ ] Capture live recovery evidence without deleting lock/state or creating a replacement Telegram thread.
46
+
47
+ Done when: the confirmed endpoint-loss scenario either self-recovers under the existing owner or produces precise safe remediation, while classic mode and single-owner polling remain unchanged.
48
+
5
49
  ## P1 — Promoted Follower Reload Evidence
6
50
 
7
51
  Context: deterministic coverage protects promoted follower thread preservation, and the latest live Linux smoke closed reload routing, follower Active, and reroute/restore regressions. The exact promoted-leader reload path is deliberately outside the 0.20.1 profile IPC hotfix because it is unrelated to profile transport isolation; keep it as an evidence-gated follow-up rather than blocking that release.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.20.6: Guest Attribution And Voice Action Hotfix
4
+
5
+ - `[Voice Action Syntax]` Hidden `telegram_voice` actions now accept the intuitive paired form `<!-- telegram_voice ... -->...<!-- /telegram_voice -->` alongside inline, attribute-text, and single-comment multiline forms. The parser captures a non-empty multiline body and preserves language/rate attributes while leaving surrounding prose visible. Impact: agents can use an explicit closing tag without leaking the intended TTS payload as ordinary text.
6
+ - `[Voice Investigation]` Removed the premature Guest Mode generate-before-answer experiment and unrelated voice-pipeline changes after live evidence identified unsupported paired markup as the confounder. After reload, voice-only paired actions delivered exactly one playable voice response with no visible fallback text in both the Aster thread and a correctly attributed private Guest Mode conversation; `telegram_attach` independently delivered the same format. Impact: standard Threaded Mode and private Guest Mode now have direct live proof on the established delivery architecture, while group Guest Mode confirmation remains explicit in `BACKLOG.md`.
7
+ - `[Guest Attribution]` Private Guest Mode now resolves `guest` as the remote conversation peer instead of the paired owner: a non-owner sender identifies itself, while an owner-authored turn prefers the replied peer, then the private-chat identity, then non-owner caller metadata. Username falls back to the remote display name and numeric id, and an unresolved payload records redacted field-presence diagnostics without leaking the owner's identity into `[telegram|guest:...]`. Group Guest Mode continues to use the group title. Impact: the agent sees the third party it is assisting in a direct conversation, not the bot owner who invoked it.
8
+ - `[Validation]` Added route and resolver regressions for owner-authored no-reply DMs, explicit replies, non-owner peers, username changes, missing usernames, numeric fallback, alternate paired profile ids, and owner-only unresolved input. Impact: private attribution and source-context provenance remain distinct while the paired owner can never become the guest label.
9
+ - `[Live Evidence]` After reloading the hotfix workspace, a real owner-authored private Guest Mode invocation arrived as `[telegram|guest:mariannaelunina]` instead of the paired owner `llblab`. Impact: the repaired remote-peer attribution now has direct Telegram evidence, so the completed P0 moved out of `BACKLOG.md` and the tree is ready for the next hotfix scope.
10
+
11
+ ## 0.20.5: Guest Media And Runtime Recovery Hotfix
12
+
13
+ - `[Setup Persistence]` The setup prompt now applies the validated bot identity to its config store before invoking persistence, so adapters that serialize current store state cannot write an empty or stale `telegram.json` on first run. Persistence failure rolls the in-memory candidate back and remains ahead of success notifications and polling startup. Impact: setup from a missing file or `{}` durably writes `botToken`, `botId`, and `botUsername` on the first successful command for default and named profiles without reporting an unsaved connection.
14
+ - `[Validation]` Added file-backed setup regressions for missing and empty config files, environment-provided tokens, named-profile isolation, and persistence-failure rollback while preserving existing cancellation, validation, polling-failure, and atomic config-write coverage. Full validation passes with 1,098 tests and one platform-only skip; typecheck and npm audit remain clean.
15
+ - `[Guest Attachments]` `telegram_attach` now admits exactly one local file during an active Guest Mode turn and rejects a second before file inspection or queue mutation. Agent-end stages that file through the paired-owner chat, sends one cached-media guest result with final text as its caption, cleans up the staging message, and never follows an ambiguous media-answer failure with a second guest answer. Impact: a requested local file is no longer silently discarded, while Telegram's one-result constraint remains explicit.
16
+ - `[Guest Media Transport]` `answerGuestQuery` now accepts a typed cached document/photo/audio/voice result in addition to text and Rich Markdown articles, and the bus-aware API preserves that exact result when a follower routes it through the leader. Impact: guest delivery no longer hard-codes article construction at the direct or IPC boundary, providing the minimal one-shot transport needed for staged local media without widening the follower API allowlist.
17
+ - `[Guest Media Staging]` Added a bounded local-media staging primitive that selects document/photo/audio/voice multipart transport, extracts the returned Telegram `file_id`, emits one matching cached guest result, truncates captions safely to 1,024 code points, and deletes the staging message in `finally`. Extraction and answer failures still clean up; cleanup failures record diagnostics without retrying the one-shot guest answer. Impact: local Guest Mode artifacts have deterministic cleanup and no duplicate-answer path.
18
+ - `[Guest Voice]` Guest agent-end now routes one explicit or policy-intercepted voice reply through the existing synthesis/handler chain, captures the generated OGG/OPUS artifact instead of sending it to sentinel chat `0`, stages it through the leader-owned multipart transport, and answers with one cached voice result. Visible answer text becomes the media caption, and multiple voice blocks reduce to the first result under Telegram's one-query/one-result contract. Impact: ordinary Guest Mode responses can include synthesized audio without bypassing ownership or emitting a separate text answer.
19
+ - `[Leader Endpoint Recovery]` The active Threaded Mode leader now checks its Unix socket during the existing follower-health cadence. If an external cleanup unlinks the endpoint while the server and polling owner remain alive, it closes only the orphaned local server, recreates the socket and parent directory, and resumes follower reachability without restarting polling, changing leader epoch, or touching thread bindings. Named pipes and classic mode remain unchanged because they do not expose the same filesystem-loss condition. Impact: a live owner can self-heal the observed `ENOENT bus.sock` split between polling health and follower connectivity without unsafe lock takeover.
20
+ - `[Follower Diagnostics]` When bounded registration retries fail with `ENOENT`, `ECONNREFUSED`, or `ETIMEDOUT` behind a still-live lock owner, the connection result now identifies `live owner / unreachable bus endpoint`, asks the operator to wait briefly and retry `/telegram-connect`, and explicitly rejects force takeover while the owner remains live. Impact: transient endpoint recovery no longer looks like a generic registration failure or invite split-brain remediation.
21
+ - `[Profile Diagnostics]` Current logs use `logs.jsonl` for default and `logs.<profile>.jsonl` for named profiles; preserved logs use `logs._prev.jsonl` and `logs.<profile>._prev.jsonl`. Status and `telegram_help` resolve paths through the shared helper, old logs remain untouched as ephemeral evidence, and profile names allow only lowercase ASCII letters and digits. Impact: `_prev` remains an unmistakable lifecycle suffix because underscores and dots cannot occur in profile identifiers, while compact dotted profile filenames remain readable.
22
+ - `[Compaction Status]` Removed pi-telegram's terminal and status-summary `compacting` projection while retaining the compaction flag in explicit bridge diagnostics and dispatch safety. A Telegram-owned active turn continues to render `active`; unrelated compaction leaves the stable connected/leader/follower role visible. Impact: Pi remains the sole owner of compaction lifecycle UI, and pi-telegram reports Telegram ownership rather than duplicating generic session state.
23
+ - `[Compaction Activity]` Compaction now starts the same connected-instance native typing path used by agent activity instead of suppressing typing when no Telegram turn is active. Active Telegram compaction targets its thread plus `All`; local/autonomous compaction targets the instance's assigned thread plus `All`, with completion, timeout, and shutdown stopping the keyed loop. Impact: compaction remains visible in Telegram without taking ownership of Pi's terminal lifecycle label.
24
+
3
25
  ## 0.20.4: Thread State Ownership Hotfix
4
26
 
5
27
  - `[State Ownership]` Made the active transport lock owner the only process allowed to persist the profile-shared `state.json`; followers remain readers and acquire write authority only after promotion. Status-only persistence now refreshes disk-backed bindings before serialization. Impact: a stale follower diagnostics snapshot cannot erase newer leader-owned bindings, produce duplicate slot occupancy, or make a live follower disappear from current thread state.
package/README.md CHANGED
@@ -151,6 +151,8 @@ Run these inside Pi.
151
151
  | `/telegram-disconnect` | Stop polling and release ownership |
152
152
  | `/telegram-status` | Inspect connection, mode, queue, transport, and recent diagnostics |
153
153
 
154
+ Named profile identifiers contain only lowercase ASCII letters and digits (maximum 32 characters); `default`, `main`, and `active` remain reserved.
155
+
154
156
  ## Main Surfaces
155
157
 
156
158
  ### Operator Menu
@@ -120,7 +120,7 @@ Deleting `locks.json` resets runtime ownership without deleting Telegram configu
120
120
 
121
121
  Telegram private-chat Threaded Mode is the public switch for multi-instance Telegram operation. Classic single-DM polling is the base mode. When Telegram private-chat threads are available for the bot, the bridge enables the local leader/follower bus automatically; when threads are unavailable or later disabled, the bridge returns to classic single-DM polling as a first-class mode.
122
122
 
123
- Named Telegram profiles are orthogonal to Threaded Mode. The selected profile chooses the bot/session slice (`botToken`, `botId`, `botUsername`, `allowedUserId`, `lastUpdateId`) and scopes singleton locks, diagnostics logs, state files, thread/bus owner keys, and leader/follower IPC endpoints; it must not change the Threaded Mode rules. Within one selected profile, leader/follower election, bus transport, thread provisioning, routing, ownership forwarding, cleanup, and runtime diagnostics behave exactly as they do for the default profile. A different selected profile is a parallel bot runtime: its locks, `tmp/telegram/state.<profile>.json`, `tmp/telegram/logs.<profile>.jsonl`, previous log, thread bindings, Unix sockets, and Windows named pipes are isolated from the default profile and from other named profiles while shared bridge settings remain top-level/global. The default profile preserves legacy state, log, socket, and named-pipe paths for compatibility.
123
+ Named Telegram profiles are orthogonal to Threaded Mode. The selected profile chooses the bot/session slice (`botToken`, `botId`, `botUsername`, `allowedUserId`, `lastUpdateId`) and scopes singleton locks, diagnostics logs, state files, thread/bus owner keys, and leader/follower IPC endpoints; it must not change the Threaded Mode rules. Within one selected profile, leader/follower election, bus transport, thread provisioning, routing, ownership forwarding, cleanup, and runtime diagnostics behave exactly as they do for the default profile. A different selected profile is a parallel bot runtime: its locks, `tmp/telegram/state.<profile>.json`, `tmp/telegram/logs.<profile>.jsonl`, `tmp/telegram/logs.<profile>._prev.jsonl`, thread bindings, Unix sockets, and Windows named pipes are isolated from the default profile and from other named profiles while shared bridge settings remain top-level/global. The default profile preserves legacy state, log, socket, and named-pipe paths for compatibility.
124
124
 
125
125
  Profile reality follows three explicit storage classes. `telegram.json` shared settings and extension registries are process-global platform configuration; profile bot/session fields and observable transport/routing authority are profile-scoped; queues, active turns, ownership caches, menu state, and runtime controllers are session-local memory. Downloaded attachments use UUID-prefixed names in the shared Telegram scratch directory and are session artifacts rather than identity or routing authority, so cross-profile cleanup is limited to stale scratch files and cannot redirect live traffic.
126
126
 
@@ -214,14 +214,14 @@ Queue and menu mutations are reachable through Telegram updates handled by the c
214
214
 
215
215
  ### Compaction And Typing Status
216
216
 
217
- Manual `/compact` requires inline confirmation because accidental taps are disruptive. Confirmed manual compaction and auto-compaction both set the bridge compaction flag, block queued prompt dispatch, update status to `compacting`, and clear that state on compact completion, timeout fallback, or session shutdown.
217
+ Manual `/compact` requires inline confirmation because accidental taps are disruptive. Confirmed manual compaction and auto-compaction both set the bridge compaction flag, block queued prompt dispatch, retain that flag in explicit diagnostics, and clear it on compact completion, timeout fallback, or session shutdown. Pi owns its terminal compaction lifecycle; pi-telegram keeps `Active` scoped to Telegram-owned work and otherwise preserves the stable connected/leader/follower role.
218
218
 
219
- Native typing during compaction is deliberately narrower than the compaction flag:
219
+ Native typing during compaction follows connected-instance activity rather than terminal status:
220
220
 
221
- - Confirmed manual `/compact` always starts a native `typing` keepalive in the command target and stops it on completion/failure.
222
- - Automatic/session compaction starts native `typing` only when there is an active Telegram-owned turn; it must reuse that active turn's target.
223
- - Startup, reload, connect/reconnect, restore, leader/follower recovery, and idle/background compaction without an active Telegram turn must not send visible typing.
224
- - Thread-targeted typing is sent to the concrete thread and mirrored to `All` as the aggregate activity surface.
221
+ - Confirmed manual `/compact` starts a native `typing` keepalive in the command target and stops it on completion/failure.
222
+ - Automatic/session compaction with an active Telegram turn reuses that turn's target.
223
+ - Automatic/session compaction without an active Telegram turn uses the connected instance's assigned target; an unconnected instance sends nothing.
224
+ - Thread-targeted typing is sent to the concrete thread and mirrored to `All` as the aggregate activity surface; completion, timeout, and shutdown stop the keyed loop.
225
225
 
226
226
  At every connected instance `agent_start`, the lifecycle binding starts Telegram's native `…typing` indicator in that instance's assigned target, whether the run came from Telegram, the local TUI, or an autonomous continuation such as Grow Loop. Terminal `Active` remains Telegram-turn-specific; the native indicator answers the separate question of whether the instance is doing agent work. Assistant message start/update hooks still re-arm it during Telegram-owned turns so transient provider/model errors do not leave a continuing run without activity feedback, and agent/session completion stops it.
227
227
 
@@ -233,7 +233,7 @@ Assistant delivery guarantees:
233
233
 
234
234
  - Model-authored Markdown is the source of truth; the bridge does not pre-render assistant Markdown to HTML unless the operator selects `assistant.rendering: "html"` for compatibility.
235
235
  - Before native Rich Markdown delivery, the bridge normalizes known Bot-API-fragile source forms without changing visible meaning, including space-after-marker blockquotes and dollar-prefixed ticker atoms that Telegram may otherwise treat as unterminated math.
236
- - Prompt context blocks use compact metadata (`[tag|key:value]`) as the stable inbound contract. `[telegram...]` names the current surface only: owner/current turns use `[telegram]` or `[telegram|thread:<name>]`; guest-mode turns use `[telegram|guest:<group-title-or-peer-username-or-id>]`. Source authors for quoted/forwarded material and their files are carried by `[reply|from:<username-or-id>]`, `[forward|from:<username-or-id>]`, and `[attachments|from:<username-or-id>]`, while plain `[attachments]` remains current-turn attachments and is ordered before reply/forward/source context.
236
+ - Prompt context blocks use compact metadata (`[tag|key:value]`) as the stable inbound contract. `[telegram...]` names the current surface only: owner/current turns use `[telegram]` or `[telegram|thread:<name>]`; guest-mode turns use `[telegram|guest:<group-title-or-peer-username-or-id>]`. In a private Guest Mode turn the paired owner's `from` identity is never the guest: an explicit replied peer wins, then the remote private-chat identity, then non-owner caller metadata; username falls back to the remote display name and numeric id. Source authors for quoted/forwarded material and their files are carried by `[reply|from:<username-or-id>]`, `[forward|from:<username-or-id>]`, and `[attachments|from:<username-or-id>]`, while plain `[attachments]` remains current-turn attachments and is ordered before reply/forward/source context.
237
237
  - Quoted rich replies use Telegram `rich_message` blocks as the prompt-context source when available, so `[reply]` context receives rendered plain text instead of raw `InputRichMessage.markdown` fallback text.
238
238
  - Long native Markdown replies are split only at Telegram Rich Message transport limits; oversized fenced code, display-math, and fully wrapped inline-formatting blocks are rewrapped per chunk so persisted Rich Markdown chunks remain structurally valid.
239
239
  - When Draft previews are enabled, streaming previews pass structurally closed assistant Markdown prefixes through to `sendRichMessageDraft` with ownership checks, voice suppression, and serialized flushes. Unclosed inline spans, links, fenced code, comments, and display-math blocks are held back until a safe boundary exists. Draft failures are recorded and the failing frame is skipped instead of degrading to raw plain-message previews, because partial Markdown can be invalid while the final message remains valid.
@@ -192,7 +192,7 @@ Manual smoke checklist:
192
192
  6. Close the follower terminal; verify heartbeat pruning, disconnected notice, and cleanup behavior match Unix-like behavior.
193
193
  7. Reload the leader and verify status/debug output does not expose raw pipe internals except in explicit diagnostics.
194
194
 
195
- If any step fails, capture `telegram-status --debug`, `tmp/telegram/state.json`, `tmp/telegram/logs.jsonl`, and, after a reload, `tmp/telegram/logs.previous.jsonl`. Debug status prints local leader/follower endpoints with their active transport kind (`pipe` or `socket`), while the runtime log records request-scoped transport failures with envelope kind, request id, retry attempt, endpoint, and classified IPC error. Reloads preserve the prior JSONL log as `logs.previous.jsonl` so the evidence that caused the reload is not immediately overwritten.
195
+ If any step fails, capture `telegram-status --debug`, `tmp/telegram/state.json`, `tmp/telegram/logs.jsonl`, and, after a reload, `tmp/telegram/logs._prev.jsonl`. Debug status prints local leader/follower endpoints with their active transport kind (`pipe` or `socket`), while the runtime log records request-scoped transport failures with envelope kind, request id, retry attempt, endpoint, and classified IPC error. Reloads preserve the prior JSONL log as `logs._prev.jsonl` so the evidence that caused the reload is not immediately overwritten.
196
196
 
197
197
  ### Native Windows Assumption Audit
198
198
 
package/docs/outbound.md CHANGED
@@ -18,6 +18,12 @@ An outbound handler is selected by `type`. Text replies and assistant markup map
18
18
 
19
19
  The voice pipeline is detailed below: configured `type: "voice"` handlers first, then programmatic handlers, then registered synthesis providers.
20
20
 
21
+ ### Guest Mode media boundary
22
+
23
+ A Guest Mode reply is one `answerGuestQuery` call carrying exactly one `InlineQueryResult`; it is not a normal chat target and cannot receive `sendDocument`/`sendVoice` multipart uploads through sentinel `chatId: 0`. `telegram_attach` therefore admits at most one file during a guest turn and rejects additional files before queue mutation.
24
+
25
+ Telegram accepts public URLs or existing Telegram `file_id` values for inline media results, but pi-telegram does not publish local artifacts to external hosting. A local guest document, photo, MP3 audio, or OGG/OPUS voice therefore uses a temporary upload to the paired owner's bot chat, extraction of the returned `file_id`, one cached-media guest answer, and best-effort deletion of the staging message. That message can briefly appear or notify the owner. One guest query can carry only one media item, and its answer text must fit the media caption limit rather than a separate full Rich Markdown message.
26
+
21
27
  Configured text handlers provide `template`. A string is one command; an array is ordered composition. Top-level `args` and `defaults` apply to all composed steps unless a step defines private values. The command-template default timeout applies automatically. Use `template: [...]` for composition; the old local `pipe` alias is removed in 0.13.0.
22
28
 
23
29
  ## Text Handler Config
package/index.ts CHANGED
@@ -24,6 +24,7 @@ import * as Menu from "./lib/menu.ts";
24
24
  import * as Model from "./lib/model.ts";
25
25
  import * as Outbound from "./lib/outbound.ts";
26
26
  import * as Ownership from "./lib/ownership.ts";
27
+ import * as Paths from "./lib/paths.ts";
27
28
  import * as Pi from "./lib/pi.ts";
28
29
  import * as Polling from "./lib/polling.ts";
29
30
  import * as Preview from "./lib/preview.ts";
@@ -309,6 +310,7 @@ export default function (pi: Pi.ExtensionAPI) {
309
310
  >({
310
311
  getConfig: configStore.get,
311
312
  getActiveProfileName: configStore.getActiveProfileName,
313
+ getDiagnosticPaths: Paths.getTelegramDiagnosticsDisplayPaths,
312
314
  isPollingActive: Polling.createTelegramPollingActivityReader(
313
315
  pollingControllerState,
314
316
  ),
@@ -1318,6 +1320,7 @@ export default function (pi: Pi.ExtensionAPI) {
1318
1320
  sendTextReply,
1319
1321
  dispatchNextQueuedTelegramTurn,
1320
1322
  answerGuestQuery,
1323
+ deleteMessage: deleteTelegramMessage,
1321
1324
  sendGuestReply,
1322
1325
  finalizeMarkdownPreview,
1323
1326
  proactivePushChatIdGetter,
package/lib/bindings.ts CHANGED
@@ -127,22 +127,29 @@ export function registerTelegramCommandsAndTools({
127
127
  });
128
128
  setupConfigStore.activateProfile(profileName);
129
129
  persistSetupConfig = async () => {
130
+ try {
131
+ configStore.activateProfile(undefined);
132
+ configStore.set({
133
+ ...storedConfig,
134
+ profiles: {
135
+ ...(storedConfig.profiles ?? {}),
136
+ [profileName]: storedConfig.profiles?.[profileName] ?? {
137
+ botToken: "",
138
+ },
139
+ },
140
+ });
141
+ configStore.activateProfile(profileName);
142
+ configStore.set(setupConfigStore.get());
143
+ await persistConfig();
144
+ } catch (error) {
145
+ configStore.activateProfile(undefined);
146
+ configStore.set(storedConfig);
147
+ configStore.activateProfile(previousProfileName);
148
+ throw error;
149
+ }
130
150
  if (previousProfileName !== profileName) {
131
151
  await (stopPolling ?? lockedPollingRuntime.stop)();
132
152
  }
133
- configStore.activateProfile(undefined);
134
- configStore.set({
135
- ...storedConfig,
136
- profiles: {
137
- ...(storedConfig.profiles ?? {}),
138
- [profileName]: storedConfig.profiles?.[profileName] ?? {
139
- botToken: "",
140
- },
141
- },
142
- });
143
- configStore.activateProfile(profileName);
144
- configStore.set(setupConfigStore.get());
145
- await persistConfig();
146
153
  };
147
154
  }
148
155
  const runSetup = Setup.createTelegramSetupPromptRuntime({
@@ -242,14 +249,8 @@ interface TelegramLifecycleBindingDeps {
242
249
  >["sendTextReply"] &
243
250
  NonNullable<OutboundHandlers.TelegramVoiceReplySenderDeps["sendTextReply"]>;
244
251
  dispatchNextQueuedTelegramTurn: (ctx: Pi.ExtensionContext) => void;
245
- answerGuestQuery: NonNullable<
246
- Queue.TelegramAgentEndHookRuntimeDeps<
247
- Queue.PendingTelegramTurn,
248
- Pi.ExtensionContext,
249
- Pi.AgentEndEvent["messages"][number],
250
- Keyboard.TelegramInlineKeyboardMarkup
251
- >["answerGuestQuery"]
252
- >;
252
+ answerGuestQuery: TelegramApi.TelegramBridgeApiRuntime["answerGuestQuery"];
253
+ deleteMessage: TelegramApi.TelegramBridgeApiRuntime["deleteMessage"];
253
254
  sendGuestReply: NonNullable<
254
255
  Queue.TelegramAgentEndHookRuntimeDeps<
255
256
  Queue.PendingTelegramTurn,
@@ -295,6 +296,7 @@ export function registerTelegramLifecycleRuntimeHooks({
295
296
  sendTextReply,
296
297
  dispatchNextQueuedTelegramTurn,
297
298
  answerGuestQuery,
299
+ deleteMessage,
298
300
  sendGuestReply,
299
301
  finalizeMarkdownPreview,
300
302
  proactivePushChatIdGetter,
@@ -319,18 +321,107 @@ export function registerTelegramLifecycleRuntimeHooks({
319
321
  sendTextReply,
320
322
  recordRuntimeEvent,
321
323
  });
322
- const outboundReplyPlanner =
323
- OutboundHandlers.createTelegramOutboundReplyPlanner(buttonActionStore);
324
- const outboundReplyArtifactSender =
325
- OutboundHandlers.createTelegramOutboundReplyArtifactSender({
326
- execCommand: CommandTemplates.execCommandTemplate,
324
+ const sendGuestAttachment = async (
325
+ turn: Queue.PendingTelegramTurn,
326
+ attachment: Queue.QueuedAttachment,
327
+ caption?: string,
328
+ ): Promise<void> => {
329
+ const stagingTarget = proactivePushTargetGetter();
330
+ const stagingChatId = stagingTarget?.chatId ?? proactivePushChatIdGetter();
331
+ if (stagingChatId === undefined) {
332
+ throw new Error("Guest attachment staging requires a paired Telegram chat");
333
+ }
334
+ await OutboundAttachments.deliverTelegramGuestCachedAttachment({
335
+ guestQueryId: turn.guestQueryId!,
336
+ stagingChatId,
337
+ stagingTarget,
338
+ attachment,
339
+ caption,
327
340
  sendMultipart: callMultipart,
328
- sendTextReply,
329
- sendChatAction,
330
- sendRecordVoiceAction,
331
- getHandlers: configStore.getOutboundHandlers,
341
+ answerGuestQuery: (guestQueryId, result) =>
342
+ answerGuestQuery(guestQueryId, undefined, { result }),
343
+ answerGuestText: (guestQueryId, text) =>
344
+ answerGuestQuery(guestQueryId, text),
345
+ fallbackText:
346
+ caption || "Telegram bridge could not deliver the requested attachment.",
347
+ deleteMessage,
332
348
  recordRuntimeEvent,
333
349
  });
350
+ };
351
+ const outboundReplyPlanner =
352
+ OutboundHandlers.createTelegramOutboundReplyPlanner(buttonActionStore);
353
+ const voiceReplySenderDeps = {
354
+ execCommand: CommandTemplates.execCommandTemplate,
355
+ sendMultipart: callMultipart,
356
+ sendTextReply,
357
+ sendChatAction,
358
+ sendRecordVoiceAction,
359
+ getHandlers: configStore.getOutboundHandlers,
360
+ recordRuntimeEvent,
361
+ };
362
+ const outboundReplyArtifactSender =
363
+ OutboundHandlers.createTelegramOutboundReplyArtifactSender(
364
+ voiceReplySenderDeps,
365
+ );
366
+ const sendGuestVoiceReply = async (
367
+ turn: Queue.PendingTelegramTurn,
368
+ plan: OutboundHandlers.TelegramOutboundReplyPlan,
369
+ caption?: string,
370
+ ): Promise<void> => {
371
+ const stagingTarget = proactivePushTargetGetter();
372
+ const stagingChatId = stagingTarget?.chatId ?? proactivePushChatIdGetter();
373
+ if (stagingChatId === undefined) {
374
+ throw new Error("Guest voice staging requires a paired Telegram chat");
375
+ }
376
+ const guestVoiceSender =
377
+ OutboundHandlers.createTelegramOutboundReplyArtifactSender({
378
+ ...voiceReplySenderDeps,
379
+ sendChatAction: undefined,
380
+ sendRecordVoiceAction: undefined,
381
+ sendMultipart: async (
382
+ _method,
383
+ _fields,
384
+ _fileField,
385
+ filePath,
386
+ fileName,
387
+ ) => {
388
+ try {
389
+ await OutboundAttachments.deliverTelegramGuestCachedAttachment({
390
+ guestQueryId: turn.guestQueryId!,
391
+ stagingChatId,
392
+ stagingTarget,
393
+ attachment: { path: filePath, fileName },
394
+ caption,
395
+ sendMultipart: callMultipart,
396
+ answerGuestQuery: (guestQueryId, result) =>
397
+ answerGuestQuery(guestQueryId, undefined, { result }),
398
+ answerGuestText: (guestQueryId, text) =>
399
+ answerGuestQuery(guestQueryId, text),
400
+ fallbackText:
401
+ caption || "Telegram bridge could not deliver the voice reply.",
402
+ deleteMessage,
403
+ recordRuntimeEvent,
404
+ });
405
+ } catch (error) {
406
+ recordRuntimeEvent("delivery", error, {
407
+ phase: "guest-voice-answer",
408
+ guestQueryId: turn.guestQueryId,
409
+ });
410
+ }
411
+ return {};
412
+ },
413
+ });
414
+ await guestVoiceSender(
415
+ turn,
416
+ {
417
+ ...plan,
418
+ ...(plan.voiceReplies?.length
419
+ ? { voiceReplies: [plan.voiceReplies[0]!] }
420
+ : {}),
421
+ },
422
+ { replyToPrompt: false },
423
+ );
424
+ };
334
425
  const agentLifecycleHooks = Queue.createTelegramAgentLifecycleHooks<
335
426
  Queue.PendingTelegramTurn,
336
427
  Pi.ExtensionContext,
@@ -383,6 +474,8 @@ export function registerTelegramLifecycleRuntimeHooks({
383
474
  sendQueuedAttachments: queuedAttachmentSender,
384
475
  answerGuestQuery,
385
476
  sendGuestReply,
477
+ sendGuestAttachment,
478
+ sendGuestVoiceReply,
386
479
  planOutboundReply: outboundReplyPlanner,
387
480
  sendOutboundReplyArtifacts: outboundReplyArtifactSender,
388
481
  getDefaultChatId: proactivePushChatIdGetter,
@@ -398,8 +491,8 @@ export function registerTelegramLifecycleRuntimeHooks({
398
491
  const agentStartWithDedupReset = Lifecycle.createAgentStartDedupHook(
399
492
  agentLifecycleHooks.onAgentStart,
400
493
  );
401
- const startAgentActivityTypingLoop = (ctx: Pi.ExtensionContext): void => {
402
- if (!canSendAgentActivity(ctx)) return;
494
+ const startAgentActivityTypingLoop = (ctx: Pi.ExtensionContext): boolean => {
495
+ if (!canSendAgentActivity(ctx)) return false;
403
496
  const turn = activeTurnRuntime.get();
404
497
  const target = turn?.target ?? proactivePushTargetGetter();
405
498
  promptDispatchRuntime.startTypingLoop(
@@ -407,6 +500,7 @@ export function registerTelegramLifecycleRuntimeHooks({
407
500
  turn?.chatId ?? target?.chatId ?? proactivePushChatIdGetter(),
408
501
  { target },
409
502
  );
503
+ return true;
410
504
  };
411
505
  const startActiveTurnTypingLoop = (ctx: Pi.ExtensionContext): void => {
412
506
  const turn = activeTurnRuntime.get();
@@ -417,9 +511,8 @@ export function registerTelegramLifecycleRuntimeHooks({
417
511
  const compactionObserver = Lifecycle.createTelegramCompactionObserverRuntime({
418
512
  setCompactionInProgress: lifecycle.setCompactionInProgress,
419
513
  updateStatus,
420
- startTypingLoop: startActiveTurnTypingLoop,
514
+ startTypingLoop: startAgentActivityTypingLoop,
421
515
  stopTypingLoop: typing.stop,
422
- shouldStartTypingLoop: activeTurnRuntime.has,
423
516
  requestDeferredDispatchNextQueuedTelegramTurn:
424
517
  deferredQueueDispatchRuntime.request,
425
518
  dispatchNextQueuedTelegramTurn,
package/lib/bus-api.ts CHANGED
@@ -5,10 +5,10 @@
5
5
  */
6
6
 
7
7
  import type {
8
+ TelegramAnswerGuestQueryOptions,
8
9
  TelegramApiCallOptions,
9
10
  TelegramBridgeApiRuntime,
10
11
  TelegramEditMessageTextBody,
11
- TelegramInputRichMessage,
12
12
  TelegramSendMessageBody,
13
13
  TelegramSendMessageDraftBody,
14
14
  TelegramSendRichMessageBody,
@@ -273,14 +273,16 @@ export function createTelegramBusAwareApiRuntime(
273
273
  async answerGuestQuery(
274
274
  guestQueryId: string,
275
275
  text?: string,
276
- options?: { parseMode?: string; richMessage?: TelegramInputRichMessage },
276
+ options?: TelegramAnswerGuestQueryOptions,
277
277
  ): Promise<void> {
278
278
  if (deps.ownsDirect()) {
279
279
  await deps.directRuntime.answerGuestQuery(guestQueryId, text, options);
280
280
  return;
281
281
  }
282
282
  const body: Record<string, unknown> = { guest_query_id: guestQueryId };
283
- if (text !== undefined || options?.richMessage) {
283
+ if (options?.result) {
284
+ body.result = options.result;
285
+ } else if (text !== undefined || options?.richMessage) {
284
286
  const inputContent: Record<string, unknown> = options?.richMessage
285
287
  ? { rich_message: options.richMessage }
286
288
  : { message_text: text };
package/lib/bus-leader.ts CHANGED
@@ -1199,6 +1199,13 @@ export function createTelegramBusLeaderRuntime<TContext>(
1199
1199
  followerRealityTimer.unref?.();
1200
1200
  };
1201
1201
  const pruneFollowers = async () => {
1202
+ try {
1203
+ await localServer.ensureEndpoint();
1204
+ } catch (error) {
1205
+ deps.recordRuntimeEvent?.("bus", error, {
1206
+ phase: "leader-endpoint-recovery",
1207
+ });
1208
+ }
1202
1209
  const removed = deps.followerRegistry.pruneStale(
1203
1210
  getNowMs(),
1204
1211
  followerStaleAfterMs,
package/lib/bus.ts CHANGED
@@ -165,6 +165,7 @@ export function isTelegramFollowerApiCallAllowed(input: {
165
165
  "sendRichMessageDraft",
166
166
  ]);
167
167
  const allowedMultipartMethods = new Set([
168
+ "sendAudio",
168
169
  "sendDocument",
169
170
  "sendMediaGroup",
170
171
  "sendPhoto",
@@ -384,6 +385,7 @@ export function parseTelegramBusEnvelope(
384
385
  export interface TelegramBusLocalServer {
385
386
  start: () => Promise<void>;
386
387
  stop: () => Promise<void>;
388
+ ensureEndpoint: () => Promise<boolean>;
387
389
  }
388
390
 
389
391
  export type TelegramBusSocketPathSource = string | (() => string);
@@ -610,12 +612,14 @@ export function createTelegramBusLocalServer(
610
612
  ): TelegramBusLocalServer {
611
613
  let server: Server | undefined;
612
614
  let activeSocketPath: string | undefined;
615
+ let endpointRecovery: Promise<boolean> | undefined;
616
+ let stopGeneration = 0;
613
617
  const sockets = new Set<Socket>();
614
618
  const closeSocket = (socket: Socket) => {
615
619
  sockets.delete(socket);
616
620
  socket.destroy();
617
621
  };
618
- return {
622
+ const runtime: TelegramBusLocalServer = {
619
623
  start: async () => {
620
624
  if (server) return;
621
625
  const socketPath = resolveTelegramBusSocketPath(deps.socketPath);
@@ -679,6 +683,7 @@ export function createTelegramBusLocalServer(
679
683
  if (!usesWindowsPipe) chmodSync(socketPath, 0o600);
680
684
  },
681
685
  stop: async () => {
686
+ stopGeneration += 1;
682
687
  const activeServer = server;
683
688
  const socketPath = activeSocketPath;
684
689
  server = undefined;
@@ -703,7 +708,44 @@ export function createTelegramBusLocalServer(
703
708
  );
704
709
  }
705
710
  },
711
+ ensureEndpoint: async () => {
712
+ const socketPath = activeSocketPath;
713
+ if (
714
+ !server ||
715
+ !socketPath ||
716
+ isTelegramBusPipePath(socketPath) ||
717
+ existsSync(socketPath)
718
+ ) {
719
+ return false;
720
+ }
721
+ if (endpointRecovery) return endpointRecovery;
722
+ endpointRecovery = (async () => {
723
+ deps.recordTransportEvent?.(
724
+ "server-endpoint-missing",
725
+ getTelegramBusEndpointDiagnostics(socketPath),
726
+ );
727
+ const recoveryStopGeneration = stopGeneration + 1;
728
+ await runtime.stop();
729
+ if (stopGeneration !== recoveryStopGeneration) return false;
730
+ await runtime.start();
731
+ if (stopGeneration !== recoveryStopGeneration) {
732
+ await runtime.stop();
733
+ return false;
734
+ }
735
+ deps.recordTransportEvent?.(
736
+ "server-endpoint-recovered",
737
+ getTelegramBusEndpointDiagnostics(socketPath),
738
+ );
739
+ return true;
740
+ })();
741
+ try {
742
+ return await endpointRecovery;
743
+ } finally {
744
+ endpointRecovery = undefined;
745
+ }
746
+ },
706
747
  };
748
+ return runtime;
707
749
  }
708
750
 
709
751
  function getTelegramBusEnvelopeDiagnostics(
package/lib/config.ts CHANGED
@@ -85,8 +85,8 @@ export interface TelegramBotProfile {
85
85
  lastUpdateId?: number;
86
86
  }
87
87
 
88
- /** Profile names must be lowercase letters, digits, hyphens, underscores; max 32 chars. */
89
- const TELEGRAM_PROFILE_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,31}$/;
88
+ /** Profile names must contain only lowercase ASCII letters and digits; max 32 chars. */
89
+ const TELEGRAM_PROFILE_NAME_PATTERN = /^[a-z0-9]{1,32}$/;
90
90
  const TELEGRAM_RESERVED_PROFILE_NAMES: ReadonlySet<string> = new Set([
91
91
  "default",
92
92
  "main",
package/lib/lifecycle.ts CHANGED
@@ -153,9 +153,8 @@ function unrefTelegramLifecycleTimer(timer: TelegramLifecycleTimer): void {
153
153
  export interface TelegramCompactionObserverRuntimeDeps<TContext> {
154
154
  setCompactionInProgress: (inProgress: boolean) => void;
155
155
  updateStatus: (ctx: TContext) => void;
156
- startTypingLoop?: (ctx: TContext) => void;
156
+ startTypingLoop?: (ctx: TContext) => boolean | void;
157
157
  stopTypingLoop?: () => void;
158
- shouldStartTypingLoop?: () => boolean;
159
158
  requestDeferredDispatchNextQueuedTelegramTurn: (
160
159
  dispatch: (ctx: TContext) => void,
161
160
  ) => void;
@@ -196,8 +195,9 @@ export function createTelegramCompactionObserverRuntime<TContext>(
196
195
  return {
197
196
  onSessionBeforeCompact: (_event, ctx) => {
198
197
  deps.setCompactionInProgress(true);
199
- typingStartedByObserver = deps.shouldStartTypingLoop?.() ?? true;
200
- if (typingStartedByObserver) deps.startTypingLoop?.(ctx);
198
+ const typingStartResult = deps.startTypingLoop?.(ctx);
199
+ typingStartedByObserver =
200
+ !!deps.startTypingLoop && typingStartResult !== false;
201
201
  deps.updateStatus(ctx);
202
202
  clearFallbackTimer();
203
203
  fallbackTimer = setTimer(() => {
package/lib/locks.ts CHANGED
@@ -203,6 +203,17 @@ export function formatTelegramLockEntry(lock: TelegramLockEntry): string {
203
203
  return lock.cwd ? `pid ${lock.pid}, cwd ${lock.cwd}` : `pid ${lock.pid}`;
204
204
  }
205
205
 
206
+ function formatTelegramFollowerRegistrationFailure(message: string): string {
207
+ if (/\b(?:ENOENT|ECONNREFUSED|ETIMEDOUT)\b/u.test(message)) {
208
+ return (
209
+ `live owner / unreachable bus endpoint after bounded retries (${message}); ` +
210
+ "wait briefly for owner recovery, then retry /telegram-connect. " +
211
+ "Do not force takeover while the owner remains live"
212
+ );
213
+ }
214
+ return message;
215
+ }
216
+
206
217
  function getLockState(
207
218
  lock: TelegramLockEntry | undefined,
208
219
  pid: number,
@@ -509,7 +520,7 @@ export function createTelegramLockedPollingRuntime<
509
520
  ok: false,
510
521
  canTakeover: false,
511
522
  owner,
512
- message: `Telegram bridge is active in another Pi instance (${owner}); follower registration failed: ${failureMessage}.`,
523
+ message: `Telegram bridge is active in another Pi instance (${owner}); follower registration failed: ${formatTelegramFollowerRegistrationFailure(failureMessage)}.`,
513
524
  };
514
525
  }
515
526
  }
package/lib/logs.ts CHANGED
@@ -65,7 +65,7 @@ export function getTelegramPreviousRuntimeLogPath(
65
65
  ): string {
66
66
  return resolveTelegramProfileTempFilePath(
67
67
  "logs",
68
- "previous.jsonl",
68
+ "_prev.jsonl",
69
69
  agentDir,
70
70
  profileName,
71
71
  );
@@ -91,7 +91,7 @@ export function createTelegramRuntimeJsonlLog(
91
91
  const resolvePreviousPath = () => {
92
92
  if (typeof options.previousPath === "function") return options.previousPath();
93
93
  if (options.previousPath) return options.previousPath;
94
- return resolvePath().replace(/\.jsonl$/u, ".previous.jsonl");
94
+ return resolvePath().replace(/\.jsonl$/u, "._prev.jsonl");
95
95
  };
96
96
  const maxBytes = options.maxBytes ?? DEFAULT_MAX_LOG_BYTES;
97
97
  const getNowMs = options.getNowMs ?? Date.now;