@llblab/pi-telegram 0.20.4 → 0.20.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/BACKLOG.md CHANGED
@@ -2,6 +2,70 @@
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
+ ## P0 — Private Guest DM Peer Attribution
6
+
7
+ Evidence: live private Guest Mode produced `[telegram|guest:<owner>]` for an owner-authored DM turn even though `guest` must identify the remote conversation peer. Code inspection confirms that private guest routing falls back to `fromPeer` whenever an owner-authored message has no usable `reply_to_message`; because `from.id` then equals the configured `allowedUserId`, the owner is mislabeled as the guest. Existing coverage protects incoming guest messages and owner replies with explicit replied-guest metadata, but does not cover owner-authored private guest messages without reply context.
8
+
9
+ Planned work:
10
+
11
+ - [ ] Capture or minimize the raw private `guest_message` shape for owner-authored turns without reply context and identify the stable remote-peer fields supplied by Telegram (`chat` identity, username/name/id, or another explicit peer field) before choosing a resolver.
12
+ - [ ] Centralize Guest Mode peer attribution: group turns use the group title; private non-owner turns use the sender; private owner turns use the replied guest when present and otherwise the remote private-chat peer.
13
+ - [ ] Compare ownership by Telegram user id (`allowedUserId`), not display name or username. Never emit the configured owner as `guest`; if Telegram omits a username, fall back to the remote peer's stable name/id rather than the owner.
14
+ - [ ] Keep `[reply|from:...]` and `[attachments|from:...]` source attribution aligned with the same resolved peer without changing the current-turn/source-context distinction.
15
+ - [ ] Add regressions for incoming private guests, owner replies, owner-authored no-reply turns, missing usernames, username changes, and named-profile pairing identities.
16
+ - [ ] Update the prompt-context contract/docs only after the minimized Telegram fixture establishes the actual private Guest Mode field semantics.
17
+
18
+ Done when: `[telegram|guest:...]` always identifies the remote peer or group for private/group Guest Mode, never the paired owner, and reply/attachment provenance remains source-correct.
19
+
20
+ ## P0 — Guest Reply File And Audio Delivery
21
+
22
+ Evidence: live Guest Mode accepted `telegram_attach` during an active guest turn and reported the file as queued, but delivered nothing. Code inspection confirms that guest turns use sentinel `chatId: 0`; the tool appends files to `queuedAttachments`, then the agent-end guest branch sends only `answerGuestQuery` text and returns before queued attachments or voice artifacts run. Telegram's `answerGuestQuery` accepts one `InlineQueryResult`, not ordinary `sendDocument`/`sendVoice` multipart delivery, so local artifacts require a guest-specific result plan rather than reuse of chat/thread attachment transport.
23
+
24
+ Planned work:
25
+
26
+ - [x] Fail closed immediately for unsupported guest attachments until guest delivery is available; never return `Queued` when the guest agent-end path cannot consume the artifact.
27
+ - [x] Map the current Bot API `InlineQueryResult` capabilities for document, photo, audio, and voice replies, including URL versus cached `file_id`, caption limits, supported formats, and the one-result-per-guest-query constraint. `answerGuestQuery` accepts exactly one result; local multipart uploads are not accepted there. URL results require public HTTP content (documents only PDF/ZIP, audio MP3, voice OGG/OPUS, photos JPEG up to 5 MB), while cached result variants accept Telegram `document_file_id`, `photo_file_id`, `audio_file_id`, or `voice_file_id`; media captions remain limited to 0–1024 characters after entity parsing.
28
+ - [x] Design one guest reply planner that chooses exactly one result: text article, one cached local file/media with answer text reduced to a caption, or one cached synthesized voice/audio result. Guest tool admission rejects a second attachment before mutation. A failure before the one-shot answer may degrade to one text article; an ambiguous/failing `answerGuestQuery` call must not issue a second answer that could duplicate delivery.
29
+ - [x] Determine an evidence-backed local-file staging path. Local media must upload through the existing leader-owned multipart transport to the paired owner's bot chat, extract the returned Telegram `file_id`, answer the guest query with the matching cached result, and delete the staging message in `finally`. The staging message can briefly appear or notify the owner; this unavoidable Bot API limitation must be documented, no external hosting is introduced, and cleanup failure must be diagnosed rather than hidden.
30
+ - [x] Extend `answerGuestQuery` and bus forwarding from hard-coded article input to the minimal typed result union required by confirmed file/audio/voice cases.
31
+ - [x] Route `telegram_attach`, queued outbound artifacts, and `telegram_voice` through the guest planner before the guest branch returns; never call ordinary multipart methods with sentinel `chatId: 0`.
32
+ - [x] Preserve follower operation by routing staging and `answerGuestQuery` through the transport leader without duplicate answers or leaked staging messages.
33
+ - [x] Add regressions for unsupported fail-closed behavior, document/image/audio/voice result construction, caption fallback, staging cleanup/failure, multiple-file rejection, guest query one-shot semantics, and text fallback after media failure.
34
+ - [ ] Capture live private and group Guest Mode evidence for one local document and one synthesized voice/audio reply before claiming support.
35
+
36
+ Done when: guest turns never silently lose queued artifacts, one supported local file or audio/voice result can be delivered through `answerGuestQuery` with clear constraints, and unsupported/multi-file cases fail visibly without sending to an unrelated thread.
37
+
38
+ ## P1 — Compaction Status Ownership And Native Activity
39
+
40
+ 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.
41
+
42
+ Planned work:
43
+
44
+ - [x] Remove `compacting` as a pi-telegram terminal status label while retaining the internal compaction flag for queue/dispatch safety and explicit diagnostics.
45
+ - [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.
46
+ - [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.
47
+ - [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.
48
+ - [x] Add transport-level regressions that assert actual `sendChatAction(typing)` targets and keepalive lifecycle, not only invocation of a mocked `startTypingLoop` callback.
49
+ - [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.
50
+ - [ ] Capture live evidence for manual Telegram compaction, auto-compaction during a Telegram turn, and non-Telegram auto-compaction before finalizing the activity contract.
51
+
52
+ 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.
53
+
54
+ ## P1 — Leader Endpoint Loss Recovery
55
+
56
+ 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.
57
+
58
+ Planned work:
59
+
60
+ - [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.
61
+ - [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.
62
+ - [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.
63
+ - [x] Keep intentional classic ownership unchanged because classic mode does not require a bus endpoint.
64
+ - [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.
65
+ - [ ] Capture live recovery evidence without deleting lock/state or creating a replacement Telegram thread.
66
+
67
+ 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.
68
+
5
69
  ## P1 — Promoted Follower Reload Evidence
6
70
 
7
71
  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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.20.5: Guest Media And Runtime Recovery Hotfix
4
+
5
+ - `[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.
6
+ - `[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.
7
+ - `[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.
8
+ - `[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.
9
+ - `[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.
10
+ - `[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.
11
+ - `[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.
12
+ - `[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.
13
+ - `[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.
14
+ - `[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.
15
+ - `[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.
16
+
3
17
  ## 0.20.4: Thread State Ownership Hotfix
4
18
 
5
19
  - `[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
 
@@ -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;
@@ -88,6 +88,7 @@ export interface TelegramQueuedOutboundAttachmentView {
88
88
 
89
89
  export interface TelegramOutboundAttachmentQueueTargetView {
90
90
  queuedAttachments: TelegramQueuedOutboundAttachmentView[];
91
+ guestQueryId?: string;
91
92
  }
92
93
 
93
94
  export interface TelegramQueuedOutboundAttachmentTurnView extends TelegramOutboundAttachmentQueueTargetView {
@@ -96,6 +97,42 @@ export interface TelegramQueuedOutboundAttachmentTurnView extends TelegramOutbou
96
97
  target?: TelegramTarget;
97
98
  }
98
99
 
100
+ export type TelegramGuestCachedAttachmentResult =
101
+ | {
102
+ type: "document";
103
+ id: string;
104
+ title: string;
105
+ document_file_id: string;
106
+ caption?: string;
107
+ }
108
+ | {
109
+ type: "photo";
110
+ id: string;
111
+ photo_file_id: string;
112
+ caption?: string;
113
+ }
114
+ | {
115
+ type: "audio";
116
+ id: string;
117
+ audio_file_id: string;
118
+ caption?: string;
119
+ }
120
+ | {
121
+ type: "voice";
122
+ id: string;
123
+ voice_file_id: string;
124
+ title: string;
125
+ caption?: string;
126
+ };
127
+
128
+ interface TelegramGuestStagingMessage {
129
+ message_id?: number;
130
+ document?: { file_id?: string };
131
+ photo?: Array<{ file_id?: string; file_size?: number }>;
132
+ audio?: { file_id?: string };
133
+ voice?: { file_id?: string };
134
+ }
135
+
99
136
  function isTelegramOutboundPhotoAttachmentPath(path: string): boolean {
100
137
  const normalized = path.toLowerCase();
101
138
  return (
@@ -107,6 +144,27 @@ function isTelegramOutboundPhotoAttachmentPath(path: string): boolean {
107
144
  );
108
145
  }
109
146
 
147
+ function getTelegramGuestAttachmentTransport(path: string): {
148
+ method: "sendDocument" | "sendPhoto" | "sendAudio" | "sendVoice";
149
+ fileField: "document" | "photo" | "audio" | "voice";
150
+ } {
151
+ const normalized = path.toLowerCase();
152
+ if (
153
+ normalized.endsWith(".jpg") ||
154
+ normalized.endsWith(".jpeg") ||
155
+ normalized.endsWith(".png")
156
+ ) {
157
+ return { method: "sendPhoto", fileField: "photo" };
158
+ }
159
+ if (normalized.endsWith(".ogg") || normalized.endsWith(".opus")) {
160
+ return { method: "sendVoice", fileField: "voice" };
161
+ }
162
+ if (normalized.endsWith(".mp3")) {
163
+ return { method: "sendAudio", fileField: "audio" };
164
+ }
165
+ return { method: "sendDocument", fileField: "document" };
166
+ }
167
+
110
168
  function formatTelegramOutboundAttachmentSizeLimitError(
111
169
  size: number,
112
170
  maxSize: number,
@@ -388,6 +446,14 @@ export async function queueTelegramOutboundAttachments(options: {
388
446
  statPath: options.statPath,
389
447
  });
390
448
  }
449
+ if (
450
+ options.activeTurn.guestQueryId &&
451
+ options.activeTurn.queuedAttachments.length + options.paths.length > 1
452
+ ) {
453
+ throw new Error(
454
+ "Telegram Guest Mode supports one attachment per reply; no attachment was queued",
455
+ );
456
+ }
391
457
  if (
392
458
  options.activeTurn.queuedAttachments.length + options.paths.length >
393
459
  options.maxAttachmentsPerTurn
@@ -414,6 +480,110 @@ export async function queueTelegramOutboundAttachments(options: {
414
480
  };
415
481
  }
416
482
 
483
+ export async function deliverTelegramGuestCachedAttachment(options: {
484
+ guestQueryId: string;
485
+ stagingChatId: number;
486
+ stagingTarget?: TelegramTarget;
487
+ attachment: TelegramQueuedOutboundAttachmentView;
488
+ caption?: string;
489
+ sendMultipart: TelegramQueuedOutboundAttachmentDeliveryDeps["sendMultipart"];
490
+ answerGuestQuery: (
491
+ guestQueryId: string,
492
+ result: TelegramGuestCachedAttachmentResult,
493
+ ) => Promise<void>;
494
+ answerGuestText?: (guestQueryId: string, text: string) => Promise<void>;
495
+ fallbackText?: string;
496
+ deleteMessage: (chatId: number, messageId: number) => Promise<void>;
497
+ recordRuntimeEvent?: TelegramOutboundAttachmentRuntimeEventRecorderPort["recordRuntimeEvent"];
498
+ }): Promise<void> {
499
+ const transport = getTelegramGuestAttachmentTransport(options.attachment.path);
500
+ let stagingMessageId: number | undefined;
501
+ let answerAttempted = false;
502
+ try {
503
+ const message = (await options.sendMultipart(
504
+ transport.method,
505
+ {
506
+ chat_id: String(options.stagingChatId),
507
+ ...getTelegramMultipartTargetFields(options.stagingTarget),
508
+ },
509
+ transport.fileField,
510
+ options.attachment.path,
511
+ options.attachment.fileName,
512
+ )) as TelegramGuestStagingMessage;
513
+ stagingMessageId = message.message_id;
514
+ const caption = options.caption
515
+ ? Array.from(options.caption).slice(0, 1024).join("")
516
+ : undefined;
517
+ let result: TelegramGuestCachedAttachmentResult;
518
+ if (transport.fileField === "photo") {
519
+ const photo = [...(message.photo ?? [])]
520
+ .sort((left, right) => (left.file_size ?? 0) - (right.file_size ?? 0))
521
+ .at(-1);
522
+ if (!photo?.file_id) throw new Error("Guest staging upload returned no photo file_id");
523
+ result = {
524
+ type: "photo",
525
+ id: "attachment-1",
526
+ photo_file_id: photo.file_id,
527
+ ...(caption ? { caption } : {}),
528
+ };
529
+ } else if (transport.fileField === "audio") {
530
+ if (!message.audio?.file_id)
531
+ throw new Error("Guest staging upload returned no audio file_id");
532
+ result = {
533
+ type: "audio",
534
+ id: "attachment-1",
535
+ audio_file_id: message.audio.file_id,
536
+ ...(caption ? { caption } : {}),
537
+ };
538
+ } else if (transport.fileField === "voice") {
539
+ if (!message.voice?.file_id)
540
+ throw new Error("Guest staging upload returned no voice file_id");
541
+ result = {
542
+ type: "voice",
543
+ id: "attachment-1",
544
+ voice_file_id: message.voice.file_id,
545
+ title: options.attachment.fileName,
546
+ ...(caption ? { caption } : {}),
547
+ };
548
+ } else {
549
+ if (!message.document?.file_id)
550
+ throw new Error("Guest staging upload returned no document file_id");
551
+ result = {
552
+ type: "document",
553
+ id: "attachment-1",
554
+ title: options.attachment.fileName,
555
+ document_file_id: message.document.file_id,
556
+ ...(caption ? { caption } : {}),
557
+ };
558
+ }
559
+ answerAttempted = true;
560
+ await options.answerGuestQuery(options.guestQueryId, result);
561
+ } catch (error) {
562
+ if (
563
+ !answerAttempted &&
564
+ options.answerGuestText &&
565
+ options.fallbackText
566
+ ) {
567
+ answerAttempted = true;
568
+ await options.answerGuestText(options.guestQueryId, options.fallbackText);
569
+ } else {
570
+ throw error;
571
+ }
572
+ } finally {
573
+ if (stagingMessageId !== undefined) {
574
+ try {
575
+ await options.deleteMessage(options.stagingChatId, stagingMessageId);
576
+ } catch (error) {
577
+ options.recordRuntimeEvent?.("attachment", error, {
578
+ phase: "guest-staging-cleanup",
579
+ chatId: options.stagingChatId,
580
+ messageId: stagingMessageId,
581
+ });
582
+ }
583
+ }
584
+ }
585
+ }
586
+
417
587
  export async function sendTelegramOutboundMessage(options: {
418
588
  text: string;
419
589
  chatId?: number;
package/lib/paths.ts CHANGED
@@ -76,9 +76,10 @@ export function getTelegramDiagnosticsDisplayPaths(profileName?: string): {
76
76
  logs: string;
77
77
  } {
78
78
  const suffix = getTelegramProfilePathSuffix(profileName);
79
+ const profileSlug = suffix.slice(1);
79
80
  return {
80
81
  state: `~/.pi/agent/tmp/telegram/state${suffix}.json`,
81
- logs: `~/.pi/agent/tmp/telegram/logs${suffix}.jsonl`,
82
+ logs: `~/.pi/agent/tmp/telegram/logs${profileSlug ? `.${profileSlug}` : ""}.jsonl`,
82
83
  };
83
84
  }
84
85
 
package/lib/queue.ts CHANGED
@@ -899,6 +899,16 @@ export interface TelegramAgentEndRuntimeDeps<
899
899
  options?: { parseMode?: string },
900
900
  ) => Promise<void>;
901
901
  sendGuestReply?: (guestQueryId: string, markdown: string) => Promise<void>;
902
+ sendGuestAttachment?: (
903
+ turn: TTurn,
904
+ attachment: QueuedAttachment,
905
+ caption?: string,
906
+ ) => Promise<void>;
907
+ sendGuestVoiceReply?: (
908
+ turn: TTurn,
909
+ plan: TelegramAgentEndOutboundReplyPlan<TReplyMarkup>,
910
+ caption?: string,
911
+ ) => Promise<void>;
902
912
  planOutboundReply?: (
903
913
  markdown: string,
904
914
  ) => TelegramAgentEndOutboundReplyPlan<TReplyMarkup>;
@@ -958,6 +968,8 @@ export interface TelegramAgentEndHookRuntimeDeps<
958
968
  sendQueuedAttachments: (turn: TTurn) => Promise<void>;
959
969
  answerGuestQuery?: TelegramAgentEndRuntimeDeps<TTurn>["answerGuestQuery"];
960
970
  sendGuestReply?: TelegramAgentEndRuntimeDeps<TTurn>["sendGuestReply"];
971
+ sendGuestAttachment?: TelegramAgentEndRuntimeDeps<TTurn>["sendGuestAttachment"];
972
+ sendGuestVoiceReply?: TelegramAgentEndRuntimeDeps<TTurn>["sendGuestVoiceReply"];
961
973
  planOutboundReply?: TelegramAgentEndRuntimeDeps<
962
974
  TTurn,
963
975
  TReplyMarkup
@@ -1083,6 +1095,8 @@ export function createTelegramAgentEndHook<
1083
1095
  sendQueuedAttachments: deps.sendQueuedAttachments,
1084
1096
  answerGuestQuery: deps.answerGuestQuery,
1085
1097
  sendGuestReply: deps.sendGuestReply,
1098
+ sendGuestAttachment: deps.sendGuestAttachment,
1099
+ sendGuestVoiceReply: deps.sendGuestVoiceReply,
1086
1100
  planOutboundReply: deps.planOutboundReply,
1087
1101
  sendOutboundReplyArtifacts: deps.sendOutboundReplyArtifacts,
1088
1102
  getDefaultChatId: deps.getDefaultChatId,
@@ -1181,7 +1195,38 @@ export async function handleTelegramAgentEndRuntime<
1181
1195
  if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
1182
1196
  return;
1183
1197
  }
1184
- if (finalText) {
1198
+ const [guestAttachment] = turn.queuedAttachments;
1199
+ if (guestAttachment && deps.sendGuestAttachment) {
1200
+ try {
1201
+ await deps.sendGuestAttachment(
1202
+ turn,
1203
+ guestAttachment,
1204
+ finalText || undefined,
1205
+ );
1206
+ } catch (error) {
1207
+ deps.recordRuntimeEvent?.("delivery", error, {
1208
+ phase: "guest-attachment",
1209
+ guestQueryId: turn.guestQueryId,
1210
+ });
1211
+ }
1212
+ } else if (
1213
+ outboundReply &&
1214
+ (outboundReply.voiceText || outboundReply.voiceReplies?.length) &&
1215
+ deps.sendGuestVoiceReply
1216
+ ) {
1217
+ try {
1218
+ await deps.sendGuestVoiceReply(
1219
+ turn,
1220
+ outboundReply,
1221
+ finalText || undefined,
1222
+ );
1223
+ } catch (error) {
1224
+ deps.recordRuntimeEvent?.("delivery", error, {
1225
+ phase: "guest-voice",
1226
+ guestQueryId: turn.guestQueryId,
1227
+ });
1228
+ }
1229
+ } else if (finalText) {
1185
1230
  if (deps.sendGuestReply) {
1186
1231
  await deps.sendGuestReply(turn.guestQueryId, finalText);
1187
1232
  } else {
package/lib/setup.ts CHANGED
@@ -197,8 +197,14 @@ export function createTelegramSetupPromptRuntime<
197
197
  promptEditor: (label, value) => ctx.ui.editor(label, value),
198
198
  getMe: deps.getMe,
199
199
  persistConfig: async (config) => {
200
- await deps.persistConfig(config);
200
+ const previousConfig = deps.getConfig();
201
201
  deps.setConfig(config);
202
+ try {
203
+ await deps.persistConfig(config);
204
+ } catch (error) {
205
+ deps.setConfig(previousConfig);
206
+ throw error;
207
+ }
202
208
  },
203
209
  notify: (message, level) => ctx.ui.notify(message, level),
204
210
  startPolling: () => deps.startPolling(ctx),
package/lib/status.ts CHANGED
@@ -181,6 +181,7 @@ export interface TelegramBridgeStatusLineState {
181
181
  hasBotToken?: boolean;
182
182
  botUsername?: string;
183
183
  activeProfileName?: string;
184
+ diagnosticPaths?: { state: string; logs: string };
184
185
  allowedUserId?: number;
185
186
  botThreadMode?: "unknown" | "enabled" | "disabled";
186
187
  botThreadModeUpdatedAtMs?: number;
@@ -259,6 +260,9 @@ export interface TelegramBridgeStatusRuntimeDeps<
259
260
  statusKey?: string;
260
261
  getConfig: () => TelegramBridgeStatusConfig;
261
262
  getActiveProfileName?: () => string | undefined;
263
+ getDiagnosticPaths?: (
264
+ profileName?: string,
265
+ ) => { state: string; logs: string };
262
266
  isPollingActive: () => boolean;
263
267
  getActiveSourceMessageIds: () => number[] | undefined;
264
268
  hasActiveTurn: () => boolean;
@@ -620,10 +624,12 @@ export function createTelegramBridgeStatusRuntime<
620
624
  getBridgeStatusLineState: () => {
621
625
  const config = deps.getConfig();
622
626
  const botThreadMode = deps.getBotThreadMode?.();
627
+ const activeProfileName = deps.getActiveProfileName?.();
623
628
  return {
624
629
  hasBotToken: Boolean(config.botToken),
625
630
  botUsername: config.botUsername,
626
- activeProfileName: deps.getActiveProfileName?.(),
631
+ activeProfileName,
632
+ diagnosticPaths: deps.getDiagnosticPaths?.(activeProfileName),
627
633
  allowedUserId: config.allowedUserId,
628
634
  botThreadMode: botThreadMode?.threadMode,
629
635
  botThreadModeUpdatedAtMs: botThreadMode?.updatedAtMs,
@@ -774,9 +780,6 @@ export function buildTelegramStatusBarText(
774
780
  return `${label} ${theme.fg("warning", "electing")}${queued}`;
775
781
  if (!state.pollingActive && state.busRole !== "follower")
776
782
  return `${theme.fg("accent", "telegram")} ${theme.fg("muted", "disconnected")}${queued}`;
777
- if (state.compactionInProgress) {
778
- return `${label} ${theme.fg("warning", "compacting")}${queued}`;
779
- }
780
783
  if (state.processing) {
781
784
  const processingStatus = state.queuedStatus
782
785
  ? "active"
@@ -1058,19 +1061,18 @@ function buildTelegramBridgeCompactStatusLines(
1058
1061
  ? ` (control=${controlQueueCount}, priority=${priorityQueueCount}, default=${defaultQueueCount})`
1059
1062
  : ""
1060
1063
  }`;
1061
- const executionState = state.compactionInProgress
1062
- ? "compacting"
1063
- : state.pendingDispatch
1064
- ? "pending dispatch"
1065
- : state.activeSourceMessageIds?.length
1066
- ? "active"
1067
- : "idle";
1064
+ const executionState = state.pendingDispatch
1065
+ ? "pending dispatch"
1066
+ : state.activeSourceMessageIds?.length
1067
+ ? "active"
1068
+ : "idle";
1068
1069
  const profileSuffix = state.activeProfileName
1069
1070
  ? `.${state.activeProfileName.replace(/[^a-zA-Z0-9._-]+/g, "_")}`
1070
1071
  : "";
1071
- const diagnosticsPaths = {
1072
+ const profileSlug = profileSuffix.slice(1);
1073
+ const diagnosticsPaths = state.diagnosticPaths ?? {
1072
1074
  state: `~/.pi/agent/tmp/telegram/state${profileSuffix}.json`,
1073
- logs: `~/.pi/agent/tmp/telegram/logs${profileSuffix}.jsonl`,
1075
+ logs: `~/.pi/agent/tmp/telegram/logs${profileSlug ? `.${profileSlug}` : ""}.jsonl`,
1074
1076
  };
1075
1077
  return [
1076
1078
  "connection:",
@@ -1251,7 +1253,6 @@ function buildContextSummary(
1251
1253
  }
1252
1254
 
1253
1255
  function buildStatusSummary(ctx: TelegramStatusContext): string {
1254
- if (ctx.isCompactionInProgress?.()) return "compacting";
1255
1256
  if (ctx.hasPendingMessages?.()) return "pending";
1256
1257
  if (ctx.isIdle?.() === false) return "active";
1257
1258
  if (ctx.isIdle?.() === true) return "idle";
@@ -287,6 +287,44 @@ export interface TelegramFileDownloadOptions {
287
287
  maxFileSizeBytes?: number;
288
288
  }
289
289
 
290
+ export type TelegramGuestCachedMediaResult =
291
+ | {
292
+ type: "document";
293
+ id: string;
294
+ title: string;
295
+ document_file_id: string;
296
+ caption?: string;
297
+ parse_mode?: string;
298
+ }
299
+ | {
300
+ type: "photo";
301
+ id: string;
302
+ photo_file_id: string;
303
+ caption?: string;
304
+ parse_mode?: string;
305
+ }
306
+ | {
307
+ type: "audio";
308
+ id: string;
309
+ audio_file_id: string;
310
+ caption?: string;
311
+ parse_mode?: string;
312
+ }
313
+ | {
314
+ type: "voice";
315
+ id: string;
316
+ voice_file_id: string;
317
+ title: string;
318
+ caption?: string;
319
+ parse_mode?: string;
320
+ };
321
+
322
+ export interface TelegramAnswerGuestQueryOptions {
323
+ parseMode?: string;
324
+ richMessage?: TelegramInputRichMessage;
325
+ result?: TelegramGuestCachedMediaResult;
326
+ }
327
+
290
328
  export interface TelegramAnswerCallbackQueryOptions {
291
329
  recordRuntimeEvent?: (
292
330
  kind: "api",
@@ -322,7 +360,7 @@ export interface TelegramApiClient {
322
360
  answerGuestQuery?: (
323
361
  guestQueryId: string,
324
362
  text?: string,
325
- options?: { parseMode?: string; richMessage?: TelegramInputRichMessage },
363
+ options?: TelegramAnswerGuestQueryOptions,
326
364
  ) => Promise<void>;
327
365
  }
328
366
 
@@ -401,7 +439,7 @@ export interface TelegramBridgeApiRuntime {
401
439
  answerGuestQuery: (
402
440
  guestQueryId: string,
403
441
  text?: string,
404
- options?: { parseMode?: string; richMessage?: TelegramInputRichMessage },
442
+ options?: TelegramAnswerGuestQueryOptions,
405
443
  ) => Promise<void>;
406
444
  deleteMessage: (chatId: number, messageId: number) => Promise<void>;
407
445
  prepareTempDir: () => Promise<number>;
@@ -1312,12 +1350,12 @@ export function createTelegramBridgeApiRuntime(
1312
1350
  answerGuestQuery: (
1313
1351
  guestQueryId: string,
1314
1352
  text: string | undefined,
1315
- options:
1316
- | { parseMode?: string; richMessage?: TelegramInputRichMessage }
1317
- | undefined,
1353
+ options: TelegramAnswerGuestQueryOptions | undefined,
1318
1354
  ) => {
1319
1355
  const body: Record<string, unknown> = { guest_query_id: guestQueryId };
1320
- if (text !== undefined || options?.richMessage) {
1356
+ if (options?.result) {
1357
+ body.result = options.result;
1358
+ } else if (text !== undefined || options?.richMessage) {
1321
1359
  const inputContent: Record<string, unknown> = options?.richMessage
1322
1360
  ? { rich_message: options.richMessage }
1323
1361
  : { message_text: text };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.20.4",
3
+ "version": "0.20.5",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"