@llblab/pi-telegram 0.23.1 → 0.23.3
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/AGENTS.md +5 -5
- package/CHANGELOG.md +13 -0
- package/README.md +3 -3
- package/docs/architecture.md +6 -6
- package/docs/outbound.md +1 -1
- package/docs/public-api.md +2 -2
- package/docs/ui-style.md +1 -1
- package/docs/voice.md +8 -9
- package/index.ts +11 -8
- package/lib/bindings.ts +10 -7
- package/lib/config.ts +28 -9
- package/lib/menu-settings.ts +46 -97
- package/lib/prompts.ts +4 -2
- package/lib/queue.ts +52 -8
- package/lib/routing.ts +10 -16
- package/lib/status.ts +1 -1
- package/lib/text-groups.ts +29 -15
- package/lib/turns.ts +25 -21
- package/lib/voice.ts +6 -7
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -87,7 +87,7 @@ The core product loop is mobile continuation: start or supervise work in the ter
|
|
|
87
87
|
- Instance slots are extension-owned ring-order metadata. Allocate fresh slots from the `bot.lastSlot` cursor as `A` → `B` → ... → `Z` → `A`, allowing the cursor to move to lower letters after wraparound. Only current live/recovering bindings plus unexpired pending provisions and explicit reservations occupy slots; historical records must not block allocation indefinitely. Preserve a slot on ordinary current binding/thread reuse without advancing or rewinding the fresh-allocation cursor. Follower bindings survive process absence as stable restoration hints until explicit stale/deleted/offline evidence invalidates them. Successful target reuse must refresh the binding timestamp and replacement runtime identity; generic leader startup or heartbeat pruning must not erase the target merely because no follower registration is currently live. An authenticated live follower whose carried exact target is missing from current persisted bindings should recover that target instead of creating another Telegram thread; preserve a carried slot only when it does not collide with another current record. Preserve a cursor that belongs to a live or recoverable binding and continue skipping retained restart hints plus unexpired pending/reserved slots. Explicit stale/deleted/offline reconciliation may release that slot; generic process absence may not. The alphabet cap is a feature — compact single-letter tabs are scannable.
|
|
88
88
|
- Instance thread names are provisioner-authored identity metadata, not model-authored output. Fresh Threaded Mode threads select one of five curated compact 4-6 letter Latin names for the assigned slot letter using provisioning timestamp entropy, then create the Telegram thread with that thread name immediately. The title is the thread name itself, not `{slot} {threadName}` or `{slot} — {threadName}`. Do not register or reintroduce an agent-facing thread rename tool; removing the extra rename prompt/tool turn is an intentional simplicity constraint. Thread names must mirror into terminal status, live diagnostics, and `[telegram|thread:name]` through one target-aware current-instance identity resolver. Registered follower/leader metadata takes precedence over a stale shared record for the same target, with the record used only as fallback; do not hide a valid baked/custom thread name behind old slot-prefix validation. A follower that later becomes leader keeps its existing name; leadership changes are transport role changes, not identity resets.
|
|
89
89
|
- Telegram private-chat Threaded Mode treats `All` as an aggregate/control surface, not a durable `General` thread or process launcher, and leader activation proactively creates or reuses its private-DM thread. When the owner writes in an unknown thread, the leader reclaims and routes the first one only if it lacks an active binding; later unknown threads remain intact and receive an in-thread chooser to reroute the captured prompt or restore a stale binding. Destructive cleanup requires explicit confirmation through `thread-reconciler`; another Pi instance still requires a manually started process followed by `/telegram-connect`. Without Threaded Mode, process the message through classic routing.
|
|
90
|
-
- Telegram extension work should not hold Pi's core agent lifecycle hostage once Pi has produced the semantic result. For Telegram-originated turns, final Telegram delivery, attachment upload, and transport cleanup are extension-owned side effects; schedule them off the critical `agent_end` path when ordering can still be preserved, record failures as runtime events, and keep dispatch of the next Telegram turn behind the delivery task when needed to avoid interleaving previews/finals. Public activity handlers run through isolated asynchronous per-handler queues; lifecycle hooks enqueue normalized events and never await consumer work. Proactive push defaults enabled and requires explicit `assistant.proactivePush: false` to opt out. While enabled, core delivery projects each completed Pi-visible assistant text block from local/autonomous work once and in order; bind admitted blocks to the exact target, profile/token transport stamp, direct leader epoch or follower registration generation, and session generation. Token deltas, hidden reasoning, tool traffic, stale authority, and Telegram-owned turns never enter that projection.
|
|
90
|
+
- Telegram extension work should not hold Pi's core agent lifecycle hostage once Pi has produced the semantic result. For Telegram-originated turns, final Telegram delivery, attachment upload, and transport cleanup are extension-owned side effects; schedule them off the critical `agent_end` path when ordering can still be preserved, record failures as runtime events, and keep dispatch of the next Telegram turn behind the delivery task when needed to avoid interleaving previews/finals. Treat a low-level error `agent_end` as retryable while its Telegram turn remains active; only a later successful `agent_end` or Pi's `agent_settled` boundary may finalize and release that turn. Public activity handlers run through isolated asynchronous per-handler queues; lifecycle hooks enqueue normalized events and never await consumer work. Proactive push defaults enabled and requires explicit `assistant.proactivePush: false` to opt out. While enabled, core delivery projects each completed Pi-visible assistant text block from local/autonomous work once and in order; bind admitted blocks to the exact target, profile/token transport stamp, direct leader epoch or follower registration generation, and session generation. Token deltas, hidden reasoning, tool traffic, stale authority, and Telegram-owned turns never enter that projection.
|
|
91
91
|
- Telegram runtime state should be treated as event-driven reconciliation of extension assumptions against observable Telegram signals, not as a full Telegram bot read-model and not as a reason to query Telegram on every action. Keep a local assumption model for bot identity/capabilities, pairing assumptions, thread support, known thread lifecycle, bound instance targets, reservations, and transport health. Invalidate and reconcile the relevant slice on meaningful events: startup/reload, lifecycle service messages, stale-send/API errors, setup/pairing changes, follower register/prune, explicit status/debug requests, and bounded low-frequency leader health ticks. `lib/sync.ts` owns sync slices, observation intake, invalidation triggers, status/debug freshness, and reconciliation scheduling; it must not promise complete bot-state mirroring because Bot API lacks a complete topic listing surface. `lib/thread-reconciler.ts` owns pure Threaded Mode lifecycle planning and should become the only policy authority for destructive thread cleanup decisions. `threads` owns current Telegram UI thread/tab binding primitives and thread-name helpers mapped to Bot API topic transport, `status` renders projections, and `index.ts` only wires ports. `tmp/telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging and resets on extension start / runtime scope changes; it is diagnostics only, not routing/provisioning authority. `state.json` should be an observable/debug snapshot aligned with `/telegram-status` (extension runtime, bot capabilities including `bot.lastSlot`, runtime role, live roster projection, reservations, diagnostics), not routing/provisioning authority. Because every process on one Telegram profile shares this file, only the active transport lock owner may persist it; followers read current state and gain write authority only after promotion. Status-only writes must refresh disk-backed bindings before serialization so a stale loaded snapshot cannot erase newer leader state. Live bus/runtime state is authoritative; file reservations and fresh capability observations may be startup hints/collision guards only; stale snapshots must re-probe before suppressing bus/topic behavior. Do not persist stale/offline/failed thread target history as source of truth.
|
|
92
92
|
- Thread bindings are bidirectional. From the Pi-instance side, an instance knows and preserves its target, slot, thread name, lifecycle state, and direct-delivery defaults. From the Telegram-client/bot side, the bridge observes thread creation/close/reopen/stale-send signals and reconciles them into instance binding state. Lifecycle transitions that affect operator understanding should be Telegram-visible when a live transport remains: every successful follower registration/re-registration gets a compact connected notice in the assigned thread, while heartbeat pruning stays silent because it is only liveness suspicion. These notices should use the instance thread name when known and fall back to the slot letter only while the thread is still unnamed. New unknown threads (owner writing in `All`) are preserved until the user explicitly chooses a reroute/restore/new-instance action that makes cleanup eligible; any destructive cleanup must go through `thread-reconciler`. Unknown `forum_topic_created` service events are observations, not destructive cleanup proof.
|
|
93
93
|
- The multi-instance bus uses private bot DMs with Telegram private-chat Threaded Mode enabled for the bot. No supergroup, group, or forum configuration is needed; the thread chat is always the private chat with the paired owner. Threaded capability checks must use bot/private-chat evidence such as `getMe.has_topics_enabled`, incoming `message_thread_id`, and topic operation success/failure; do not use group chat metadata as the control-plane truth for Telegram private-chat Threaded Mode.
|
|
@@ -116,12 +116,12 @@ The core product loop is mobile continuation: start or supervise work in the ter
|
|
|
116
116
|
- `telegram_attach` is the canonical outbound file-delivery path for Telegram-originated requests; outside active Telegram turns it may send immediately to the paired/default chat, a registered follower's assigned target, or explicit `chat_id` plus optional `thread_id` for explicit local/TUI delivery requests only when this Pi instance owns `/telegram-connect` or is registered with the Threaded Mode multi-instance bus. `telegram_message` is the first-class direct Telegram Markdown text tool for local/TUI prompts and follows the same ownership-or-bus-registration gate; neither direct tool replaces normal active-turn replies. It reuses top-level `telegram_button` comments for inline buttons; buttons must be attached to a text message, never sent as standalone actions
|
|
117
117
|
- Telegram prompt guidance is layered and context-frugal: unconfigured sessions receive no bridge suffix, configured sessions receive compact direct-delivery/help guidance, and Telegram-originated turns receive only the minimal per-turn contract. Detailed voice/button/direct-delivery/Threaded Mode/formatting/debug guidance belongs in the agent-facing `telegram_help` tool instead of being repeated in every prompt.
|
|
118
118
|
- Telegram inbound prompt topology keeps current user material contiguous: `[outputs]` produced from current attachments follows `[attachments]` immediately, then independent `[reply]` or forwarded-source context follows the complete current-user block. Forwarded Rich Message media belongs under source-attributed `[attachments|from:<identity>]` immediately after its `[forward|from:<identity>]` block and remains available as model image content. Do not place source context between an attachment and its transcription or handler output.
|
|
119
|
-
- Forward-
|
|
119
|
+
- Forward-annotation grouping distinguishes two semantic layers: the forward owns its source text/caption/media, while an optional separate owner-authored annotation normally arrives immediately before it. Use a bounded one-second candidate window in both transport orders: ordinary short human text may wait for an adjacent same-sender/chat/thread forward, and a forwarded message—including a media-only forward with no source caption—may wait for one adjacent owner annotation. A matching opposite-kind message flushes immediately; same-kind messages, commands, bots, ordinary non-forward captions, media groups, target/sender mismatches, reversed ids, and distant ids stay separate. Preserve the candidate across session replacement and follower forwarding without weakening exact routing-generation authority.
|
|
120
120
|
- `telegram_voice` and `telegram_button` are not Pi tools; keep prompts/docs explicit that agents should author markup while voice synthesis provider extensions own TTS/OGG conversion, and pi-telegram owns button routing plus Telegram delivery
|
|
121
|
-
- Voice reply policy and prompt context are owned by pi-telegram's `telegram.json` `voice.replyMode`: missing
|
|
121
|
+
- Voice reply policy and prompt context are owned by pi-telegram's `telegram.json` `voice.replyMode`: missing, invalid, `hidden`, and legacy `manual` config resolve to `hidden` and add no `[voice]` context; `mirror` adds `[voice] delivery: automatic voice` only for voice/audio input, while `always` adds that same single factual line to every Telegram turn. Place voice context after `[outputs]` when handler output exists, otherwise after `[attachments]`; provider prompt contributions are optional provider-specific additions, not the default policy or delivery-composition channel.
|
|
122
122
|
- Optional `telegram.json` `time` may add `[time] YYYY-MM-DD HH:mm:ss <timezone>` to Telegram-originated prompts for wall-clock context. It is hidden by default, uses `time.injectionMode` values `hidden|always|interval`, stores `time.interval` in milliseconds, uses the system timezone, and should render last after `[attachments]`, `[outputs]`, and `[voice]` sections. The Settings row `🕒 Time injection: hidden|always|interval` controls `time.injectionMode` only.
|
|
123
123
|
- Agent activity status is native-only for the release path: Telegram uses `sendChatAction(typing)` and renders its native `…typing` indicator as the automatic work signal. When a connected instance starts any real agent run, send that indicator to the instance's own thread (and aggregate `All` surface), including Telegram turns, local/TUI prompts, and autonomous continuations such as Grow Loop; stop it at agent/session completion. This signal is independent from terminal `Active`, which remains scoped to Telegram-owned turns. Confirmed manual `/compact` and automatic compaction tied to an active Telegram turn also use native activity. Do not send activity merely for startup/connect/reload/recovery or add extra in-chat work surfaces without a new explicit design decision.
|
|
124
|
-
- Voice reply mode Settings UI standard: the top-level Settings row is `👄 Voice reply: hidden|
|
|
124
|
+
- Voice reply mode Settings UI standard: the top-level Settings row is `👄 Voice reply: hidden|mirror|always`; `hidden` is the true default, persists no `voice.replyMode`, adds no voice context, and still allows explicit agent-authored `telegram_voice`; `mirror` activates automatic voice context only for voice/audio input, and `always` activates it for every Telegram turn. The submenu title is `👄 Voice reply mode:`; choice buttons use lowercase labels with a model-style active dot (`🟢 hidden`, `🟢 mirror`) rather than per-mode emoji; the explanatory submenu body uses compact HTML-code bullets such as `<code>-</code> <code>hidden</code> (default): ...`. Preserve this wording/icons unless the operator explicitly asks to redesign it
|
|
125
125
|
- Outbound voice delivery is one fallback pipeline: configured `outboundHandlers` with `type: "voice"` run first in `telegram.json` order, then programmatic voice handlers, then registered voice synthesis providers as zero-config progressive fallbacks; provider extensions must not override operator-configured handlers
|
|
126
126
|
- `telegram_voice` text is arbitrary TTS-target text and the prompt should show only forms that include text to speak: `<!-- telegram_voice text="Short summary" -->` or `<!-- telegram_voice: Short summary -->`. Avoid raw Markdown/code/tables in voice text.
|
|
127
127
|
- `telegram_button` has three canonical forms: `<!-- telegram_button: OK -->` for label-only buttons, `<!-- telegram_button label=Continue prompt="Continue with the current plan." -->` for one-line prompts, or `<!-- telegram_button label="Show risks"\nList the main risks first.\n-->` for multiline prompts. Do not author JSON button specs, inline-after-text comments, standalone button tools, or comments inside code/quotes/lists/indented examples; write normal Markdown plus top-level hidden comments, and add visible parent text when buttons would otherwise be the only output
|
|
@@ -149,7 +149,7 @@ The core product loop is mobile continuation: start or supervise work in the ter
|
|
|
149
149
|
|
|
150
150
|
- Treat queue handling, compaction interaction, and lifecycle-hook state transitions as regression-prone areas; validate them after changing dispatch logic
|
|
151
151
|
- Keep the standard `npm run typecheck` gate strict about unused locals and parameters so stale imports and abandoned adapters fail validation instead of accumulating silently
|
|
152
|
-
- Route important runtime failures through the recent runtime event recorder so `/telegram-status` remains useful for post-mortem debugging
|
|
152
|
+
- Route important runtime failures through the recent runtime event recorder so `/telegram-status` remains useful for post-mortem debugging. Keep the compact TUI status bar bounded to the generic `error` state; detailed error messages belong only in diagnostics/logs, never inline beside the status label.
|
|
153
153
|
- Treat remaining Markdown-to-HTML rendering as Telegram UI/compat output work, not generic Markdown rendering or assistant reply delivery
|
|
154
154
|
- Preserve literal code content in Telegram rendering
|
|
155
155
|
- Avoid HTML chunk splits that break tags
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.23.3: Thread-Scoped Settings Hotfix
|
|
4
|
+
|
|
5
|
+
- `Thread-Scoped Settings Rehydration`: Preserved `message_thread_id` when rebuilding full Settings menu state after session reload or TTL expiry. Impact: a stale Settings message in Threaded Mode retains its exact Telegram target when later callbacks reopen menus or cross into model/status controls.
|
|
6
|
+
- `Composition Root Hygiene`: Sorted the local domain imports in `index.ts` and normalized adjacent wrapping without changing runtime composition. Impact: the entrypoint remains easier to scan and review while preserving the existing dependency graph and behavior.
|
|
7
|
+
|
|
8
|
+
## 0.23.2: Voice Policy And Turn Delivery Hotfix
|
|
9
|
+
|
|
10
|
+
- `Settings Persistence Race`: Rebuilt expired Settings callback state from the live model-menu context before applying mutations. Polling now persists only its monotonic `lastUpdateId` into the current config-store snapshot instead of later submitting the detached full config object captured when polling started. Impact: a subsequent Telegram update can no longer erase freshly persisted `voice.replyMode`, `assistant.proactivePush`, or other Settings values and return the menu to stale defaults.
|
|
11
|
+
- `Compact Error Status`: Reduced pi-telegram's TUI error projection to the single `error` state while retaining detailed failures in runtime diagnostics and logs. Impact: provider and transport messages no longer consume the status line or displace the surrounding model/session indicators.
|
|
12
|
+
- `Forward Annotation Pairing`: Extended the bounded one-second coalescer so one optional owner-authored annotation and adjacent forward join in either transport order, including photo-only forwards without their own source caption. Prompt construction keeps the layers distinct: owner annotation first, then `[forward|from:...]` with the forward's own source text/caption, then source-attributed media; same-kind messages and existing command/sender/target/id-gap exclusions remain separate. Impact: Telegram forwarding gestures no longer split the user's explanation from the forwarded source or conflate it with the forward's own content.
|
|
13
|
+
- `Voice Reply Policy`: Collapsed the redundant explicit `manual` mode into the silent `hidden` default, kept `mirror` automatic only for voice/audio input, and kept `always` automatic for every Telegram turn. Active automatic turns now carry exactly `[voice] delivery: automatic voice`; the per-turn system suffix only points to `telegram_help`, explicit `telegram_voice` remains an agent-authored override, and legacy `manual` config resolves to `hidden`. Impact: models receive one effective delivery fact instead of a noisy mode matrix, while providers remain responsible for synthesis rather than text/voice composition.
|
|
14
|
+
- `Provider Retry Delivery`: Retained an active Telegram turn across low-level `agent_end` errors until Pi either produced a successful retry result or emitted `agent_settled`. Retry recovery now delivers the eventual semantic result once to the original target, while an exhausted retry finalizes the retained error once and releases queue state; redacted diagnostics distinguish retention, recovery, and settled failure. Impact: transient provider transport failures can no longer orphan a later successful reply from its originating Telegram turn.
|
|
15
|
+
|
|
3
16
|
## 0.23.1: Context Budget And Runtime Simplification
|
|
4
17
|
|
|
5
18
|
- `Bus Runtime Simplification`: Removed the no-op follower-binding recovery timer and grace configuration, the unused disconnected-announcement helper, and the duplicate follower-prune callback. Heartbeat pruning now emits one accurate diagnostic while preserving durable thread bindings. Impact: leader/follower recovery carries less dormant state and cannot imply offline cleanup that never occurs.
|
package/README.md
CHANGED
|
@@ -92,7 +92,7 @@ The first Telegram user to message the bot becomes the allowed owner. Other user
|
|
|
92
92
|
|
|
93
93
|
| Surface | What you can do | Why it matters |
|
|
94
94
|
| --- | --- | --- |
|
|
95
|
-
| Prompt intake | Send text, replies, edits, images, files, albums, voice notes, and handler output into Pi. | Telegram becomes a real mobile input surface
|
|
95
|
+
| Prompt intake | Send text, replies, edits, images, files, albums, voice notes, forwards with adjacent comments, and handler output into Pi. | Telegram becomes a real mobile input surface; one forward-plus-comment gesture stays one attributed prompt even for photo-only forwards. |
|
|
96
96
|
| Queue control | Inspect waiting turns, delete stale work, promote important prompts, continue, abort, stop, or force the next queued item. | Long Pi tasks keep running while new mobile prompts stay visible and controllable instead of interrupting or disappearing. |
|
|
97
97
|
| Operator menu | Use `/start` for status, prompt templates, model, thinking, settings, queue, extension sections, and diagnostics. | The bot is an operator panel, not a command cheat sheet. |
|
|
98
98
|
| Prompt templates | Run Pi prompt templates as Telegram-safe commands such as `/fix_tests`. | Reusable local workflows become phone-accessible without exposing arbitrary terminal commands. |
|
|
@@ -104,7 +104,7 @@ The first Telegram user to message the bot becomes the allowed owner. Other user
|
|
|
104
104
|
| Inbound files | Download inbound files to the Pi agent temp directory with size limits. | Screenshots, PDFs, datasets, and artifacts enter Pi as inspectable local files. |
|
|
105
105
|
| Outbound artifacts | Return generated files through `telegram_attach` during active turns or explicit direct delivery. | Agents send real artifacts as files, not pasted blobs. |
|
|
106
106
|
| Voice input | Route audio through configured command-template handlers, programmatic handlers, or STT providers. | Voice notes become usable prompt context. |
|
|
107
|
-
| Voice output |
|
|
107
|
+
| Voice output | Choose `hidden`, `mirror`, or `always`; active automatic turns carry one compact `[voice] delivery: automatic voice` line, while explicit `telegram_voice` remains available. | Voice policy stays dynamic and model-legible without duplicating the full action contract in every prompt. |
|
|
108
108
|
| Buttons | Turn top-level `telegram_button` comments into inline buttons. | Assistant-authored choices become native Telegram interactions. |
|
|
109
109
|
| Callback routing | Route known callbacks to the owner extension and unknown callbacks back into Pi. | Companion extensions can build UI without polling Telegram themselves. |
|
|
110
110
|
| Threaded Mode | Run one leader plus visible follower Pi instances through named private-chat threads. | One bot can host a local multi-instance Pi organism without hidden process spawning. |
|
|
@@ -165,7 +165,7 @@ Named profile identifiers contain only lowercase ASCII letters and digits (maxim
|
|
|
165
165
|
|
|
166
166
|
### Queue Runtime
|
|
167
167
|
|
|
168
|
-
Messages sent while Pi is busy become queued turns. Priority lanes support control actions and model-switch continuations. Queue controls let you inspect, delete, promote, and dispatch work from Telegram without touching the terminal.
|
|
168
|
+
Messages sent while Pi is busy become queued turns. Priority lanes support control actions and model-switch continuations. Queue controls let you inspect, delete, promote, and dispatch work from Telegram without touching the terminal. If Pi automatically retries a transient provider failure, the active Telegram turn stays bound until the successful reply arrives or Pi confirms that the run has settled.
|
|
169
169
|
|
|
170
170
|
### Native Rich Markdown
|
|
171
171
|
|
package/docs/architecture.md
CHANGED
|
@@ -135,7 +135,7 @@ Telegram private-chat Threaded Mode is the public switch for multi-instance Tele
|
|
|
135
135
|
|
|
136
136
|
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.
|
|
137
137
|
|
|
138
|
-
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. Config persistence serializes cross-process writers and applies each recursive mutation delta to the latest disk snapshot, so named-profile offsets and unrelated global/profile updates do not stale-replace one another. Runtime profile switching follows stop-old/commit-new ordering: reload keeps the selected identity stable, old polling/lock/bus teardown finishes while all dynamic resolvers still point at the old profile, and only then may activation expose the new token, lock key, state path, target namespace, and IPC endpoint. 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.
|
|
138
|
+
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. Config persistence serializes cross-process writers and applies each recursive mutation delta to the latest disk snapshot, so named-profile offsets and unrelated global/profile updates do not stale-replace one another. Polling publishes only its monotonic `lastUpdateId` into the current config-store snapshot; it never persists the detached full config object captured at poll start, which would interpret newer Settings fields as deletions. Runtime profile switching follows stop-old/commit-new ordering: reload keeps the selected identity stable, old polling/lock/bus teardown finishes while all dynamic resolvers still point at the old profile, and only then may activation expose the new token, lock key, state path, target namespace, and IPC endpoint. 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.
|
|
139
139
|
|
|
140
140
|
When Threaded Mode is active, the current polling owner is also the Telegram bus leader. The leader owns the local bus endpoint (Unix-domain socket on Unix-like platforms, named pipe on native Windows), polls `getUpdates`, performs direct Bot API calls, records follower heartbeats, prunes stale followers, and provisions Telegram UI thread targets through live runtime/bus state. Follower liveness is intentionally fast because heartbeat traffic is local IPC: followers heartbeat every `1s`, the leader treats them as stale after `2s`, and the prune loop runs every `1s` so stopped followers are detected promptly while active forwarded updates/API calls still refresh liveness. Heartbeat pruning is silent liveness bookkeeping: it preserves the follower thread binding and does not send a Telegram-visible disconnected notice, because the common cause may be leader reload or IPC handoff rather than a dead follower. Successful follower target reuse refreshes the binding's recovery timestamp. Absent follower bindings remain durable restoration hints until explicit stale, deleted, offline, or reconciliation evidence invalidates them; process absence and heartbeat pruning alone do not remove them. If an authenticated live follower carries an exact target that is absent from current bindings, the leader recovers it only behind a synchronous visibility probe: success activates it, explicit stale evidence provisions a replacement, and ambiguous failure rejects registration. A carried slot is restored only when it is not already occupied. `tmp/telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging; it resets on extension start and runtime scope changes, and must not become routing/provisioning authority. `tmp/telegram/state.json` is an extension+bot observable/debug snapshot aligned with status diagnostics: `source: "snapshot"` and `writtenAtMs` mark it as observational, not authoritative. All instances on one Telegram profile read the same snapshot, but only the active transport lock owner persists it; followers become writers only after promotion. Status-only persistence reloads current disk bindings before serialization, preventing a stale follower/status view from erasing newer leader-owned targets. Fresh capability observations may skip redundant startup probes, but stale snapshots re-probe before suppressing bus/thread behavior. Top-level `bot` mirrors bot-wide capabilities such as thread mode, `runtime` describes process role/status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors recent status/debug signals including the latest thread-reconciler phase/counts, `threads` stores current routeable bindings, TTL-bounded reservations explain short-lived slot collision guards, and TTL-pruned `pendingProvisions` protects in-flight topic creation slots from cleanup/allocation races. Fresh provisioning writes pending state before the Bot API create call, adds the returned target to the pending record, persists a `starting` binding, then promotes it to `active` and clears pending state. If final binding persistence fails after Telegram returns a thread id, the targeted pending provision remains as cleanup/retry evidence. Once targeted pending provisions expire, they are retained for `thread-reconciler` close/delete cleanup and pending scratchpad removal after a successful cleanup apply; untargeted expired pending records can prune without cleanup because no Telegram thread id exists. Runtime events coalesce status-snapshot writes so transient bus/API/update failures remain inspectable even when the operator has not opened `/telegram-status`. The bridge must not keep a durable `telegram-targets.json` target history; stale/offline/failed thread observations are pruned instead of reused. Previous-process leader bindings that still probe alive become reservations/collision guards, not routeable active threads, so a reloaded leader can take the next free slot without duplicating the same visible tab name. The thread chat is always the private bot DM with the paired owner (`allowedUserId`). In Telegram private-chat Threaded Mode, the leader creates/reuses its own thread before polling — it is a real bound instance, not a dispatcher. Followers authenticate bus envelopes with the leader-minted capability secret stored in the active lock entry. Leader lock entries also carry a stable `leaderEpoch` minted on acquisition and preserved across heartbeat refreshes; leader-owned cleanup/provisioning plans stamp that epoch, and Thread Reconciler apply skips destructive work if leadership has moved on before side effects run. Followers own their own Pi session state, queue, active turns, previews, menus, and lifecycle hooks, but route allowlisted, target-scoped Telegram API calls through the leader. When a follower promotes after heartbeat loss, status/state diagnostics expose only the transient `electing` lifecycle phase; stable `leader`/`follower` identity stays in the bus role so diagnostics do not duplicate role state. The TUI status bar and `/telegram-status` report `leader` or `follower` role so a registered follower is not shown as generically disconnected. Terminal status identity and the `[telegram|thread:name]` prompt label use the same target-aware current-instance resolver: registered local metadata wins over a stale shared binding for the matching target, while the binding remains a fallback for partial metadata.
|
|
141
141
|
|
|
@@ -167,7 +167,7 @@ All inbound updates are gated by the configured authorized user id.
|
|
|
167
167
|
2. Persist update offsets only after successful handling; repeated handler failures are bounded.
|
|
168
168
|
3. Filter to the paired private user; guest-mode updates require an existing paired user and cannot establish first pairing.
|
|
169
169
|
4. Dispatch owned callbacks and controls before fallback prompt forwarding.
|
|
170
|
-
5. Coalesce media groups, likely split long text, and
|
|
170
|
+
5. Coalesce media groups, likely split long text, and one adjacent forward-plus-comment pair in either order when needed.
|
|
171
171
|
6. Download files into `~/.pi/agent/tmp/telegram` with size limits and partial-download cleanup.
|
|
172
172
|
7. Run configured/programmatic inbound handlers in order, appending successful stdout under `[outputs]`.
|
|
173
173
|
8. Add local attachments under `[attachments]`, optional voice context, and optional final `[time]` context.
|
|
@@ -175,7 +175,7 @@ All inbound updates are gated by the configured authorized user id.
|
|
|
175
175
|
10. Handle `edited_message` updates separately while the original turn is still queued.
|
|
176
176
|
11. Dispatch only when all safety gates are clear.
|
|
177
177
|
|
|
178
|
-
Long-text split recovery remains conservative: only human text at or above the near-limit threshold opens its debounce window.
|
|
178
|
+
Long-text split recovery remains conservative: only human text at or above the near-limit threshold opens its debounce window. Forward annotation has two semantic layers: the forward owns its source text/caption/media, while an optional separate owner-authored annotation normally precedes it. A bounded one-second pairing window joins that annotation and adjacent forward in either transport order, including a media-only forward without source caption text; the matching opposite-kind message flushes immediately. Same-kind rapid messages, commands, bots, ordinary non-forward captions, media groups, different senders/targets, reversed ids, and distant message ids do not enter this pairing path. Prompt construction always places the owner annotation first, followed by `[forward|from:...]` with the forward's own source text/caption, then source-attributed forwarded attachments, regardless of arrival order.
|
|
179
179
|
|
|
180
180
|
### Queue And Dispatch Safety
|
|
181
181
|
|
|
@@ -200,7 +200,7 @@ Dispatch requires:
|
|
|
200
200
|
- `ctx.isIdle()` is true.
|
|
201
201
|
- `ctx.hasPendingMessages()` is false.
|
|
202
202
|
|
|
203
|
-
A dispatched prompt remains queued until `agent_start` consumes it. This keeps the active Telegram turn bound for previews, attachments, aborts, and final replies.
|
|
203
|
+
A dispatched prompt remains queued until `agent_start` consumes it. This keeps the active Telegram turn bound for previews, attachments, aborts, and final replies. A low-level `agent_end` error also retains that active turn because Pi may retry automatically; a later successful `agent_end` delivers through the original target and metadata, while `agent_settled` proves that an unrecovered error can be finalized once before queue dispatch resumes.
|
|
204
204
|
|
|
205
205
|
Post-agent-end queue dispatch uses a session-bound deferred dispatcher. It is activated on session start, clears timers on shutdown, and skips callbacks from older generations before touching `ExtensionContext`. Dispatch stays session-bound after polling ownership moves elsewhere. When a queued Telegram prompt is forwarded into Pi, it uses a normal `sendUserMessage(content)` turn after the bridge's idle/dispatch guards pass; it does not use Pi's `followUp` delivery option or inject terminal input.
|
|
206
206
|
|
|
@@ -299,11 +299,11 @@ Status rendering distinguishes connected, active, dispatching, queued, tool-runn
|
|
|
299
299
|
|
|
300
300
|
Queue reactions are shortcut controls for waiting turns. Promotion reactions (`👍`, `⚡️`, `❤️`, `🕊`, `🔥`) move prompts to priority; removal reactions (`👎`, `👻`, `💔`, `💩`, `🗑`) remove waiting turns because ordinary Telegram DM deletions are not exposed through Bot API polling.
|
|
301
301
|
|
|
302
|
-
`/telegram-status` records grouped diagnostics for transport/API, polling/update, prompt dispatch, controls, typing, compaction, setup, session lifecycle, attachment queue/delivery, and recent redacted runtime events. Expected preview noise such as unchanged edit responses is filtered out.
|
|
302
|
+
`/telegram-status` records grouped diagnostics for transport/API, polling/update, prompt dispatch, controls, typing, compaction, setup, session lifecycle, attachment queue/delivery, and recent redacted runtime events. Expected preview noise such as unchanged edit responses is filtered out. The compact TUI status renders only `error`; detailed failure text remains in diagnostics and profile-scoped logs instead of expanding the status line.
|
|
303
303
|
|
|
304
304
|
When `assistant.proactivePush` is enabled and this instance has exact direct or follower transport authority, completed public assistant text blocks from local/autonomous work are sent once and in source order to the instance's authorized target. Visible commentary/checkpoints and the final block use the configured Rich or HTML renderer. Hidden reasoning, tool traffic, token deltas, local prompt text, Telegram-owned turns, and stale generations are not mirrored. Each admitted block remains fenced to its exact target, profile/token stamp, leader epoch or follower registration generation, and session generation; non-idempotent acknowledgement ambiguity never authorizes replay.
|
|
305
305
|
|
|
306
|
-
Telegram prompt guidance is context-aware. Unconfigured sessions receive no bridge suffix. Local/TUI prompts receive only explicit direct-delivery guidance
|
|
306
|
+
Telegram prompt guidance is context-aware. Unconfigured sessions receive no bridge suffix. Local/TUI prompts receive only explicit direct-delivery guidance. Telegram-originated turns receive a compact pointer to `telegram_help` plus dynamic prompt blocks such as `[voice] delivery: automatic voice`; full voice/button/direct-delivery/Threaded Mode syntax stays in the help tool rather than every system prompt.
|
|
307
307
|
|
|
308
308
|
## In-Flight Model Switching
|
|
309
309
|
|
package/docs/outbound.md
CHANGED
|
@@ -30,7 +30,7 @@ When `assistant.rendering` is `"rich"`, a Telegram-originated turn that queues e
|
|
|
30
30
|
|
|
31
31
|
The optimization is deliberately narrow. HTML rendering, empty final text, multiple files, documents and other unsupported formats, Guest Mode, explicit `telegram_voice`, voice-preferred turns, and OGG/Opus artifacts retain their established text/attachment/voice paths. A known-safe Rich upload rejection falls back to those paths. A `commit-unknown` transport outcome or a nominally successful upload without a verifiable message id never falls back or replays because the first non-idempotent send may already have committed.
|
|
32
32
|
|
|
33
|
-
This behavior does not generate media or alter voice policy. `telegram_attach` still represents an explicit assistant artifact decision, while `
|
|
33
|
+
This behavior does not generate media or alter voice policy. `telegram_attach` still represents an explicit assistant artifact decision, while `hidden`, `mirror`, and `always` continue to decide voice synthesis independently.
|
|
34
34
|
|
|
35
35
|
Core assistant output accepts only the Markdown or HTML `InputRichMessage` forms and does not construct explicit block arrays or `InputRichBlockThinking`. Telegram's Thinking block is draft-only and must never become a projection of hidden reasoning or chain-of-thought. Any future use for Activity would require an explicitly public user-visible summary rather than provider reasoning content.
|
|
36
36
|
|
package/docs/public-api.md
CHANGED
|
@@ -93,7 +93,7 @@ interface TelegramConfig {
|
|
|
93
93
|
proactivePush?: boolean;
|
|
94
94
|
};
|
|
95
95
|
voice?: {
|
|
96
|
-
replyMode?: "
|
|
96
|
+
replyMode?: "hidden" | "mirror" | "always";
|
|
97
97
|
sendTranscript?: boolean;
|
|
98
98
|
};
|
|
99
99
|
time?: {
|
|
@@ -106,7 +106,7 @@ interface TelegramConfig {
|
|
|
106
106
|
Hidden/default semantics are represented by absence:
|
|
107
107
|
|
|
108
108
|
- `assistant.proactivePush` defaults to `true`; omit it to keep projection enabled, or set it explicitly to `false` to disable it. When enabled, each completed public assistant text block from local or autonomous work is projected to the authorized Telegram target once and in source order. This includes visible intermediate commentary/checkpoints and the final block. It excludes token deltas, hidden reasoning, tool calls/arguments/results, Telegram-owned turns, empty blocks, and stale authority. Projection uses the configured Rich or HTML assistant renderer and binds admitted work to the exact target, profile/token transport generation, direct leader epoch or follower registration generation, and session generation. The old top-level `proactivePush` key is ignored; move the setting manually under `assistant`.
|
|
109
|
-
- Voice Reply `hidden`: no `voice.replyMode` key is persisted.
|
|
109
|
+
- Voice Reply `hidden`: no `voice.replyMode` key is persisted; legacy `manual` resolves to this silent default. `mirror` adds `[voice] delivery: automatic voice` only to voice/audio-input turns, while `always` adds the same effective line to every Telegram turn.
|
|
110
110
|
- Agent activity status is not configurable in this release. Telegram uses native `sendChatAction(typing)` / product `...active` status as the only automatic in-chat work signal before the final reply.
|
|
111
111
|
- Time Injection `hidden`: no `time.injectionMode` key is persisted; if `time` becomes empty, the whole `time` object may be omitted.
|
|
112
112
|
|
package/docs/ui-style.md
CHANGED
package/docs/voice.md
CHANGED
|
@@ -33,14 +33,13 @@ Voice provider extensions can also register STT backends with `registerTelegramV
|
|
|
33
33
|
|
|
34
34
|
## Voice Reply Policy
|
|
35
35
|
|
|
36
|
-
The bridge decides **when** to reply with voice from `voice.replyMode` in `TelegramConfig` (stored in `telegram.json`).
|
|
36
|
+
The bridge decides **when** to reply with voice from `voice.replyMode` in `TelegramConfig` (stored in `telegram.json`). Missing, invalid, `hidden`, and legacy `manual` values resolve to the silent `hidden` default.
|
|
37
37
|
|
|
38
38
|
### Modes
|
|
39
39
|
|
|
40
|
-
- **`hidden` (default):** no `voice.replyMode` is stored
|
|
41
|
-
- **`
|
|
42
|
-
- **`
|
|
43
|
-
- **`always`:** always reply with voice.
|
|
40
|
+
- **`hidden` (default):** no `voice.replyMode` is stored and no automatic voice context is added; explicit agent-authored `telegram_voice` actions still work.
|
|
41
|
+
- **`mirror`:** voice/audio input activates automatic voice delivery. Text input follows `hidden` behavior.
|
|
42
|
+
- **`always`:** every Telegram turn activates automatic voice delivery.
|
|
44
43
|
|
|
45
44
|
**Warning:** In `always` mode, the bridge transparently intercepts ALL text replies and converts them to voice on success. Users will only receive voice messages when voice generation succeeds. If voice generation fails, the bridge falls back to sending the planned text reply.
|
|
46
45
|
|
|
@@ -97,7 +96,7 @@ Registration returns a disposer function for cleanup. Stable provider registrati
|
|
|
97
96
|
|
|
98
97
|
## Outbound Voice Handlers
|
|
99
98
|
|
|
100
|
-
Users can also configure `outboundHandlers` with `type: "voice"` in `telegram.json`. This is the command-template path for TTS without a provider extension. Reply modes (`
|
|
99
|
+
Users can also configure `outboundHandlers` with `type: "voice"` in `telegram.json`. This is the command-template path for TTS without a provider extension. Reply modes (`hidden`, `mirror`, `always`) affect these handlers the same way they affect providers: explicit `telegram_voice` blocks and automatic mirror/always interception both produce a voice reply plan, then delivery tries configured outbound voice handlers first and registered synthesis providers as progressive fallbacks.
|
|
101
100
|
|
|
102
101
|
Voice handlers receive the text on stdin in composed pipelines and can use `{text}`, `{lang}`, `{rate}`, `{mp3}`, and `{ogg}` placeholders. Set `output` to `"ogg"` or another placeholder name when the template writes to a known path:
|
|
103
102
|
|
|
@@ -167,7 +166,7 @@ Voice provider extensions can register a Voice Extension Section (settings UI) v
|
|
|
167
166
|
|
|
168
167
|
## Prompt Guidance
|
|
169
168
|
|
|
170
|
-
The bridge keeps voice prompt context compact and policy-owned.
|
|
169
|
+
The bridge keeps voice prompt context compact, effective, and policy-owned. `hidden` and text-originated `mirror` turns add no voice line. Voice/audio-originated `mirror` turns and every `always` turn add exactly `[voice] delivery: automatic voice`, describing the current delivery environment without exposing the underlying mode matrix or an instruction list. The marker is appended after `[outputs]` when handler output exists, otherwise after `[attachments]`. Voice inputs also appear in `[attachments]` with their downloaded file names, MIME data, and handler output, so agents can infer concrete voice-file context from attachment metadata.
|
|
171
170
|
|
|
172
171
|
Voice synthesis providers can supply prompt guidance through `getVoicePromptContribution(view)`, but provider text should stay optional and provider-specific. Reply-mode context belongs to pi-telegram.
|
|
173
172
|
|
|
@@ -204,12 +203,12 @@ Voice synthesis providers can supply prompt guidance through `getVoicePromptCont
|
|
|
204
203
|
```json
|
|
205
204
|
{
|
|
206
205
|
"voice": {
|
|
207
|
-
"replyMode": "
|
|
206
|
+
"replyMode": "mirror"
|
|
208
207
|
}
|
|
209
208
|
}
|
|
210
209
|
```
|
|
211
210
|
|
|
212
|
-
Valid
|
|
211
|
+
Valid modes are `"hidden"`, `"mirror"`, and `"always"`; selecting `hidden` removes the key. Missing, invalid, and legacy `"manual"` values resolve to `hidden` and stay silent in prompt context.
|
|
213
212
|
|
|
214
213
|
The bridge reads `voice.replyMode` from the config when building a turn.
|
|
215
214
|
|
package/index.ts
CHANGED
|
@@ -7,18 +7,18 @@
|
|
|
7
7
|
import * as Activity from "./lib/activity.ts";
|
|
8
8
|
import * as Bindings from "./lib/bindings.ts";
|
|
9
9
|
import * as BusApi from "./lib/bus-api.ts";
|
|
10
|
-
import * as Bus from "./lib/bus.ts";
|
|
11
10
|
import * as BusFollower from "./lib/bus-follower.ts";
|
|
12
11
|
import * as BusLeader from "./lib/bus-leader.ts";
|
|
13
12
|
import * as BusTransport from "./lib/bus-transport.ts";
|
|
13
|
+
import * as Bus from "./lib/bus.ts";
|
|
14
14
|
import * as CommandTemplates from "./lib/command-templates.ts";
|
|
15
15
|
import * as Commands from "./lib/commands.ts";
|
|
16
16
|
import * as Config from "./lib/config.ts";
|
|
17
17
|
import * as Delivery from "./lib/delivery.ts";
|
|
18
|
-
import * as Threads from "./lib/threads.ts";
|
|
19
18
|
import * as Inbound from "./lib/inbound.ts";
|
|
20
19
|
import * as Lifecycle from "./lib/lifecycle.ts";
|
|
21
20
|
import * as Locks from "./lib/locks.ts";
|
|
21
|
+
import * as Logs from "./lib/logs.ts";
|
|
22
22
|
import * as Media from "./lib/media.ts";
|
|
23
23
|
import * as MenuQueue from "./lib/menu-queue.ts";
|
|
24
24
|
import * as MenuSettings from "./lib/menu-settings.ts";
|
|
@@ -35,13 +35,13 @@ import * as Queue from "./lib/queue.ts";
|
|
|
35
35
|
import * as Replies from "./lib/replies.ts";
|
|
36
36
|
import * as Routing from "./lib/routing.ts";
|
|
37
37
|
import * as Runtime from "./lib/runtime.ts";
|
|
38
|
-
import * as Logs from "./lib/logs.ts";
|
|
39
38
|
import * as Sections from "./lib/sections.ts";
|
|
40
39
|
import * as Status from "./lib/status.ts";
|
|
41
40
|
import * as Sync from "./lib/sync.ts";
|
|
42
41
|
import * as TelegramApi from "./lib/telegram-api.ts";
|
|
43
42
|
import * as TextGroups from "./lib/text-groups.ts";
|
|
44
43
|
import * as ThreadReconciler from "./lib/thread-reconciler.ts";
|
|
44
|
+
import * as Threads from "./lib/threads.ts";
|
|
45
45
|
import * as TimeInjection from "./lib/time-injection.ts";
|
|
46
46
|
import * as Updates from "./lib/updates.ts";
|
|
47
47
|
import * as Voice from "./lib/voice.ts";
|
|
@@ -222,6 +222,11 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
222
222
|
persist: configStore.persist,
|
|
223
223
|
markConfigChange: telegramSyncStateRuntime.markConfigChange,
|
|
224
224
|
});
|
|
225
|
+
const persistTelegramPollingOffset =
|
|
226
|
+
Config.createTelegramPollingOffsetPersister(
|
|
227
|
+
configStore,
|
|
228
|
+
persistTelegramConfigWithSync,
|
|
229
|
+
);
|
|
225
230
|
const currentInstanceThreadRuntime =
|
|
226
231
|
Threads.createTelegramCurrentInstanceThreadRuntime({
|
|
227
232
|
instanceId: telegramInstanceId,
|
|
@@ -460,8 +465,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
460
465
|
isTransportStampActive: telegramTransportStampRuntime.isActive,
|
|
461
466
|
ownsDirect: lockRuntime.owns,
|
|
462
467
|
getDirectEpoch: lockRuntime.getOwnedLeaderEpoch,
|
|
463
|
-
isFollowerRegistered:
|
|
464
|
-
telegramBusFollowerRegistrationState.isRegistered,
|
|
468
|
+
isFollowerRegistered: telegramBusFollowerRegistrationState.isRegistered,
|
|
465
469
|
getFollowerGeneration:
|
|
466
470
|
telegramBusFollowerRegistrationState.getGeneration,
|
|
467
471
|
},
|
|
@@ -771,8 +775,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
771
775
|
return telegramActiveBusAuthSecret;
|
|
772
776
|
},
|
|
773
777
|
...forwardedRouteHandlers,
|
|
774
|
-
prepareForwardedMessage:
|
|
775
|
-
textGroupRuntime.prepareForwardedMessage,
|
|
778
|
+
prepareForwardedMessage: textGroupRuntime.prepareForwardedMessage,
|
|
776
779
|
recordRuntimeEvent,
|
|
777
780
|
},
|
|
778
781
|
targetReplacement: {
|
|
@@ -822,7 +825,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
822
825
|
hasBotToken: configStore.hasBotToken,
|
|
823
826
|
deleteWebhook,
|
|
824
827
|
getUpdates,
|
|
825
|
-
persistConfig:
|
|
828
|
+
persistConfig: persistTelegramPollingOffset,
|
|
826
829
|
prepareUpdateBatch: textGroupRuntime.prepareUpdateBatch,
|
|
827
830
|
handleUpdate: Updates.createTelegramUpdateHandle({
|
|
828
831
|
defaultHandle: inboundRouteRuntime.handleUpdate,
|
package/lib/bindings.ts
CHANGED
|
@@ -36,9 +36,7 @@ type TelegramRuntimeEventRecorder = (
|
|
|
36
36
|
type TelegramBridgeStatusUpdater =
|
|
37
37
|
Status.TelegramStatusRuntime<Pi.ExtensionContext>["updateStatus"];
|
|
38
38
|
|
|
39
|
-
export interface TelegramAssistantOutputBindingRuntime<
|
|
40
|
-
TTransportStamp,
|
|
41
|
-
> {
|
|
39
|
+
export interface TelegramAssistantOutputBindingRuntime<TTransportStamp> {
|
|
42
40
|
runtime: Activity.TelegramAssistantOutputRuntime;
|
|
43
41
|
observeEvent: (event: Activity.TelegramActivityEvent) => void;
|
|
44
42
|
authority: Routing.TelegramAssistantOutputAuthorityRuntime<TTransportStamp>;
|
|
@@ -49,7 +47,9 @@ export function createTelegramAssistantOutputBindingRuntime<
|
|
|
49
47
|
>(deps: {
|
|
50
48
|
isEnabled: () => boolean;
|
|
51
49
|
authority: {
|
|
52
|
-
getPreferredTarget: () =>
|
|
50
|
+
getPreferredTarget: () =>
|
|
51
|
+
| OutboundAttachments.TelegramQueuedOutboundAttachmentTurnView["target"]
|
|
52
|
+
| undefined;
|
|
53
53
|
getFallbackChatId: () => number | undefined;
|
|
54
54
|
getTransportStamp: () => TTransportStamp;
|
|
55
55
|
isTransportStampActive: (stamp: TTransportStamp) => boolean;
|
|
@@ -63,8 +63,9 @@ export function createTelegramAssistantOutputBindingRuntime<
|
|
|
63
63
|
>[0];
|
|
64
64
|
recordRuntimeEvent: TelegramRuntimeEventRecorder;
|
|
65
65
|
}): TelegramAssistantOutputBindingRuntime<TTransportStamp> {
|
|
66
|
-
const authority =
|
|
67
|
-
|
|
66
|
+
const authority = Routing.createTelegramAssistantOutputAuthorityRuntime(
|
|
67
|
+
deps.authority,
|
|
68
|
+
);
|
|
68
69
|
const send =
|
|
69
70
|
OutboundHandlers.createTelegramAssistantOutputSender<TTransportStamp>(
|
|
70
71
|
deps.sender,
|
|
@@ -640,6 +641,7 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
640
641
|
},
|
|
641
642
|
async onSessionShutdown(event, ctx) {
|
|
642
643
|
if (!isSessionContextActive(ctx)) return;
|
|
644
|
+
agentLifecycleHooks.clearRetainedAgentEnd();
|
|
643
645
|
activityRuntime.onSessionShutdown();
|
|
644
646
|
assistantOutputRuntime.stop();
|
|
645
647
|
compactionObserver.onSessionShutdown();
|
|
@@ -706,8 +708,9 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
706
708
|
activityRuntime.onAgentEnd();
|
|
707
709
|
await agentLifecycleHooks.onAgentEnd(event, ctx);
|
|
708
710
|
},
|
|
709
|
-
onAgentSettled(
|
|
711
|
+
async onAgentSettled(event, ctx) {
|
|
710
712
|
if (!isSessionContextActive(ctx)) return;
|
|
713
|
+
await agentLifecycleHooks.onAgentSettled(event, ctx);
|
|
711
714
|
activityRuntime.onAgentSettled();
|
|
712
715
|
},
|
|
713
716
|
onBeforeAgentStart: Prompts.createTelegramProactiveBeforeAgentStartHook({
|
package/lib/config.ts
CHANGED
|
@@ -17,8 +17,8 @@ import {
|
|
|
17
17
|
import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
|
|
18
18
|
import { resolveAgentDir, resolveTelegramConfigPath } from "./paths.ts";
|
|
19
19
|
|
|
20
|
-
import type { TelegramInboundHandlerConfig } from "./inbound.ts";
|
|
21
20
|
import type { CommandTemplateObjectConfig } from "./command-templates.ts";
|
|
21
|
+
import type { TelegramInboundHandlerConfig } from "./inbound.ts";
|
|
22
22
|
import { withTelegramFileTransaction } from "./locks.ts";
|
|
23
23
|
|
|
24
24
|
const CONFIG_RUNTIME_KEY = "__piTelegramConfigRuntime__";
|
|
@@ -72,7 +72,7 @@ export interface TelegramConfig {
|
|
|
72
72
|
/** @deprecated use assistant.rendering */
|
|
73
73
|
assistantRendering?: TelegramAssistantRenderingMode;
|
|
74
74
|
voice?: {
|
|
75
|
-
replyMode?: "
|
|
75
|
+
replyMode?: "hidden" | "mirror" | "always";
|
|
76
76
|
/** Whether to attach the provider's transcriptText as caption on voice messages */
|
|
77
77
|
sendTranscript?: boolean;
|
|
78
78
|
};
|
|
@@ -543,6 +543,27 @@ export function createTelegramConfigStore(
|
|
|
543
543
|
};
|
|
544
544
|
}
|
|
545
545
|
|
|
546
|
+
export function createTelegramPollingOffsetPersister(
|
|
547
|
+
configStore: Pick<TelegramConfigStore, "get" | "set" | "persist">,
|
|
548
|
+
persist: () => Promise<void> = () => configStore.persist(),
|
|
549
|
+
): (pollingConfig: { lastUpdateId?: number }) => Promise<void> {
|
|
550
|
+
return async (pollingConfig) => {
|
|
551
|
+
const nextOffset = pollingConfig.lastUpdateId;
|
|
552
|
+
if (typeof nextOffset === "number") {
|
|
553
|
+
const current = configStore.get();
|
|
554
|
+
const currentOffset = current.lastUpdateId;
|
|
555
|
+
configStore.set({
|
|
556
|
+
...current,
|
|
557
|
+
lastUpdateId:
|
|
558
|
+
typeof currentOffset === "number"
|
|
559
|
+
? Math.max(currentOffset, nextOffset)
|
|
560
|
+
: nextOffset,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
await persist();
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
546
567
|
export function createTelegramProactivePushChecker(
|
|
547
568
|
configStore: Pick<TelegramConfigStore, "get">,
|
|
548
569
|
): () => boolean {
|
|
@@ -625,12 +646,10 @@ export function createTelegramAssistantRenderingModeSetter(
|
|
|
625
646
|
|
|
626
647
|
export function createTelegramVoiceReplyModeGetter(
|
|
627
648
|
configStore: Pick<TelegramConfigStore, "get">,
|
|
628
|
-
): () => "
|
|
649
|
+
): () => "hidden" | "mirror" | "always" {
|
|
629
650
|
return () => {
|
|
630
651
|
const mode = configStore.get().voice?.replyMode;
|
|
631
|
-
return mode === "mirror" || mode === "always"
|
|
632
|
-
? mode
|
|
633
|
-
: "manual";
|
|
652
|
+
return mode === "mirror" || mode === "always" ? mode : "hidden";
|
|
634
653
|
};
|
|
635
654
|
}
|
|
636
655
|
|
|
@@ -639,17 +658,17 @@ export function createTelegramVoiceReplyModeConfiguredChecker(
|
|
|
639
658
|
): () => boolean {
|
|
640
659
|
return () => {
|
|
641
660
|
const mode = configStore.get().voice?.replyMode;
|
|
642
|
-
return mode === "mirror" || mode === "always"
|
|
661
|
+
return mode === "mirror" || mode === "always";
|
|
643
662
|
};
|
|
644
663
|
}
|
|
645
664
|
|
|
646
665
|
export function createTelegramVoiceReplyModeSetter(
|
|
647
666
|
configStore: TelegramMutableConfigStore,
|
|
648
|
-
): (replyMode: "
|
|
667
|
+
): (replyMode: "hidden" | "mirror" | "always" | undefined) => Promise<void> {
|
|
649
668
|
return async (replyMode) => {
|
|
650
669
|
await loadLatestTelegramConfig(configStore);
|
|
651
670
|
const current = configStore.get();
|
|
652
|
-
if (replyMode === undefined) {
|
|
671
|
+
if (replyMode === undefined || replyMode === "hidden") {
|
|
653
672
|
const { replyMode: _replyMode, ...remainingVoice } = current.voice ?? {};
|
|
654
673
|
const next = { ...current };
|
|
655
674
|
if (Object.keys(remainingVoice).length > 0) next.voice = remainingVoice;
|
package/lib/menu-settings.ts
CHANGED
|
@@ -4,10 +4,6 @@
|
|
|
4
4
|
* Owns hidden settings-menu rendering, settings callbacks, and persisted toggle wiring
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
getTelegramExtensionSettingsRows,
|
|
9
|
-
type TelegramSectionRegistry,
|
|
10
|
-
} from "./sections.ts";
|
|
11
7
|
import type {
|
|
12
8
|
TelegramAssistantRenderingMode,
|
|
13
9
|
TelegramTimeMode,
|
|
@@ -15,6 +11,10 @@ import type {
|
|
|
15
11
|
import type { TelegramInlineKeyboardMarkup } from "./keyboard.ts";
|
|
16
12
|
import type { TelegramModelMenuState } from "./menu-model.ts";
|
|
17
13
|
import type { MenuModel } from "./model.ts";
|
|
14
|
+
import {
|
|
15
|
+
getTelegramExtensionSettingsRows,
|
|
16
|
+
type TelegramSectionRegistry,
|
|
17
|
+
} from "./sections.ts";
|
|
18
18
|
import type { TelegramVoiceReplyMode } from "./voice.ts";
|
|
19
19
|
|
|
20
20
|
export type TelegramSettingsMenuReplyMarkup = TelegramInlineKeyboardMarkup;
|
|
@@ -74,7 +74,11 @@ export interface TelegramSettingsMenuRuntime<TContext> {
|
|
|
74
74
|
query: {
|
|
75
75
|
id: string;
|
|
76
76
|
data?: string;
|
|
77
|
-
message?: {
|
|
77
|
+
message?: {
|
|
78
|
+
message_id?: number;
|
|
79
|
+
message_thread_id?: number;
|
|
80
|
+
chat?: { id?: number };
|
|
81
|
+
};
|
|
78
82
|
},
|
|
79
83
|
ctx: TContext,
|
|
80
84
|
) => Promise<boolean>;
|
|
@@ -98,6 +102,7 @@ export interface TelegramSettingsMenuRuntimeDeps<
|
|
|
98
102
|
getModelMenuState: (
|
|
99
103
|
chatId: number,
|
|
100
104
|
ctx: TContext,
|
|
105
|
+
threadId?: number,
|
|
101
106
|
) => Promise<TelegramModelMenuState<TModel>>;
|
|
102
107
|
getStoredModelMenuState: (
|
|
103
108
|
messageId: number | undefined,
|
|
@@ -201,10 +206,9 @@ export function buildVoiceReplyModeSettingsText(
|
|
|
201
206
|
"",
|
|
202
207
|
"Controls when pi-telegram converts assistant text replies into Telegram voice messages.",
|
|
203
208
|
"",
|
|
204
|
-
"<code>-</code> <code>hidden</code> (default):
|
|
205
|
-
"<code>-</code> <code>
|
|
206
|
-
"<code>-</code> <code>
|
|
207
|
-
"<code>-</code> <code>always</code>: every reply is converted to voice when delivery succeeds.",
|
|
209
|
+
"<code>-</code> <code>hidden</code> (default): add no automatic voice context; explicit 'telegram_voice' actions still work.",
|
|
210
|
+
"<code>-</code> <code>mirror</code>: voice input activates automatic voice delivery; text input follows 'hidden' behavior.",
|
|
211
|
+
"<code>-</code> <code>always</code>: activate automatic voice delivery for every reply.",
|
|
208
212
|
].join("\n");
|
|
209
213
|
}
|
|
210
214
|
|
|
@@ -225,18 +229,19 @@ export function buildTimeInjectionModeSettingsText(
|
|
|
225
229
|
export function buildTelegramSettingsMenuReplyMarkup(
|
|
226
230
|
proactivePushEnabled: boolean,
|
|
227
231
|
draftPreviewsEnabled: boolean,
|
|
228
|
-
assistantRenderingModeOrVoiceReplyMode:
|
|
232
|
+
assistantRenderingModeOrVoiceReplyMode:
|
|
233
|
+
TelegramAssistantRenderingMode | TelegramVoiceReplyMode,
|
|
229
234
|
voiceReplyModeOrTimeInjectionMode: TelegramVoiceReplyMode | TelegramTimeMode,
|
|
230
|
-
timeInjectionModeOrSectionRegistry?:
|
|
235
|
+
timeInjectionModeOrSectionRegistry?:
|
|
236
|
+
TelegramTimeMode | TelegramSectionRegistry,
|
|
231
237
|
sectionRegistryOrVoiceReplyModeConfigured?: TelegramSectionRegistry | boolean,
|
|
232
238
|
voiceReplyModeConfigured = true,
|
|
233
239
|
): TelegramSettingsMenuReplyMarkup {
|
|
234
240
|
const hasRenderingMode =
|
|
235
241
|
assistantRenderingModeOrVoiceReplyMode === "rich" ||
|
|
236
242
|
assistantRenderingModeOrVoiceReplyMode === "html";
|
|
237
|
-
const assistantRenderingMode: TelegramAssistantRenderingMode =
|
|
238
|
-
? assistantRenderingModeOrVoiceReplyMode
|
|
239
|
-
: "rich";
|
|
243
|
+
const assistantRenderingMode: TelegramAssistantRenderingMode =
|
|
244
|
+
hasRenderingMode ? assistantRenderingModeOrVoiceReplyMode : "rich";
|
|
240
245
|
const voiceReplyMode = hasRenderingMode
|
|
241
246
|
? (voiceReplyModeOrTimeInjectionMode as TelegramVoiceReplyMode)
|
|
242
247
|
: (assistantRenderingModeOrVoiceReplyMode as TelegramVoiceReplyMode);
|
|
@@ -245,9 +250,9 @@ export function buildTelegramSettingsMenuReplyMarkup(
|
|
|
245
250
|
: (voiceReplyModeOrTimeInjectionMode as TelegramTimeMode);
|
|
246
251
|
const sectionRegistry = hasRenderingMode
|
|
247
252
|
? (sectionRegistryOrVoiceReplyModeConfigured as
|
|
248
|
-
|
|
|
249
|
-
|
|
250
|
-
|
|
253
|
+
TelegramSectionRegistry | undefined)
|
|
254
|
+
: (timeInjectionModeOrSectionRegistry as
|
|
255
|
+
TelegramSectionRegistry | undefined);
|
|
251
256
|
const effectiveVoiceReplyModeConfigured = hasRenderingMode
|
|
252
257
|
? voiceReplyModeConfigured
|
|
253
258
|
: typeof sectionRegistryOrVoiceReplyModeConfigured === "boolean"
|
|
@@ -268,7 +273,10 @@ export function buildTelegramSettingsMenuReplyMarkup(
|
|
|
268
273
|
{
|
|
269
274
|
text: `👄 Voice reply: ${getTelegramSettingsStateValueLabel(
|
|
270
275
|
getVoiceReplyModeLabel(
|
|
271
|
-
getVoiceReplyModeSetting(
|
|
276
|
+
getVoiceReplyModeSetting(
|
|
277
|
+
voiceReplyMode,
|
|
278
|
+
effectiveVoiceReplyModeConfigured,
|
|
279
|
+
),
|
|
272
280
|
),
|
|
273
281
|
)}`,
|
|
274
282
|
callback_data: "settings:open:voice-reply",
|
|
@@ -361,7 +369,7 @@ export function buildDraftPreviewsSettingsReplyMarkup(
|
|
|
361
369
|
},
|
|
362
370
|
{
|
|
363
371
|
text: enabled ? "⚫️ Off" : "🟡 Off",
|
|
364
|
-
callback_data: "settings:set:draft-previews:off"
|
|
372
|
+
callback_data: "settings:set:draft-previews:off",
|
|
365
373
|
},
|
|
366
374
|
],
|
|
367
375
|
],
|
|
@@ -407,12 +415,7 @@ export function buildVoiceReplyModeSettingsReplyMarkup(
|
|
|
407
415
|
configured = true,
|
|
408
416
|
): TelegramSettingsMenuReplyMarkup {
|
|
409
417
|
const activeMode = getVoiceReplyModeSetting(mode, configured);
|
|
410
|
-
const modes: TelegramVoiceReplyModeSetting[] = [
|
|
411
|
-
"hidden",
|
|
412
|
-
"manual",
|
|
413
|
-
"mirror",
|
|
414
|
-
"always",
|
|
415
|
-
];
|
|
418
|
+
const modes: TelegramVoiceReplyModeSetting[] = ["hidden", "mirror", "always"];
|
|
416
419
|
return {
|
|
417
420
|
inline_keyboard: [
|
|
418
421
|
[{ text: "⬆️ Back", callback_data: "settings:list" }],
|
|
@@ -511,7 +514,10 @@ export async function handleTelegramSettingsMenuCallbackAction(
|
|
|
511
514
|
await deps.answerCallbackQuery(callbackQueryId);
|
|
512
515
|
return true;
|
|
513
516
|
}
|
|
514
|
-
if (
|
|
517
|
+
if (
|
|
518
|
+
data === "settings:open:draft-previews" ||
|
|
519
|
+
data === "settings:open:rich-drafts"
|
|
520
|
+
) {
|
|
515
521
|
await updateDraftPreviewsSettingsMessage(deps);
|
|
516
522
|
await deps.answerCallbackQuery(callbackQueryId);
|
|
517
523
|
return true;
|
|
@@ -536,12 +542,7 @@ export async function handleTelegramSettingsMenuCallbackAction(
|
|
|
536
542
|
}
|
|
537
543
|
if (data.startsWith("settings:set:voice-reply:")) {
|
|
538
544
|
const mode = data.slice("settings:set:voice-reply:".length);
|
|
539
|
-
if (
|
|
540
|
-
mode === "hidden" ||
|
|
541
|
-
mode === "manual" ||
|
|
542
|
-
mode === "mirror" ||
|
|
543
|
-
mode === "always"
|
|
544
|
-
) {
|
|
545
|
+
if (mode === "hidden" || mode === "mirror" || mode === "always") {
|
|
545
546
|
await deps.setVoiceReplyMode(mode === "hidden" ? undefined : mode);
|
|
546
547
|
await updateVoiceReplyModeSettingsMessage(deps);
|
|
547
548
|
await deps.answerCallbackQuery(
|
|
@@ -663,79 +664,27 @@ export function createTelegramSettingsMenuRuntime<
|
|
|
663
664
|
},
|
|
664
665
|
sectionRegistry,
|
|
665
666
|
),
|
|
666
|
-
handleCallbackQuery: async (query) => {
|
|
667
|
+
handleCallbackQuery: async (query, ctx) => {
|
|
667
668
|
if (!query.data?.startsWith("settings:")) return false;
|
|
668
|
-
const
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
);
|
|
669
|
+
const messageId = query.message?.message_id;
|
|
670
|
+
const chatId = query.message?.chat?.id;
|
|
671
|
+
let state = deps.getStoredModelMenuState(messageId, chatId);
|
|
672
672
|
if (!state) {
|
|
673
|
-
|
|
674
|
-
if (
|
|
675
|
-
query.data.startsWith("settings:set:voice-reply:") &&
|
|
676
|
-
(voiceMode === "hidden" ||
|
|
677
|
-
voiceMode === "manual" ||
|
|
678
|
-
voiceMode === "mirror" ||
|
|
679
|
-
voiceMode === "always")
|
|
680
|
-
) {
|
|
681
|
-
await deps.setVoiceReplyMode(
|
|
682
|
-
voiceMode === "hidden" ? undefined : voiceMode,
|
|
683
|
-
);
|
|
684
|
-
await deps.answerCallbackQuery(
|
|
685
|
-
query.id,
|
|
686
|
-
`Voice reply mode: ${voiceMode}`,
|
|
687
|
-
);
|
|
688
|
-
return true;
|
|
689
|
-
}
|
|
690
|
-
if (
|
|
691
|
-
query.data === "settings:set:draft-previews:on" ||
|
|
692
|
-
query.data === "settings:set:draft-previews:off" ||
|
|
693
|
-
query.data === "settings:set:rich-drafts:on" ||
|
|
694
|
-
query.data === "settings:set:rich-drafts:off"
|
|
695
|
-
) {
|
|
696
|
-
const enabled = query.data.endsWith(":on");
|
|
697
|
-
await deps.setDraftPreviewsEnabled(enabled);
|
|
698
|
-
await deps.answerCallbackQuery(
|
|
699
|
-
query.id,
|
|
700
|
-
`Draft previews ${enabled ? "enabled" : "disabled"}`,
|
|
701
|
-
);
|
|
702
|
-
return true;
|
|
703
|
-
}
|
|
704
|
-
if (query.data.startsWith("settings:set:assistant-rendering:")) {
|
|
705
|
-
const mode = query.data.slice("settings:set:assistant-rendering:".length);
|
|
706
|
-
if (mode === "rich" || mode === "html") {
|
|
707
|
-
await deps.setAssistantRenderingMode(mode);
|
|
708
|
-
await deps.answerCallbackQuery(query.id, `Rendering: ${mode}`);
|
|
709
|
-
return true;
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
const hasTimeInjectionPrefix = query.data.startsWith(
|
|
713
|
-
"settings:set:time-injection:",
|
|
714
|
-
);
|
|
715
|
-
const timeMode = hasTimeInjectionPrefix
|
|
716
|
-
? query.data.slice("settings:set:time-injection:".length)
|
|
717
|
-
: query.data.slice("settings:set:time:".length);
|
|
718
|
-
if (
|
|
719
|
-
(hasTimeInjectionPrefix ||
|
|
720
|
-
query.data.startsWith("settings:set:time:")) &&
|
|
721
|
-
(timeMode === "off" ||
|
|
722
|
-
timeMode === "hidden" ||
|
|
723
|
-
timeMode === "always" ||
|
|
724
|
-
timeMode === "interval")
|
|
725
|
-
) {
|
|
726
|
-
const normalizedMode = timeMode === "off" ? "hidden" : timeMode;
|
|
727
|
-
await deps.setTimeInjectionMode(normalizedMode);
|
|
673
|
+
if (typeof messageId !== "number" || typeof chatId !== "number") {
|
|
728
674
|
await deps.answerCallbackQuery(
|
|
729
675
|
query.id,
|
|
730
|
-
|
|
676
|
+
"Interactive message expired.",
|
|
731
677
|
);
|
|
732
678
|
return true;
|
|
733
679
|
}
|
|
734
|
-
await deps.
|
|
735
|
-
|
|
736
|
-
|
|
680
|
+
state = await deps.getModelMenuState(
|
|
681
|
+
chatId,
|
|
682
|
+
ctx,
|
|
683
|
+
query.message?.message_thread_id,
|
|
737
684
|
);
|
|
738
|
-
|
|
685
|
+
state.messageId = messageId;
|
|
686
|
+
state.mode = "settings";
|
|
687
|
+
deps.storeModelMenuState(state);
|
|
739
688
|
}
|
|
740
689
|
return handleTelegramSettingsMenuCallbackAction(query.id, query.data, {
|
|
741
690
|
isProactivePushEnabled: deps.isProactivePushEnabled,
|
package/lib/prompts.ts
CHANGED
|
@@ -16,7 +16,7 @@ Telegram bridge available. Do not use it from local/TUI prompts unless explicitl
|
|
|
16
16
|
|
|
17
17
|
const TELEGRAM_TURN_SYSTEM_PROMPT_SUFFIX = `
|
|
18
18
|
|
|
19
|
-
Telegram turn note:
|
|
19
|
+
Telegram turn note: Call \`telegram_help\` if you need the pi-telegram bridge action contract.`;
|
|
20
20
|
|
|
21
21
|
function buildTelegramHelpText(profileName?: string): string {
|
|
22
22
|
const diagnosticsPaths = getTelegramDiagnosticsDisplayPaths(profileName);
|
|
@@ -26,7 +26,8 @@ How to understand Telegram turns:
|
|
|
26
26
|
- \`[telegram|thread:name|from:user|guest:group]\` marks Telegram origin and attributes.
|
|
27
27
|
- \`thread\` is the visible Thread identity in Threaded Mode; it is not a bus role.
|
|
28
28
|
- \`[reply]\` is quoted context only; act on the user's current instruction.
|
|
29
|
-
- \`[attachments]\` are local files; \`[outputs]\` are handler results/transcripts; \`[time]\` is wall-clock context
|
|
29
|
+
- \`[attachments]\` are local files; \`[outputs]\` are handler results/transcripts; \`[time]\` is wall-clock context.
|
|
30
|
+
- \`[voice] delivery: automatic voice\` means pi-telegram will synthesize ordinary assistant text for this turn; no \`[voice]\` line means no automatic voice policy.
|
|
30
31
|
|
|
31
32
|
How to answer Telegram turns:
|
|
32
33
|
- Reply in concise, scannable mobile Telegram Rich Markdown.
|
|
@@ -41,6 +42,7 @@ Assistant-authored Telegram actions:
|
|
|
41
42
|
- Keep the complete action at top level and include a non-empty voice payload.
|
|
42
43
|
- Keep voice text TTS-friendly; avoid raw Markdown, code, and tables in voice text.
|
|
43
44
|
- Voice delivery generates and attaches OGG automatically; do not also call \`telegram_attach\` for the same audio.
|
|
45
|
+
- Voice reply modes are compact: \`hidden\` emits no automatic context, \`mirror\` emits it for voice/audio input, and \`always\` emits it for every Telegram turn. Explicit \`telegram_voice\` remains available for an intentionally distinct spoken payload.
|
|
44
46
|
- Button forms: \`<!-- telegram_button: OK -->\`, \`<!-- telegram_button label=Continue prompt="Continue with the current plan." -->\`, or multiline \`<!-- telegram_button label="Show risks"\nList the main risks first.\n-->\`.
|
|
45
47
|
- If hidden comments would be the whole reply, add visible text such as \`Choose one:\`.
|
|
46
48
|
|
package/lib/queue.ts
CHANGED
|
@@ -850,14 +850,55 @@ export function createTelegramAgentLifecycleHooks<
|
|
|
850
850
|
TReplyMarkup
|
|
851
851
|
>,
|
|
852
852
|
) {
|
|
853
|
+
const onAgentStart = createTelegramAgentStartHook<TTurn, TContext>(deps);
|
|
854
|
+
const deliverAgentEnd = createTelegramAgentEndHook<
|
|
855
|
+
TTurn,
|
|
856
|
+
TContext,
|
|
857
|
+
TMessage,
|
|
858
|
+
TReplyMarkup
|
|
859
|
+
>(deps);
|
|
860
|
+
let retainedErrorEvent: TelegramAgentEndHookEvent<TMessage> | undefined;
|
|
853
861
|
return {
|
|
854
|
-
onAgentStart
|
|
855
|
-
onAgentEnd
|
|
856
|
-
|
|
857
|
-
TContext,
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
862
|
+
onAgentStart,
|
|
863
|
+
async onAgentEnd(
|
|
864
|
+
event: TelegramAgentEndHookEvent<TMessage>,
|
|
865
|
+
ctx: TContext,
|
|
866
|
+
): Promise<void> {
|
|
867
|
+
const turn = deps.getActiveTurn();
|
|
868
|
+
const assistant = turn ? deps.extractAssistant(event.messages) : {};
|
|
869
|
+
if (turn && assistant.stopReason === "error") {
|
|
870
|
+
retainedErrorEvent = event;
|
|
871
|
+
deps.recordRuntimeEvent?.(
|
|
872
|
+
"provider-retry",
|
|
873
|
+
new Error("Retained Telegram turn after retryable agent error"),
|
|
874
|
+
{ phase: "retained", hasFinalText: !!assistant.text?.trim() },
|
|
875
|
+
);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
if (retainedErrorEvent) {
|
|
879
|
+
retainedErrorEvent = undefined;
|
|
880
|
+
deps.recordRuntimeEvent?.(
|
|
881
|
+
"provider-retry",
|
|
882
|
+
new Error("Recovered retained Telegram turn after agent retry"),
|
|
883
|
+
{ phase: "recovered" },
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
await deliverAgentEnd(event, ctx, assistant);
|
|
887
|
+
},
|
|
888
|
+
async onAgentSettled(_event: unknown, ctx: TContext): Promise<void> {
|
|
889
|
+
const event = retainedErrorEvent;
|
|
890
|
+
if (!event) return;
|
|
891
|
+
retainedErrorEvent = undefined;
|
|
892
|
+
deps.recordRuntimeEvent?.(
|
|
893
|
+
"provider-retry",
|
|
894
|
+
new Error("Finalized retained Telegram turn after agent settled"),
|
|
895
|
+
{ phase: "settled-failure" },
|
|
896
|
+
);
|
|
897
|
+
await deliverAgentEnd(event, ctx);
|
|
898
|
+
},
|
|
899
|
+
clearRetainedAgentEnd(): void {
|
|
900
|
+
retainedErrorEvent = undefined;
|
|
901
|
+
},
|
|
861
902
|
...createTelegramToolExecutionHooks<TContext>(deps),
|
|
862
903
|
};
|
|
863
904
|
}
|
|
@@ -1132,13 +1173,16 @@ export function createTelegramAgentEndHook<
|
|
|
1132
1173
|
return async (
|
|
1133
1174
|
event: TelegramAgentEndHookEvent<TMessage>,
|
|
1134
1175
|
ctx: TContext,
|
|
1176
|
+
assistantOverride?: TelegramAgentEndAssistantResult,
|
|
1135
1177
|
): Promise<void> => {
|
|
1136
1178
|
await deps.loadConfig?.();
|
|
1137
1179
|
if (deps.isSessionActive && !deps.isSessionActive(ctx)) return;
|
|
1138
1180
|
const turn = deps.getActiveTurn();
|
|
1139
1181
|
await handleTelegramAgentEndRuntime({
|
|
1140
1182
|
turn,
|
|
1141
|
-
assistant:
|
|
1183
|
+
assistant:
|
|
1184
|
+
assistantOverride ??
|
|
1185
|
+
(turn ? deps.extractAssistant(event.messages) : {}),
|
|
1142
1186
|
foldQueuedPromptsIntoHistory: deps.getFoldQueuedPromptsIntoHistory(),
|
|
1143
1187
|
resetRuntimeState: deps.resetRuntimeState,
|
|
1144
1188
|
isSessionActive: () => deps.isSessionActive?.(ctx) ?? true,
|
package/lib/routing.ts
CHANGED
|
@@ -9,7 +9,6 @@ import { basename, dirname } from "node:path";
|
|
|
9
9
|
import * as Bus from "./bus.ts";
|
|
10
10
|
import * as Commands from "./commands.ts";
|
|
11
11
|
import type { TelegramConfigStore } from "./config.ts";
|
|
12
|
-
import type { TelegramSectionRegistry } from "./sections.ts";
|
|
13
12
|
import type { TelegramInboundHandlerRuntime } from "./inbound.ts";
|
|
14
13
|
import * as Media from "./media.ts";
|
|
15
14
|
import * as Menu from "./menu.ts";
|
|
@@ -18,12 +17,13 @@ import * as OutboundHandlers from "./outbound.ts";
|
|
|
18
17
|
import * as PromptTemplates from "./prompt-templates.ts";
|
|
19
18
|
import * as Queue from "./queue.ts";
|
|
20
19
|
import type { TelegramBridgeRuntime } from "./runtime.ts";
|
|
20
|
+
import type { TelegramSectionRegistry } from "./sections.ts";
|
|
21
21
|
import * as TextGroups from "./text-groups.ts";
|
|
22
|
+
import * as ThreadReconciler from "./thread-reconciler.ts";
|
|
22
23
|
import type {
|
|
23
24
|
TelegramInstanceThreadIdentityCandidate,
|
|
24
25
|
TelegramTopicTargetRecord,
|
|
25
26
|
} from "./threads.ts";
|
|
26
|
-
import * as ThreadReconciler from "./thread-reconciler.ts";
|
|
27
27
|
import * as Turns from "./turns.ts";
|
|
28
28
|
|
|
29
29
|
interface TelegramPromptPeerView {
|
|
@@ -408,10 +408,10 @@ function formatTelegramUnboundRerouteChooserText(
|
|
|
408
408
|
: [formatTelegramUnboundTopicGuidance(), "", rerouteText].join("\n");
|
|
409
409
|
}
|
|
410
410
|
|
|
411
|
-
import { getTelegramVoiceReplyMode } from "./voice.ts";
|
|
412
|
-
import type { TelegramUser } from "./updates.ts";
|
|
413
411
|
import * as Threads from "./threads.ts";
|
|
412
|
+
import type { TelegramUser } from "./updates.ts";
|
|
414
413
|
import * as Updates from "./updates.ts";
|
|
414
|
+
import { getTelegramVoiceReplyMode } from "./voice.ts";
|
|
415
415
|
|
|
416
416
|
async function deleteReservedTelegramTopicThroughReconciler(
|
|
417
417
|
deps: {
|
|
@@ -1524,13 +1524,8 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1524
1524
|
resolveTimeLine: deps.resolveTimeLine,
|
|
1525
1525
|
getAllowedUserId: deps.configStore.getAllowedUserId,
|
|
1526
1526
|
|
|
1527
|
-
// Voice policy
|
|
1528
|
-
// but only explicit telegram.json voice.replyMode is shown in prompt context.
|
|
1527
|
+
// Voice policy resolves missing, invalid, and legacy manual config to hidden.
|
|
1529
1528
|
getVoiceReplyMode: () => getTelegramVoiceReplyMode(deps.configStore.get()),
|
|
1530
|
-
isVoiceReplyModeConfigured: () => {
|
|
1531
|
-
const mode = deps.configStore.get().voice?.replyMode;
|
|
1532
|
-
return mode === "manual" || mode === "mirror" || mode === "always";
|
|
1533
|
-
},
|
|
1534
1529
|
getTelegramThreadLabel(message) {
|
|
1535
1530
|
if (!deps.threadStore) return undefined;
|
|
1536
1531
|
const chatId = message.chat.id;
|
|
@@ -2490,7 +2485,9 @@ export interface TelegramAssistantOutputAuthorityRuntime<TTransportStamp> {
|
|
|
2490
2485
|
canDeliver: () => boolean;
|
|
2491
2486
|
}
|
|
2492
2487
|
|
|
2493
|
-
export function createTelegramAssistantOutputAuthorityRuntime<
|
|
2488
|
+
export function createTelegramAssistantOutputAuthorityRuntime<
|
|
2489
|
+
TTransportStamp,
|
|
2490
|
+
>(deps: {
|
|
2494
2491
|
getPreferredTarget: () => Queue.TelegramQueueTarget | undefined;
|
|
2495
2492
|
getFallbackChatId: () => number | undefined;
|
|
2496
2493
|
getTransportStamp: () => TTransportStamp;
|
|
@@ -2509,9 +2506,7 @@ export function createTelegramAssistantOutputAuthorityRuntime<TTransportStamp>(d
|
|
|
2509
2506
|
return {
|
|
2510
2507
|
captureAuthority() {
|
|
2511
2508
|
const target = getCurrentTarget();
|
|
2512
|
-
const directEpoch = deps.ownsDirect()
|
|
2513
|
-
? deps.getDirectEpoch()
|
|
2514
|
-
: undefined;
|
|
2509
|
+
const directEpoch = deps.ownsDirect() ? deps.getDirectEpoch() : undefined;
|
|
2515
2510
|
const followerGeneration = deps.isFollowerRegistered()
|
|
2516
2511
|
? deps.getFollowerGeneration()
|
|
2517
2512
|
: undefined;
|
|
@@ -2540,8 +2535,7 @@ export function createTelegramAssistantOutputAuthorityRuntime<TTransportStamp>(d
|
|
|
2540
2535
|
}
|
|
2541
2536
|
if (authority.route === "direct") {
|
|
2542
2537
|
return (
|
|
2543
|
-
deps.ownsDirect() &&
|
|
2544
|
-
deps.getDirectEpoch() === authority.directEpoch
|
|
2538
|
+
deps.ownsDirect() && deps.getDirectEpoch() === authority.directEpoch
|
|
2545
2539
|
);
|
|
2546
2540
|
}
|
|
2547
2541
|
if (authority.route === "follower") {
|
package/lib/status.ts
CHANGED
|
@@ -767,7 +767,7 @@ export function buildTelegramStatusBarText(
|
|
|
767
767
|
): string {
|
|
768
768
|
const label = theme.fg("accent", getTelegramStatusBarLabel(state));
|
|
769
769
|
if (state.error) {
|
|
770
|
-
return `${label} ${theme.fg("error", "error")}
|
|
770
|
+
return `${label} ${theme.fg("error", "error")}`;
|
|
771
771
|
}
|
|
772
772
|
const queued = state.queuedStatus
|
|
773
773
|
? theme.fg("success", state.queuedStatus)
|
package/lib/text-groups.ts
CHANGED
|
@@ -39,7 +39,7 @@ export interface TelegramTextGroupState<TMessage, TContext = unknown> {
|
|
|
39
39
|
suspended?: boolean;
|
|
40
40
|
reschedule?: (delayMs?: number) => void;
|
|
41
41
|
dispatchLimit?: number;
|
|
42
|
-
|
|
42
|
+
forwardPairCandidate?: TelegramForwardCommentBatchPosition;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export type TelegramForwardCommentBatchPosition = "comment" | "forward";
|
|
@@ -131,7 +131,12 @@ function getTelegramTextGroupKey(
|
|
|
131
131
|
): string | undefined {
|
|
132
132
|
if (message.media_group_id) return undefined;
|
|
133
133
|
if (!message.from || message.from.is_bot) return undefined;
|
|
134
|
-
if (
|
|
134
|
+
if (
|
|
135
|
+
!extractTelegramTextGroupText(message) &&
|
|
136
|
+
!isTelegramForwardedMessage(message)
|
|
137
|
+
) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
135
140
|
const threadKey =
|
|
136
141
|
typeof message.message_thread_id === "number"
|
|
137
142
|
? `thread:${message.message_thread_id}`
|
|
@@ -160,7 +165,7 @@ function canAppendTelegramTextGroupMessage<
|
|
|
160
165
|
message.message_id > previous.message_id &&
|
|
161
166
|
message.message_id <=
|
|
162
167
|
previous.message_id + TELEGRAM_TEXT_GROUP_MAX_MESSAGE_ID_GAP &&
|
|
163
|
-
text.length > 0 &&
|
|
168
|
+
(text.length > 0 || isTelegramForwardedMessage(message)) &&
|
|
164
169
|
!isTelegramTextGroupCommand(text)
|
|
165
170
|
);
|
|
166
171
|
}
|
|
@@ -182,7 +187,7 @@ export function queueTelegramTextGroupMessage<
|
|
|
182
187
|
) => unknown | Promise<unknown>;
|
|
183
188
|
forceStart?: boolean;
|
|
184
189
|
dispatchImmediately?: boolean;
|
|
185
|
-
|
|
190
|
+
forwardPairCandidate?: TelegramForwardCommentBatchPosition;
|
|
186
191
|
delayMs?: number;
|
|
187
192
|
}): boolean {
|
|
188
193
|
const key = getTelegramTextGroupKey(options.message);
|
|
@@ -202,7 +207,7 @@ export function queueTelegramTextGroupMessage<
|
|
|
202
207
|
const state = existing ?? { messages: [] };
|
|
203
208
|
state.messages.push(options.message);
|
|
204
209
|
state.context = options.context;
|
|
205
|
-
state.
|
|
210
|
+
state.forwardPairCandidate = options.forwardPairCandidate;
|
|
206
211
|
const dispatchQueued = (): void => {
|
|
207
212
|
state.flushTimer = undefined;
|
|
208
213
|
const queued = options.groups.get(key);
|
|
@@ -335,21 +340,30 @@ export function createTelegramTextGroupController<
|
|
|
335
340
|
if (existing.flushTimer) clearTimer(existing.flushTimer);
|
|
336
341
|
groups.delete(key!);
|
|
337
342
|
}
|
|
343
|
+
const candidatePosition: TelegramForwardCommentBatchPosition = forwarded
|
|
344
|
+
? "forward"
|
|
345
|
+
: "comment";
|
|
346
|
+
const existingCandidate = existing?.forwardPairCandidate;
|
|
347
|
+
const pairCompleted =
|
|
348
|
+
existingCandidate !== undefined &&
|
|
349
|
+
existingCandidate !== candidatePosition;
|
|
338
350
|
const separateFromCandidate =
|
|
339
|
-
|
|
340
|
-
!forwarded &&
|
|
351
|
+
existingCandidate === candidatePosition &&
|
|
341
352
|
!isTelegramTextGroupCommand(text);
|
|
342
|
-
if (separateFromCandidate) {
|
|
353
|
+
if (separateFromCandidate && existing) {
|
|
343
354
|
existing.dispatchLimit = existing.messages.length;
|
|
344
355
|
}
|
|
345
356
|
const forceStart =
|
|
346
357
|
plannedStart ||
|
|
347
358
|
(forwardCommentWaitMs !== false &&
|
|
348
|
-
!forwarded &&
|
|
349
359
|
!!key &&
|
|
350
|
-
|
|
351
|
-
|
|
360
|
+
(forwarded ||
|
|
361
|
+
(typeof message.text === "string" &&
|
|
362
|
+
!isTelegramTextGroupCommand(
|
|
363
|
+
extractTelegramTextGroupText(message),
|
|
364
|
+
))));
|
|
352
365
|
const dispatchImmediately =
|
|
366
|
+
pairCompleted ||
|
|
353
367
|
separateFromCandidate ||
|
|
354
368
|
plannedForwardCommentEnds.delete(identity) ||
|
|
355
369
|
(forwarded && !!key && groups.has(key));
|
|
@@ -364,10 +378,10 @@ export function createTelegramTextGroupController<
|
|
|
364
378
|
dispatchMessages,
|
|
365
379
|
forceStart,
|
|
366
380
|
dispatchImmediately,
|
|
367
|
-
|
|
368
|
-
forceStart &&
|
|
369
|
-
|
|
370
|
-
|
|
381
|
+
forwardPairCandidate:
|
|
382
|
+
forceStart && !canStartTelegramTextGroup(message, minSplitLength)
|
|
383
|
+
? candidatePosition
|
|
384
|
+
: undefined,
|
|
371
385
|
delayMs:
|
|
372
386
|
forceStart && !canStartTelegramTextGroup(message, minSplitLength)
|
|
373
387
|
? forwardCommentWaitMs === false
|
package/lib/turns.ts
CHANGED
|
@@ -13,9 +13,9 @@ import {
|
|
|
13
13
|
collectTelegramMessageIds,
|
|
14
14
|
downloadTelegramMessageFiles,
|
|
15
15
|
extractTelegramForwardContextText,
|
|
16
|
-
extractTelegramMessageText,
|
|
17
16
|
extractTelegramMessagesPromptText,
|
|
18
17
|
extractTelegramMessagesText,
|
|
18
|
+
extractTelegramMessageText,
|
|
19
19
|
formatTelegramHistoryText,
|
|
20
20
|
guessMediaType,
|
|
21
21
|
type DownloadedTelegramMessageFile,
|
|
@@ -59,14 +59,19 @@ export interface TelegramTurnMessage {
|
|
|
59
59
|
|
|
60
60
|
export type DownloadedTelegramTurnFile = DownloadedTelegramMessageFile;
|
|
61
61
|
|
|
62
|
-
function getTelegramTurnTarget(
|
|
62
|
+
function getTelegramTurnTarget(
|
|
63
|
+
message: TelegramTurnMessage,
|
|
64
|
+
): TelegramTurnTarget {
|
|
63
65
|
return Number.isInteger(message.message_thread_id)
|
|
64
66
|
? { chatId: message.chat.id, threadId: message.message_thread_id }
|
|
65
67
|
: { chatId: message.chat.id };
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
function formatTelegramPrefixAttributeValue(value: string): string {
|
|
69
|
-
return value
|
|
71
|
+
return value
|
|
72
|
+
.replace(/[\]\n\r|]+/g, " ")
|
|
73
|
+
.replace(/\s+/g, " ")
|
|
74
|
+
.trim();
|
|
70
75
|
}
|
|
71
76
|
|
|
72
77
|
export function createTelegramTurnPrefix(
|
|
@@ -142,7 +147,6 @@ function appendTelegramSourceContext(
|
|
|
142
147
|
return text ? `${text}\n\n${sourceContext}` : sourceContext;
|
|
143
148
|
}
|
|
144
149
|
|
|
145
|
-
|
|
146
150
|
function buildTelegramForwardContextBlock(options: {
|
|
147
151
|
context: string;
|
|
148
152
|
text: string;
|
|
@@ -411,7 +415,6 @@ export interface BuildTelegramPromptTurnOptions {
|
|
|
411
415
|
readBinaryFile: (path: string) => Promise<Uint8Array>;
|
|
412
416
|
inferImageMimeType: (path: string) => string | undefined;
|
|
413
417
|
voiceReplyMode?: TelegramVoiceReplyMode;
|
|
414
|
-
voiceReplyModeConfigured?: boolean;
|
|
415
418
|
voicePromptContribution?: string;
|
|
416
419
|
}
|
|
417
420
|
|
|
@@ -435,9 +438,11 @@ export interface TelegramPromptTurnRuntimeBuilderDeps<
|
|
|
435
438
|
}>;
|
|
436
439
|
resolveTimeLine?: (chatId: number) => string | null;
|
|
437
440
|
getVoiceReplyMode?: () => TelegramVoiceReplyMode;
|
|
438
|
-
isVoiceReplyModeConfigured?: () => boolean;
|
|
439
441
|
/** Returns the visible thread label for a message target, used to add thread context to the prompt prefix. */
|
|
440
|
-
getTelegramThreadLabel?: (message: {
|
|
442
|
+
getTelegramThreadLabel?: (message: {
|
|
443
|
+
chat: { id: number };
|
|
444
|
+
message_thread_id?: number;
|
|
445
|
+
}) => string | undefined;
|
|
441
446
|
getAllowedUserId?: () => number | undefined;
|
|
442
447
|
}
|
|
443
448
|
|
|
@@ -475,9 +480,7 @@ export function createTelegramPromptTurnRuntimeBuilder<
|
|
|
475
480
|
text: extractTelegramMessageText(message),
|
|
476
481
|
message,
|
|
477
482
|
fileNames: new Set(
|
|
478
|
-
collectTelegramFileInfos(
|
|
479
|
-
(file) => file.fileName,
|
|
480
|
-
),
|
|
483
|
+
collectTelegramFileInfos([message]).map((file) => file.fileName),
|
|
481
484
|
),
|
|
482
485
|
},
|
|
483
486
|
]
|
|
@@ -567,7 +570,6 @@ export function createTelegramPromptTurnRuntimeBuilder<
|
|
|
567
570
|
timeLine,
|
|
568
571
|
inferImageMimeType: guessMediaType,
|
|
569
572
|
voiceReplyMode,
|
|
570
|
-
voiceReplyModeConfigured: deps.isVoiceReplyModeConfigured?.(),
|
|
571
573
|
voicePromptContribution: computeVoicePromptContribution(
|
|
572
574
|
voiceReplyMode,
|
|
573
575
|
files,
|
|
@@ -581,9 +583,13 @@ function getTelegramVoicePromptContext(
|
|
|
581
583
|
voiceReplyMode: TelegramVoiceReplyMode,
|
|
582
584
|
hasVoiceFile: boolean,
|
|
583
585
|
): Record<string, string> | undefined {
|
|
584
|
-
if (
|
|
585
|
-
|
|
586
|
-
|
|
586
|
+
if (
|
|
587
|
+
voiceReplyMode !== "always" &&
|
|
588
|
+
!(voiceReplyMode === "mirror" && hasVoiceFile)
|
|
589
|
+
) {
|
|
590
|
+
return undefined;
|
|
591
|
+
}
|
|
592
|
+
return { delivery: "automatic voice" };
|
|
587
593
|
}
|
|
588
594
|
|
|
589
595
|
export async function buildTelegramPromptTurn(
|
|
@@ -597,8 +603,6 @@ export async function buildTelegramPromptTurn(
|
|
|
597
603
|
(f) => f.kind === "voice" || f.kind === "audio",
|
|
598
604
|
);
|
|
599
605
|
const voiceReplyMode = options.voiceReplyMode ?? getTelegramVoiceReplyMode();
|
|
600
|
-
const showVoiceContext =
|
|
601
|
-
options.voiceReplyModeConfigured ?? options.voiceReplyMode !== undefined;
|
|
602
606
|
const content: TelegramPromptContent[] = [
|
|
603
607
|
{
|
|
604
608
|
type: "text",
|
|
@@ -615,9 +619,10 @@ export async function buildTelegramPromptTurn(
|
|
|
615
619
|
sourceContext: options.sourceContext,
|
|
616
620
|
historyTurns: options.historyTurns,
|
|
617
621
|
timeLine: options.timeLine,
|
|
618
|
-
voiceContext:
|
|
619
|
-
|
|
620
|
-
|
|
622
|
+
voiceContext: getTelegramVoicePromptContext(
|
|
623
|
+
voiceReplyMode,
|
|
624
|
+
hasVoiceFile,
|
|
625
|
+
),
|
|
621
626
|
}),
|
|
622
627
|
},
|
|
623
628
|
];
|
|
@@ -634,8 +639,7 @@ export async function buildTelegramPromptTurn(
|
|
|
634
639
|
}
|
|
635
640
|
if (options.voicePromptContribution?.trim()) {
|
|
636
641
|
const textItem = content.find((c) => c.type === "text") as
|
|
637
|
-
|
|
638
|
-
| undefined;
|
|
642
|
+
{ type: "text"; text: string } | undefined;
|
|
639
643
|
if (textItem) {
|
|
640
644
|
textItem.text = `${textItem.text}\n\n${options.voicePromptContribution.trim()}`;
|
|
641
645
|
}
|
package/lib/voice.ts
CHANGED
|
@@ -38,7 +38,7 @@ function getNextAvailableProviderId<T>(
|
|
|
38
38
|
return id;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export type TelegramVoiceReplyMode = "
|
|
41
|
+
export type TelegramVoiceReplyMode = "hidden" | "mirror" | "always";
|
|
42
42
|
|
|
43
43
|
export type TelegramVoiceSynthesisProviderResult =
|
|
44
44
|
| string
|
|
@@ -67,9 +67,7 @@ export interface TelegramVoiceSynthesisProvider {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
export type TelegramVoiceTranscriptionProviderResult =
|
|
70
|
-
| string
|
|
71
|
-
| { text: string; language?: string }
|
|
72
|
-
| undefined;
|
|
70
|
+
string | { text: string; language?: string } | undefined;
|
|
73
71
|
|
|
74
72
|
export interface TelegramVoiceTranscriptionFile {
|
|
75
73
|
path: string;
|
|
@@ -217,16 +215,17 @@ export function clearTelegramVoiceTranscriptionProviders(): void {
|
|
|
217
215
|
// --- Voice Reply Modes ---
|
|
218
216
|
|
|
219
217
|
export const TELEGRAM_VOICE_REPLY_MODES = [
|
|
218
|
+
"hidden",
|
|
220
219
|
"mirror",
|
|
221
220
|
"always",
|
|
222
|
-
"manual",
|
|
223
221
|
] as const;
|
|
224
222
|
|
|
225
223
|
/**
|
|
226
224
|
* Returns the active voice reply mode for the current session.
|
|
227
225
|
*
|
|
228
226
|
* Pi-telegram owns reply-mode policy through telegram.json. If
|
|
229
|
-
* config.voice.replyMode is missing
|
|
227
|
+
* config.voice.replyMode is missing, invalid, or legacy `manual`, the effective
|
|
228
|
+
* mode is hidden.
|
|
230
229
|
*/
|
|
231
230
|
export function getTelegramVoiceReplyMode(config?: {
|
|
232
231
|
voice?: { replyMode?: string };
|
|
@@ -238,7 +237,7 @@ export function getTelegramVoiceReplyMode(config?: {
|
|
|
238
237
|
) {
|
|
239
238
|
return configMode as TelegramVoiceReplyMode;
|
|
240
239
|
}
|
|
241
|
-
return "
|
|
240
|
+
return "hidden";
|
|
242
241
|
}
|
|
243
242
|
|
|
244
243
|
/**
|