@llblab/pi-kit 0.1.8 → 0.1.9
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/CHANGELOG.md +4 -0
- package/README.md +1 -1
- package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -0
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +9 -0
- package/node_modules/@llblab/pi-telegram/README.md +2 -2
- package/node_modules/@llblab/pi-telegram/api/voice.ts +0 -1
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +2 -2
- package/node_modules/@llblab/pi-telegram/docs/outbound.md +2 -3
- package/node_modules/@llblab/pi-telegram/docs/public-api.md +4 -13
- package/node_modules/@llblab/pi-telegram/docs/voice.md +9 -37
- package/node_modules/@llblab/pi-telegram/index.ts +2 -0
- package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +112 -25
- package/node_modules/@llblab/pi-telegram/lib/config.ts +10 -7
- package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +15 -11
- package/node_modules/@llblab/pi-telegram/lib/outbound-voice.ts +3 -18
- package/node_modules/@llblab/pi-telegram/lib/voice.ts +7 -31
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/SKILL.md +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@llblab/pi-kit` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.1.9 - 2026-08-24
|
|
6
|
+
|
|
7
|
+
- `Telegram Release`: Advances `@llblab/pi-telegram` to `0.38.0`, presenting the default voice mode as `manual`, retaining legacy `hidden` compatibility, and removing the redundant transcript-caption configuration and provider contract.
|
|
8
|
+
|
|
5
9
|
## 0.1.8 - 2026-08-23
|
|
6
10
|
|
|
7
11
|
- `Telegram Releases`: Advances `@llblab/pi-telegram` to `0.37.1`, bringing journal-owned polling admission state from `0.37.0` plus the settings-manager compatibility hotfix for `/start`, model, and queue menus.
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
| `@llblab/pi-actors` | `0.50.0` | Extension and Skills |
|
|
10
10
|
| `@llblab/pi-codex-usage` | `0.9.3` | Extension |
|
|
11
11
|
| `@llblab/pi-grow-loop` | `0.7.2` | Extension and Skills |
|
|
12
|
-
| `@llblab/pi-telegram` | `0.
|
|
12
|
+
| `@llblab/pi-telegram` | `0.38.0` | Extension and Skills |
|
|
13
13
|
|
|
14
14
|
Versions are exact by design. Updating an extension does not change an installed kit until this repository explicitly advances that dependency and publishes a new kit version.
|
|
15
15
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
_This file owns unresolved project work only. Completed behavior belongs in `CHANGELOG.md`; durable contracts belong in `AGENTS.md` and `/docs`._
|
|
4
4
|
|
|
5
|
+
- [ ] `Coherent voice reply policy`: Release the validated `manual`, `mirror`, and `always` policy cleanup as `0.38.0`, then reconcile issue #235 and open exact migration issues for `johnlam1968/pi-voice-telegram` and `luxus/pi-xai-voice`, whose TTS providers still consume the removed transcript-caption API.
|
|
5
6
|
- [ ] `Operator-confirmed pairing`: Replace first-contact automatic ownership with an explicit pending request that the trusted Pi interface can allow or reject before `allowedUserId` is persisted or any Telegram prompt executes; retain manual preconfiguration, bound pending-request lifetime and multiplicity, fail closed across session/transport replacement, and cover unauthorized-first-contact races.
|
|
6
7
|
- [ ] `Environment-backed bot tokens`: Let each profile store an exact Pi-native `$ENV_VAR` or `${ENV_VAR}` reference in `telegram.json` instead of copying the resolved secret; preserve the originating alias when `/telegram-setup` prefills from a supported environment variable, resolve only at validation/activation boundaries, fail closed with a redacted named-variable diagnostic when unresolved, retain literal-token compatibility, and cover named profiles, reload, persistence, status, and secret-redaction paths.
|
|
7
8
|
- [ ] [`Inference bypass Generative Apps`](./docs/generative-apps.md): Let the agent install and bind one managed JavaScript app owner for a generated-prompt prefix so deterministic controls can bypass model inference without creating another button grammar.
|
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
> Each release keeps at most 8 outcome records of at most 512 characters.
|
|
4
4
|
|
|
5
|
+
## 0.38.0: Coherent Voice Reply Policy
|
|
6
|
+
|
|
7
|
+
- `Voice Reply Policy`: Renames the default user-facing `hidden` mode to `manual` while retaining `hidden` as a read-only configuration and callback compatibility alias. `manual`, `mirror`, and `always` remain the complete policy set: explicit `telegram_voice`, modality mirroring, or automatic voice replacement.
|
|
8
|
+
- `Voice Provider Contract`: Removes the redundant `voice.sendTranscript` configuration, `getTelegramVoiceSendTranscript()` public helper, provider-returned `transcriptText`, and voice-caption path. Synthesis providers now return only an OGG/Opus path or `undefined`; text-plus-voice remains an explicit agent composition rather than a parallel automatic policy.
|
|
9
|
+
|
|
10
|
+
## 0.37.2: Follower Recovery Delivery Hotfix
|
|
11
|
+
|
|
12
|
+
- `Follower Recovery Delivery`: Holds follower Bot API calls behind a bounded registration wait when heartbeat recovery temporarily clears local authority, then sends once with the restored exact generation. Calls still fail closed if registration is not restored, and acknowledgement ambiguity remains non-retryable.
|
|
13
|
+
|
|
5
14
|
## 0.37.1: Settings Manager Compatibility Hotfix
|
|
6
15
|
|
|
7
16
|
- `Menu Compatibility`: Keeps `/start`, model, and queue menu rendering compatible with Pi settings-manager implementations that do not expose `reload()`. Hosts with reload retain explicit refresh behavior; other hosts use the freshly constructed settings snapshot instead of failing with `settingsManager.reload is not a function`.
|
|
@@ -130,7 +130,7 @@ Enable the optional capabilities the bridge needs in the [@BotFather](https://t.
|
|
|
130
130
|
| 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. |
|
|
131
131
|
| Outbound artifacts | Return generated files through `telegram_attach` during active turns or explicit direct delivery. | Agents send real artifacts as files, not pasted blobs. |
|
|
132
132
|
| Voice input | Route audio through configured command-template handlers, programmatic handlers, or STT providers. | Voice notes become usable prompt context. |
|
|
133
|
-
| Voice output | Choose `
|
|
133
|
+
| Voice output | Choose `manual`, `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. |
|
|
134
134
|
| Buttons | Turn top-level `telegram_button` comments into inline buttons. | Assistant-authored choices become native Telegram interactions. |
|
|
135
135
|
| Generative Apps | Install or explicitly replace a reviewed `.mjs` application whose generated JSON button view may mix direct `app::method` actions with ordinary model prompts. | Repeated games, controls, tutors, and adapters compile routine interaction without losing selective model interpretation, explanation, or adaptation. |
|
|
136
136
|
| Callback routing | Route known callbacks to the owner extension and unknown callbacks back into Pi. | Companion extensions can build UI without polling Telegram themselves. |
|
|
@@ -218,7 +218,7 @@ Inbound files land under `<agent-dir>/tmp/telegram` and default to a 50 MiB limi
|
|
|
218
218
|
|
|
219
219
|
### Voice And Media
|
|
220
220
|
|
|
221
|
-
Voice notes, audio, images, PDFs, and other media can pass through configured inbound handlers, programmatic handlers, or registered STT providers. Outbound voice can use configured `outboundHandlers` or registered TTS providers; `pi-telegram` owns reply policy and Telegram transport, while providers own synthesis. Configure provider-neutral local/API pipelines and ordered fallbacks through [`telegram.json` command templates](./docs/voice.md#choose-an-integration-path). The default `
|
|
221
|
+
Voice notes, audio, images, PDFs, and other media can pass through configured inbound handlers, programmatic handlers, or registered STT providers. Outbound voice can use configured `outboundHandlers` or registered TTS providers; `pi-telegram` owns reply policy and Telegram transport, while providers own synthesis. Configure provider-neutral local/API pipelines and ordered fallbacks through [`telegram.json` command templates](./docs/voice.md#choose-an-integration-path). The default `manual` reply mode still supports intentional voice delivery through explicit `telegram_voice` actions; `mirror` and `always` add automatic voice policy. Explicit actions accept either a JSON object or compact double-quoted attributes, with equivalent `text` and `value` payload keys and one colon-free action marker.
|
|
222
222
|
|
|
223
223
|
### Buttons And Callbacks
|
|
224
224
|
|
|
@@ -157,7 +157,7 @@ Leader election is heartbeat-gated and lock-backed. The polling owner checks exa
|
|
|
157
157
|
4. If the leader heartbeat is stale, attempt an atomic leadership takeover; ordinary `/telegram-connect` on a follower is not a leadership move while the leader is live.
|
|
158
158
|
5. Heartbeat acknowledgements carry the authenticated live follower-slot roster. If several followers detect stale leadership, the lowest observed live slot attempts promotion immediately; higher slots defer one bounded election grace and re-check the lock. Atomic compare/write acquisition remains the final ownership authority, and a missing lower-slot follower cannot block a higher survivor beyond that grace.
|
|
159
159
|
|
|
160
|
-
Followers first try to re-register after leader reload or unknown-heartbeat responses, carrying their last known target, slot, and thread name so the new leader can reuse the same binding. After the grace window
|
|
160
|
+
Followers first try to re-register after leader reload or unknown-heartbeat responses, carrying their last known target, slot, and thread name so the new leader can reuse the same binding. Follower Bot API calls already admitted by the active Pi turn wait for that bounded re-registration and capture its new exact generation before entering transport; they do not fail merely because recovery temporarily cleared local registration, and they never replay after an ambiguous transport commit. After the grace window followers promote only when the exact observed leader lease has become stale or inactive; an unavailable IPC endpoint never authorizes replacing a still-live owner. If the exact carried target is absent from persisted bindings, the leader first runs the same synchronous visibility probe: success recovers it instead of creating another Telegram thread, explicit stale evidence provisions a replacement, and ambiguous failure rejects registration. An ambiguous absent-target probe persists only non-routable `probe-required` restoration evidence, so targetless retries and leader reloads must probe that exact target again instead of activating it or provisioning a speculative replacement. A carried slot survives only when that slot remains free. Every successful reuse refreshes the binding timestamp. The leader never restores persisted followers into the live registry speculatively. Absent follower records remain durable restart hints until explicit stale, deleted, offline, or reconciliation evidence invalidates them; only fresh authenticated registration creates live routing authority. This preserves real thread bindings through reload and process-absence gaps without allowing historical records or competing pollers to masquerade as live state.
|
|
161
161
|
|
|
162
162
|
## Leader/Follower Communication
|
|
163
163
|
|
|
@@ -314,7 +314,7 @@ Every outbound request carries its target. The leader injects `message_thread_id
|
|
|
314
314
|
Threaded Mode should make follower threads behave like normal Telegram instance surfaces, with the leader acting only as transport owner. Any feature in the matrix below that works for the leader must either work for followers or have an explicit documented exception.
|
|
315
315
|
|
|
316
316
|
| Surface | Leader behavior | Follower requirement | Routing/ownership invariant | Regression evidence |
|
|
317
|
-
|
|
|
317
|
+
| --- | --- | --- | --- | --- |
|
|
318
318
|
| Prompt intake | Thread prompt queues locally | Thread prompt is forwarded and queued by the owning follower | Target ownership routes by `{ chatId, threadId }` before local handling | Routing tests for foreign target message forwarding |
|
|
319
319
|
| Queued-message removal reactions | 👎/👻/💔/💩/🗑 marks a pending prompt/media turn for deletion when it reaches dispatch | Same reaction on a queued follower prompt marks that follower's pending turn for deletion before model dispatch | When the leader forwards a prompt to a follower, it records `chatId/messageId -> follower instance` because Bot API reaction updates expose chat/message but not thread id | Update runtime regression records forwarded message ownership and forwards the later reaction |
|
|
320
320
|
| Queue priority reactions | 👍/⚡/❤/🕊/🔥 prioritizes queued prompts | Same reactions prioritize follower queued prompts | Reaction forwarding uses stored message ownership, then follower mutates its local queue | Reaction mutation tests plus forwarded-reaction coverage |
|
|
@@ -119,14 +119,13 @@ import { registerTelegramVoiceSynthesisProvider } from "@llblab/pi-telegram/voic
|
|
|
119
119
|
|
|
120
120
|
const dispose = registerTelegramVoiceSynthesisProvider(
|
|
121
121
|
async (text, options) => {
|
|
122
|
-
|
|
123
|
-
return { audioPath, transcriptText: text };
|
|
122
|
+
return await synthesizeToOggOpus(text, options);
|
|
124
123
|
},
|
|
125
124
|
{ id: "my-extension/tts" },
|
|
126
125
|
);
|
|
127
126
|
```
|
|
128
127
|
|
|
129
|
-
Synthesis providers receive the extracted `telegram_voice` text plus optional `lang`/`rate` hints. Stable registrations pass a durable `id`; omitted ids remain a compatibility path for older providers. Providers own translation, TTS, speech rewriting,
|
|
128
|
+
Synthesis providers receive the extracted `telegram_voice` text plus optional `lang`/`rate` hints. Stable registrations pass a durable `id`; omitted ids remain a compatibility path for older providers. Providers own translation, TTS, speech rewriting, and OGG/Opus conversion. The bridge validates that the returned file ends in `.ogg` or `.opus`, sends it through Telegram `sendVoice`, and falls back to planned text if delivery fails before any visible text was delivered. Providers run after configured and programmatic voice handlers in the priority chain above.
|
|
130
129
|
|
|
131
130
|
## Voice Markup
|
|
132
131
|
|
|
@@ -99,8 +99,7 @@ interface TelegramConfig {
|
|
|
99
99
|
timeInjection?: "hidden" | "always" | "interval";
|
|
100
100
|
};
|
|
101
101
|
voice?: {
|
|
102
|
-
replyMode?: "
|
|
103
|
-
sendTranscript?: boolean;
|
|
102
|
+
replyMode?: "manual" | "mirror" | "always";
|
|
104
103
|
};
|
|
105
104
|
time?: {
|
|
106
105
|
interval?: number;
|
|
@@ -409,16 +408,13 @@ const offStt = registerTelegramVoiceTranscriptionProvider(
|
|
|
409
408
|
|
|
410
409
|
const offTts = registerTelegramVoiceSynthesisProvider(
|
|
411
410
|
async (text, options) => {
|
|
412
|
-
|
|
413
|
-
return getTelegramVoiceSendTranscript(getCurrentTelegramConfigView())
|
|
414
|
-
? { audioPath, transcriptText: text }
|
|
415
|
-
: { audioPath };
|
|
411
|
+
return await synthesizeOggOpus(text, options);
|
|
416
412
|
},
|
|
417
413
|
{ id: "@scope/my-extension/tts" },
|
|
418
414
|
);
|
|
419
415
|
```
|
|
420
416
|
|
|
421
|
-
Stable voice-provider registrations pass a durable `id`. Omitting `id` is a compatibility path for older providers and receives a generated session-local id. Providers return `undefined` to pass. TTS providers must return `.ogg` or `.opus` files for native Telegram voice notes.
|
|
417
|
+
Stable voice-provider registrations pass a durable `id`. Omitting `id` is a compatibility path for older providers and receives a generated session-local id. Providers return `undefined` to pass. TTS providers must return `.ogg` or `.opus` files for native Telegram voice notes.
|
|
422
418
|
|
|
423
419
|
Full behavior: [Voice Integration](./voice.md).
|
|
424
420
|
|
|
@@ -525,7 +521,6 @@ export default function demoOutbound(pi: ExtensionAPI) {
|
|
|
525
521
|
```ts
|
|
526
522
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
527
523
|
import {
|
|
528
|
-
getTelegramVoiceSendTranscript,
|
|
529
524
|
registerTelegramVoiceSynthesisProvider,
|
|
530
525
|
registerTelegramVoiceTranscriptionProvider,
|
|
531
526
|
} from "@llblab/pi-telegram/voice";
|
|
@@ -533,16 +528,12 @@ import {
|
|
|
533
528
|
export default function demoVoice(pi: ExtensionAPI) {
|
|
534
529
|
let unregisterTts: (() => void) | undefined;
|
|
535
530
|
let unregisterStt: (() => void) | undefined;
|
|
536
|
-
let currentConfig: { voice?: { sendTranscript?: boolean } } = {};
|
|
537
531
|
pi.on("session_start", async () => {
|
|
538
532
|
unregisterTts?.();
|
|
539
533
|
unregisterStt?.();
|
|
540
534
|
unregisterTts = registerTelegramVoiceSynthesisProvider(
|
|
541
535
|
async (text) => {
|
|
542
|
-
|
|
543
|
-
return getTelegramVoiceSendTranscript(currentConfig)
|
|
544
|
-
? { audioPath, transcriptText: text }
|
|
545
|
-
: { audioPath };
|
|
536
|
+
return await synthesizeDemoOgg(text);
|
|
546
537
|
},
|
|
547
538
|
{ id: "demo-voice/tts" },
|
|
548
539
|
);
|
|
@@ -43,12 +43,12 @@ Voice provider extensions can also register STT backends with `registerTelegramV
|
|
|
43
43
|
|
|
44
44
|
## Voice Reply Policy
|
|
45
45
|
|
|
46
|
-
The bridge decides **when** to reply with voice from `voice.replyMode` in `TelegramConfig` (stored in `telegram.json`). Missing
|
|
46
|
+
The bridge decides **when** to reply with voice from `voice.replyMode` in `TelegramConfig` (stored in `telegram.json`). Missing and invalid values resolve to the `manual` default; the former `hidden` value remains a read-only compatibility alias for `manual`.
|
|
47
47
|
|
|
48
48
|
### Modes
|
|
49
49
|
|
|
50
|
-
- **`
|
|
51
|
-
- **`mirror`:** voice/audio input activates automatic voice delivery. Text input follows `
|
|
50
|
+
- **`manual` (default):** no `voice.replyMode` is stored and no automatic voice context is added; explicit agent-authored `telegram_voice` actions still work.
|
|
51
|
+
- **`mirror`:** voice/audio input activates automatic voice delivery. Text input follows `manual` behavior.
|
|
52
52
|
- **`always`:** every Telegram turn activates automatic voice delivery.
|
|
53
53
|
|
|
54
54
|
**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.
|
|
@@ -70,7 +70,7 @@ A voice extension may combine three public seams:
|
|
|
70
70
|
|
|
71
71
|
- `registerTelegramVoiceTranscriptionProvider()` for inbound STT fallback on voice/audio files
|
|
72
72
|
- `registerTelegramVoiceSynthesisProvider()` for outbound TTS/synthesis fallback to Telegram voice messages
|
|
73
|
-
- `registerTelegramSection()` for provider-specific Telegram UI such as voice, language, style,
|
|
73
|
+
- `registerTelegramSection()` for provider-specific Telegram UI such as voice, language, style, or provider on/off controls
|
|
74
74
|
|
|
75
75
|
The reply policy itself remains a built-in pi-telegram setting (`voice.replyMode`) rather than a provider-owned menu.
|
|
76
76
|
|
|
@@ -81,10 +81,8 @@ Voice synthesis provider extensions register themselves through `registerTelegra
|
|
|
81
81
|
- Text optimisation / speech-style rewriting
|
|
82
82
|
- Adding speech tags (when desired)
|
|
83
83
|
- Running TTS + ffmpeg conversion to OGG/Opus
|
|
84
|
-
- Deciding whether to return `transcriptText` at all based on the bridge-owned `voice.sendTranscript` preference when the provider has access to the current Telegram config
|
|
85
|
-
- `transcriptText` (when returned) is attached by the bridge as the voice message **caption** only. Separate transcript messages are no longer sent.
|
|
86
84
|
|
|
87
|
-
The bridge shows a `record_voice` action while delivering and sends the final audio with Telegram `sendVoice`.
|
|
85
|
+
The bridge shows a `record_voice` action while delivering and sends the final audio with Telegram `sendVoice`.
|
|
88
86
|
|
|
89
87
|
Providers can implement `getVoicePromptContribution(view)` to inject voice-specific instructions into voice-tagged prompts (for example: "Reply only with the spoken text"). The bridge appends the first non-empty provider contribution when `mirror` or `always` mode tags the turn.
|
|
90
88
|
|
|
@@ -95,7 +93,6 @@ The provider receives the raw agent text plus optional `{ lang?, rate? }`.
|
|
|
95
93
|
It must return one of:
|
|
96
94
|
|
|
97
95
|
- `string` — path to a ready `.ogg` or `.opus` file
|
|
98
|
-
- `{ audioPath: string, transcriptText?: string }` — `audioPath` must be OGG/Opus. When `transcriptText` is present it is attached as the voice message **caption**. Providers should treat pi-telegram's `voice.sendTranscript` as the bridge-owned transcript preference instead of inventing a second reply-policy UI.
|
|
99
96
|
- `undefined` — skip this text block
|
|
100
97
|
|
|
101
98
|
**Important:** Providers are fully responsible for producing a clean, TTS-optimised native voice file. The bridge may also run configured outbound voice command templates for users who prefer process-boundary handlers instead of provider extensions.
|
|
@@ -106,7 +103,7 @@ Registration returns a disposer function for cleanup. Stable provider registrati
|
|
|
106
103
|
|
|
107
104
|
## Outbound Voice Handlers
|
|
108
105
|
|
|
109
|
-
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 (`
|
|
106
|
+
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 (`manual`, `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.
|
|
110
107
|
|
|
111
108
|
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:
|
|
112
109
|
|
|
@@ -128,31 +125,6 @@ Voice handlers receive the text on stdin in composed pipelines and can use `{tex
|
|
|
128
125
|
|
|
129
126
|
Priority for outbound voice delivery is: configured `outboundHandlers` with `type: "voice"` in their `telegram.json` order, then programmatic `voice` outbound handlers, then registered voice synthesis providers. Provider extensions are the zero-config tail of the same pipeline: they handle voice when no explicit configured handler succeeds, but they do not override operator-configured handlers. If multiple providers are registered, only one handles a given voice reply: the first provider that returns a valid `.ogg`/`.opus` artifact wins. Providers that return `undefined` explicitly pass to the next provider; providers that throw or return invalid output are recorded and the next fallback is tried.
|
|
130
127
|
|
|
131
|
-
### Provider with transcript caption (controlled by user toggle)
|
|
132
|
-
|
|
133
|
-
When the user's "Send Transcript" toggle is ON, return the clean spoken text as `transcriptText`. The bridge attaches it as the caption on the voice message. When the toggle is OFF, return only the audio path (no `transcriptText`).
|
|
134
|
-
|
|
135
|
-
```typescript
|
|
136
|
-
import {
|
|
137
|
-
getTelegramVoiceSendTranscript,
|
|
138
|
-
registerTelegramVoiceSynthesisProvider,
|
|
139
|
-
} from "@llblab/pi-telegram/voice";
|
|
140
|
-
|
|
141
|
-
registerTelegramVoiceSynthesisProvider(
|
|
142
|
-
async (text, options) => {
|
|
143
|
-
const rewritten = rewriteWithSpeechTags(text);
|
|
144
|
-
const audioPath = await myTTS(rewritten, { language: options?.lang });
|
|
145
|
-
const sendTranscript = getTelegramVoiceSendTranscript(
|
|
146
|
-
getCurrentTelegramConfigView(),
|
|
147
|
-
);
|
|
148
|
-
return sendTranscript ? { audioPath, transcriptText: text } : { audioPath };
|
|
149
|
-
},
|
|
150
|
-
{ id: "my-voice-provider/tts" },
|
|
151
|
-
);
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
`getCurrentTelegramConfigView()` represents whatever current `TelegramConfig` view your extension already owns or receives; pi-telegram does not require providers to read config directly. The bridge never sends a separate transcript message. Caption-only is the "ON" behavior.
|
|
155
|
-
|
|
156
128
|
### Surfacing provider diagnostics
|
|
157
129
|
|
|
158
130
|
Voice provider extensions can record runtime events that appear in `/telegram-status` alongside pi-telegram's own events:
|
|
@@ -170,13 +142,13 @@ recordTelegramRuntimeEvent("voice-provider", new Error("TTS failed"), {
|
|
|
170
142
|
|
|
171
143
|
## Voice Extension Section
|
|
172
144
|
|
|
173
|
-
Voice provider extensions can register a Voice Extension Section (settings UI) via `registerTelegramSection`. The section can expose provider-specific controls such as TTS voice, language, speech style,
|
|
145
|
+
Voice provider extensions can register a Voice Extension Section (settings UI) via `registerTelegramSection`. The section can expose provider-specific controls such as TTS voice, language, speech style, or STT/TTS enablement. Reply mode is a core pi-telegram setting and belongs in the built-in Settings menu.
|
|
174
146
|
|
|
175
147
|
**Note on resume:** Because the previous automatic persistent re-registration system has been removed, extensions are responsible for re-registering their Voice Extension Section on `session_start` if they want the menu to survive a `pi resume`. See `registerTelegramSection` from `@llblab/pi-telegram/sections`.
|
|
176
148
|
|
|
177
149
|
## Prompt Guidance
|
|
178
150
|
|
|
179
|
-
The bridge keeps voice prompt context compact, effective, and policy-owned. `
|
|
151
|
+
The bridge keeps voice prompt context compact, effective, and policy-owned. `manual` 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.
|
|
180
152
|
|
|
181
153
|
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.
|
|
182
154
|
|
|
@@ -224,4 +196,4 @@ The bridge reads `voice.replyMode` from the config when building a turn.
|
|
|
224
196
|
|
|
225
197
|
### Provider config
|
|
226
198
|
|
|
227
|
-
Provider-specific settings (voice ID, language, speech style,
|
|
199
|
+
Provider-specific settings (voice ID, language, speech style, STT/TTS enablement) are owned by the voice provider extension. Reply mode is owned by pi-telegram's `voice.replyMode` and configured from the built-in pi-telegram Settings menu, not duplicated in provider UIs.
|
|
@@ -415,6 +415,8 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
415
415
|
},
|
|
416
416
|
getRegistrationGeneration:
|
|
417
417
|
telegramBusFollowerRegistrationState.getGeneration,
|
|
418
|
+
waitForRegistrationGeneration:
|
|
419
|
+
telegramBusFollowerRegistrationState.waitForGeneration,
|
|
418
420
|
getForwardCommentBatchPosition:
|
|
419
421
|
textGroupRuntime.getPreparedForwardingPosition,
|
|
420
422
|
recordRuntimeEvent,
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
export const TELEGRAM_BUS_FOLLOWER_PROMOTION_GRACE_MS = 2_500;
|
|
48
48
|
export const TELEGRAM_FOLLOWER_SESSION_HANDOFF_TTL_MS = 30_000;
|
|
49
49
|
export const TELEGRAM_BUS_FOLLOWER_CLIENT_TIMEOUT_MS = 30_000;
|
|
50
|
+
export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_WAIT_MS = 30_000;
|
|
50
51
|
export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_ATTEMPTS =
|
|
51
52
|
TELEGRAM_BUS_REGISTRATION_RETRY.attempts;
|
|
52
53
|
export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_DELAY_MS =
|
|
@@ -167,6 +168,9 @@ export interface TelegramBusFollowerRegistrationState {
|
|
|
167
168
|
getSlot: () => string | undefined;
|
|
168
169
|
getThreadName: () => string | undefined;
|
|
169
170
|
getGeneration: () => string | undefined;
|
|
171
|
+
beginRecovery: () => number;
|
|
172
|
+
cancelRecovery: () => void;
|
|
173
|
+
waitForGeneration: (timeoutMs?: number) => Promise<string | undefined>;
|
|
170
174
|
getLeaderProtocol: () => TelegramBusProtocolIdentity | undefined;
|
|
171
175
|
getEligibleElectionSlots: () => readonly string[];
|
|
172
176
|
setEligibleElectionSlots: (slots: readonly string[]) => void;
|
|
@@ -214,6 +218,9 @@ export interface TelegramBusFollowerClientRuntimeDeps<TMessage = unknown> {
|
|
|
214
218
|
getApiAuthSecret?: () => string | undefined;
|
|
215
219
|
getForwardingAuthSecret?: () => string | undefined;
|
|
216
220
|
getRegistrationGeneration: () => string | undefined;
|
|
221
|
+
waitForRegistrationGeneration?: (
|
|
222
|
+
timeoutMs?: number,
|
|
223
|
+
) => Promise<string | undefined>;
|
|
217
224
|
getForwardCommentBatchPosition?: (
|
|
218
225
|
message: TMessage,
|
|
219
226
|
) => "comment" | "forward" | undefined;
|
|
@@ -231,6 +238,9 @@ export interface TelegramBusFollowerApiCallerDeps {
|
|
|
231
238
|
createRequestId: () => string;
|
|
232
239
|
getAuthSecret?: () => string | undefined;
|
|
233
240
|
getRegistrationGeneration: () => string | undefined;
|
|
241
|
+
waitForRegistrationGeneration?: (
|
|
242
|
+
timeoutMs?: number,
|
|
243
|
+
) => Promise<string | undefined>;
|
|
234
244
|
getNowMs?: () => number;
|
|
235
245
|
timeoutMs?: number;
|
|
236
246
|
}
|
|
@@ -417,6 +427,7 @@ export interface TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext> {
|
|
|
417
427
|
| "getSlot"
|
|
418
428
|
| "getThreadName"
|
|
419
429
|
| "getEligibleElectionSlots"
|
|
430
|
+
| "beginRecovery"
|
|
420
431
|
| "setRegistered"
|
|
421
432
|
>;
|
|
422
433
|
getRegistrationRuntime: () => TelegramBusFollowerRegistrationRuntime<TContext>;
|
|
@@ -638,6 +649,7 @@ export function createTelegramBusFollowerClientRuntime<
|
|
|
638
649
|
socketPath: deps.socketPath,
|
|
639
650
|
createRequestId,
|
|
640
651
|
timeoutMs,
|
|
652
|
+
waitForRegistrationGeneration: deps.waitForRegistrationGeneration,
|
|
641
653
|
};
|
|
642
654
|
return {
|
|
643
655
|
createRequestId,
|
|
@@ -695,14 +707,14 @@ export function createTelegramBusFollowerQueueHandoffClient(
|
|
|
695
707
|
const timeoutMs =
|
|
696
708
|
deps.timeoutMs ?? TELEGRAM_BUS_FOLLOWER_CLIENT_TIMEOUT_MS;
|
|
697
709
|
return async (input) => {
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
710
|
+
const registration = await resolveTelegramBusFollowerRegistration(
|
|
711
|
+
deps,
|
|
712
|
+
timeoutMs,
|
|
713
|
+
);
|
|
702
714
|
const socketPath = resolveTelegramBusSocketPath(deps.socketPath);
|
|
703
715
|
const response = await sendTelegramBusLocalEnvelope({
|
|
704
716
|
socketPath,
|
|
705
|
-
timeoutMs,
|
|
717
|
+
timeoutMs: registration.remainingTimeoutMs,
|
|
706
718
|
retry: getTelegramBusTransportRetryPolicy({
|
|
707
719
|
endpoint: socketPath,
|
|
708
720
|
operation: "operation",
|
|
@@ -712,7 +724,7 @@ export function createTelegramBusFollowerQueueHandoffClient(
|
|
|
712
724
|
requestId: deps.createRequestId(),
|
|
713
725
|
auth: deps.getAuthSecret?.(),
|
|
714
726
|
instanceId: deps.instanceId,
|
|
715
|
-
registrationGeneration,
|
|
727
|
+
registrationGeneration: registration.generation,
|
|
716
728
|
...input,
|
|
717
729
|
sentAtMs: getNowMs(),
|
|
718
730
|
},
|
|
@@ -770,11 +782,12 @@ export function createTelegramBusAgentMessageClient(
|
|
|
770
782
|
envelope:
|
|
771
783
|
| Extract<TelegramBusEnvelope, { kind: "follower.resolveAgentTarget" }>
|
|
772
784
|
| Extract<TelegramBusEnvelope, { kind: "follower.routeAgentMessage" }>,
|
|
785
|
+
requestTimeoutMs = timeoutMs,
|
|
773
786
|
): Promise<unknown> => {
|
|
774
787
|
const socketPath = resolveTelegramBusSocketPath(deps.socketPath);
|
|
775
788
|
const response = await sendTelegramBusLocalEnvelope({
|
|
776
789
|
socketPath,
|
|
777
|
-
timeoutMs,
|
|
790
|
+
timeoutMs: requestTimeoutMs,
|
|
778
791
|
retry: getTelegramBusTransportRetryPolicy({
|
|
779
792
|
endpoint: socketPath,
|
|
780
793
|
operation: "operation",
|
|
@@ -788,26 +801,30 @@ export function createTelegramBusAgentMessageClient(
|
|
|
788
801
|
: "Telegram bus agent message did not return an acknowledgement.",
|
|
789
802
|
);
|
|
790
803
|
};
|
|
791
|
-
const registrationFields = () => {
|
|
792
|
-
const
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
804
|
+
const registrationFields = async () => {
|
|
805
|
+
const registration = await resolveTelegramBusFollowerRegistration(
|
|
806
|
+
deps,
|
|
807
|
+
timeoutMs,
|
|
808
|
+
);
|
|
796
809
|
return {
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
810
|
+
fields: {
|
|
811
|
+
auth: deps.getAuthSecret?.(),
|
|
812
|
+
instanceId: deps.instanceId,
|
|
813
|
+
registrationGeneration: registration.generation,
|
|
814
|
+
},
|
|
815
|
+
remainingTimeoutMs: registration.remainingTimeoutMs,
|
|
800
816
|
};
|
|
801
817
|
};
|
|
802
818
|
return {
|
|
803
819
|
async resolveTarget(selector) {
|
|
820
|
+
const registration = await registrationFields();
|
|
804
821
|
const result = await request({
|
|
805
822
|
kind: "follower.resolveAgentTarget",
|
|
806
823
|
requestId: deps.createRequestId(),
|
|
807
|
-
...
|
|
824
|
+
...registration.fields,
|
|
808
825
|
selector,
|
|
809
826
|
sentAtMs: getNowMs(),
|
|
810
|
-
});
|
|
827
|
+
}, registration.remainingTimeoutMs);
|
|
811
828
|
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
812
829
|
throw new Error("Telegram bus returned an invalid agent target.");
|
|
813
830
|
}
|
|
@@ -821,13 +838,14 @@ export function createTelegramBusAgentMessageClient(
|
|
|
821
838
|
return { chatId: target.chatId, threadId: target.threadId };
|
|
822
839
|
},
|
|
823
840
|
async routeMessage(message) {
|
|
841
|
+
const registration = await registrationFields();
|
|
824
842
|
await request({
|
|
825
843
|
kind: "follower.routeAgentMessage",
|
|
826
844
|
requestId: deps.createRequestId(),
|
|
827
|
-
...
|
|
845
|
+
...registration.fields,
|
|
828
846
|
message,
|
|
829
847
|
sentAtMs: getNowMs(),
|
|
830
|
-
});
|
|
848
|
+
}, registration.remainingTimeoutMs);
|
|
831
849
|
},
|
|
832
850
|
};
|
|
833
851
|
}
|
|
@@ -839,16 +857,16 @@ export function createTelegramBusFollowerApiCaller(
|
|
|
839
857
|
const timeoutMs =
|
|
840
858
|
deps.timeoutMs ?? TELEGRAM_BUS_FOLLOWER_CLIENT_TIMEOUT_MS;
|
|
841
859
|
return async (method, args) => {
|
|
860
|
+
const registration = await resolveTelegramBusFollowerRegistration(
|
|
861
|
+
deps,
|
|
862
|
+
timeoutMs,
|
|
863
|
+
);
|
|
842
864
|
const socketPath = resolveTelegramBusSocketPath(deps.socketPath);
|
|
843
|
-
const registrationGeneration = deps.getRegistrationGeneration();
|
|
844
|
-
if (!registrationGeneration) {
|
|
845
|
-
throw new Error("Telegram bus follower is not registered.");
|
|
846
|
-
}
|
|
847
865
|
let response: TelegramBusEnvelope | undefined;
|
|
848
866
|
try {
|
|
849
867
|
response = await sendTelegramBusLocalEnvelope({
|
|
850
868
|
socketPath,
|
|
851
|
-
timeoutMs,
|
|
869
|
+
timeoutMs: registration.remainingTimeoutMs,
|
|
852
870
|
retry: getTelegramBusTransportRetryPolicy({
|
|
853
871
|
endpoint: socketPath,
|
|
854
872
|
operation: "operation",
|
|
@@ -858,7 +876,7 @@ export function createTelegramBusFollowerApiCaller(
|
|
|
858
876
|
requestId: deps.createRequestId(),
|
|
859
877
|
auth: deps.getAuthSecret?.(),
|
|
860
878
|
instanceId: deps.instanceId,
|
|
861
|
-
registrationGeneration,
|
|
879
|
+
registrationGeneration: registration.generation,
|
|
862
880
|
method,
|
|
863
881
|
args,
|
|
864
882
|
sentAtMs: getNowMs(),
|
|
@@ -893,6 +911,29 @@ export function createTelegramBusFollowerApiCaller(
|
|
|
893
911
|
};
|
|
894
912
|
}
|
|
895
913
|
|
|
914
|
+
async function resolveTelegramBusFollowerRegistration(
|
|
915
|
+
deps: Pick<
|
|
916
|
+
TelegramBusFollowerApiCallerDeps,
|
|
917
|
+
| "getRegistrationGeneration"
|
|
918
|
+
| "waitForRegistrationGeneration"
|
|
919
|
+
| "getNowMs"
|
|
920
|
+
>,
|
|
921
|
+
timeoutMs: number,
|
|
922
|
+
): Promise<{ generation: string; remainingTimeoutMs: number }> {
|
|
923
|
+
const current = deps.getRegistrationGeneration();
|
|
924
|
+
if (current) return { generation: current, remainingTimeoutMs: timeoutMs };
|
|
925
|
+
const getNowMs = deps.getNowMs ?? Date.now;
|
|
926
|
+
const startedAtMs = getNowMs();
|
|
927
|
+
const restored = await deps.waitForRegistrationGeneration?.(
|
|
928
|
+
timeoutMs,
|
|
929
|
+
);
|
|
930
|
+
const remainingTimeoutMs = Math.max(0, timeoutMs - (getNowMs() - startedAtMs));
|
|
931
|
+
if (restored && remainingTimeoutMs > 0) {
|
|
932
|
+
return { generation: restored, remainingTimeoutMs };
|
|
933
|
+
}
|
|
934
|
+
throw new Error("Telegram bus follower is not registered.");
|
|
935
|
+
}
|
|
936
|
+
|
|
896
937
|
function isTelegramStaleContextError(error: unknown): boolean {
|
|
897
938
|
return (
|
|
898
939
|
error instanceof Error &&
|
|
@@ -1046,12 +1087,52 @@ export function createTelegramBusFollowerRegistrationState(
|
|
|
1046
1087
|
let generation: string | undefined;
|
|
1047
1088
|
let leaderProtocol: TelegramBusProtocolIdentity | undefined;
|
|
1048
1089
|
let eligibleElectionSlots: string[] = [];
|
|
1090
|
+
let recoveryEpoch = 0;
|
|
1091
|
+
let activeRecoveryEpoch: number | undefined;
|
|
1092
|
+
const generationWaiters = new Set<{
|
|
1093
|
+
epoch: number;
|
|
1094
|
+
settle: (value: string | undefined) => void;
|
|
1095
|
+
}>();
|
|
1096
|
+
const settleGenerationWaiters = (
|
|
1097
|
+
value: string | undefined,
|
|
1098
|
+
epoch?: number,
|
|
1099
|
+
) => {
|
|
1100
|
+
for (const waiter of [...generationWaiters]) {
|
|
1101
|
+
if (epoch === undefined || waiter.epoch === epoch) waiter.settle(value);
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1049
1104
|
return {
|
|
1050
1105
|
isRegistered: () => registered,
|
|
1051
1106
|
getTarget: () => (target ? { ...target } : undefined),
|
|
1052
1107
|
getSlot: () => slot,
|
|
1053
1108
|
getThreadName: () => threadName,
|
|
1054
1109
|
getGeneration: () => generation,
|
|
1110
|
+
beginRecovery: () => {
|
|
1111
|
+
if (activeRecoveryEpoch !== undefined) return activeRecoveryEpoch;
|
|
1112
|
+
activeRecoveryEpoch = ++recoveryEpoch;
|
|
1113
|
+
return activeRecoveryEpoch;
|
|
1114
|
+
},
|
|
1115
|
+
cancelRecovery: () => {
|
|
1116
|
+
const epoch = activeRecoveryEpoch;
|
|
1117
|
+
activeRecoveryEpoch = undefined;
|
|
1118
|
+
if (epoch !== undefined) settleGenerationWaiters(undefined, epoch);
|
|
1119
|
+
},
|
|
1120
|
+
waitForGeneration: (timeoutMs = TELEGRAM_BUS_FOLLOWER_REGISTRATION_WAIT_MS) => {
|
|
1121
|
+
if (generation) return Promise.resolve(generation);
|
|
1122
|
+
const epoch = activeRecoveryEpoch;
|
|
1123
|
+
if (epoch === undefined) return Promise.resolve(undefined);
|
|
1124
|
+
return new Promise((resolve) => {
|
|
1125
|
+
let timer: NodeJS.Timeout | undefined;
|
|
1126
|
+
const settle = (value: string | undefined) => {
|
|
1127
|
+
generationWaiters.delete(waiter);
|
|
1128
|
+
if (timer) clearTimeout(timer);
|
|
1129
|
+
resolve(value);
|
|
1130
|
+
};
|
|
1131
|
+
const waiter = { epoch, settle };
|
|
1132
|
+
generationWaiters.add(waiter);
|
|
1133
|
+
timer = setTimeout(() => settle(undefined), Math.max(0, timeoutMs));
|
|
1134
|
+
});
|
|
1135
|
+
},
|
|
1055
1136
|
getLeaderProtocol: () =>
|
|
1056
1137
|
leaderProtocol
|
|
1057
1138
|
? { ...leaderProtocol, capabilities: [...leaderProtocol.capabilities] }
|
|
@@ -1077,6 +1158,10 @@ export function createTelegramBusFollowerRegistrationState(
|
|
|
1077
1158
|
}
|
|
1078
1159
|
: undefined;
|
|
1079
1160
|
if (availabilityChanged) options.onAvailabilityChanged?.();
|
|
1161
|
+
if (generation) {
|
|
1162
|
+
activeRecoveryEpoch = undefined;
|
|
1163
|
+
settleGenerationWaiters(generation);
|
|
1164
|
+
}
|
|
1080
1165
|
},
|
|
1081
1166
|
};
|
|
1082
1167
|
}
|
|
@@ -1253,6 +1338,7 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
|
|
|
1253
1338
|
): Promise<void> => {
|
|
1254
1339
|
if (promotionPending) return;
|
|
1255
1340
|
promotionPending = true;
|
|
1341
|
+
deps.registrationState.beginRecovery();
|
|
1256
1342
|
try {
|
|
1257
1343
|
const initialBinding = carriedBinding ?? snapshotBinding();
|
|
1258
1344
|
const state = deps.getLeaderState();
|
|
@@ -1366,6 +1452,7 @@ export function createTelegramBusFollowerRegistrationRuntime<
|
|
|
1366
1452
|
heartbeatPromise = undefined;
|
|
1367
1453
|
heartbeatPromiseGeneration = undefined;
|
|
1368
1454
|
deps.setActiveAuthSecret?.(undefined);
|
|
1455
|
+
deps.registrationState?.cancelRecovery();
|
|
1369
1456
|
deps.registrationState?.setRegistered(false);
|
|
1370
1457
|
lastKnownTarget = undefined;
|
|
1371
1458
|
lastKnownSlot = undefined;
|
|
@@ -118,9 +118,8 @@ export interface TelegramConfig {
|
|
|
118
118
|
/** @deprecated use assistant.rendering */
|
|
119
119
|
assistantRendering?: TelegramAssistantRenderingMode;
|
|
120
120
|
voice?: {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
sendTranscript?: boolean;
|
|
121
|
+
/** `hidden` is a read-only compatibility alias for the former manual mode. */
|
|
122
|
+
replyMode?: "manual" | "hidden" | "mirror" | "always";
|
|
124
123
|
};
|
|
125
124
|
time?: TelegramTimeConfig;
|
|
126
125
|
threads?: {
|
|
@@ -845,10 +844,10 @@ export function createTelegramActivityVerbositySetter(
|
|
|
845
844
|
|
|
846
845
|
export function createTelegramVoiceReplyModeGetter(
|
|
847
846
|
configStore: Pick<TelegramConfigStore, "get">,
|
|
848
|
-
): () => "
|
|
847
|
+
): () => "manual" | "mirror" | "always" {
|
|
849
848
|
return () => {
|
|
850
849
|
const mode = configStore.get().voice?.replyMode;
|
|
851
|
-
return mode === "mirror" || mode === "always" ? mode : "
|
|
850
|
+
return mode === "mirror" || mode === "always" ? mode : "manual";
|
|
852
851
|
};
|
|
853
852
|
}
|
|
854
853
|
|
|
@@ -863,11 +862,15 @@ export function createTelegramVoiceReplyModeConfiguredChecker(
|
|
|
863
862
|
|
|
864
863
|
export function createTelegramVoiceReplyModeSetter(
|
|
865
864
|
configStore: TelegramMutableConfigStore,
|
|
866
|
-
): (replyMode: "hidden" | "mirror" | "always" | undefined) => Promise<void> {
|
|
865
|
+
): (replyMode: "manual" | "hidden" | "mirror" | "always" | undefined) => Promise<void> {
|
|
867
866
|
return async (replyMode) => {
|
|
868
867
|
await loadLatestTelegramConfig(configStore);
|
|
869
868
|
const current = configStore.get();
|
|
870
|
-
if (
|
|
869
|
+
if (
|
|
870
|
+
replyMode === undefined ||
|
|
871
|
+
replyMode === "manual" ||
|
|
872
|
+
replyMode === "hidden"
|
|
873
|
+
) {
|
|
871
874
|
const { replyMode: _replyMode, ...remainingVoice } = current.voice ?? {};
|
|
872
875
|
const next = { ...current };
|
|
873
876
|
if (Object.keys(remainingVoice).length > 0) next.voice = remainingVoice;
|
|
@@ -149,9 +149,7 @@ export const TIME_INJECTION_MODE_SETTINGS_TITLE =
|
|
|
149
149
|
"<b>🕒 Time injection mode:</b>";
|
|
150
150
|
export const VOICE_REPLY_MODE_SETTINGS_TITLE = "<b>👄 Voice reply mode:</b>";
|
|
151
151
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
function getVoiceReplyModeLabel(mode: TelegramVoiceReplyModeSetting): string {
|
|
152
|
+
function getVoiceReplyModeLabel(mode: TelegramVoiceReplyMode): string {
|
|
155
153
|
return mode;
|
|
156
154
|
}
|
|
157
155
|
|
|
@@ -162,8 +160,8 @@ function getTelegramSettingsStateValueLabel(value: string): string {
|
|
|
162
160
|
function getVoiceReplyModeSetting(
|
|
163
161
|
mode: TelegramVoiceReplyMode,
|
|
164
162
|
configured: boolean,
|
|
165
|
-
):
|
|
166
|
-
return configured ? mode : "
|
|
163
|
+
): TelegramVoiceReplyMode {
|
|
164
|
+
return configured ? mode : "manual";
|
|
167
165
|
}
|
|
168
166
|
|
|
169
167
|
export function buildTelegramSettingsMenuText(): string {
|
|
@@ -246,8 +244,8 @@ export function buildVoiceReplyModeSettingsText(
|
|
|
246
244
|
"",
|
|
247
245
|
"Controls when pi-telegram converts assistant text replies into Telegram voice messages.",
|
|
248
246
|
"",
|
|
249
|
-
"<code>-</code> <code>
|
|
250
|
-
"<code>-</code> <code>mirror</code>: voice input activates automatic voice delivery; text input follows '
|
|
247
|
+
"<code>-</code> <code>manual</code> (default): add no automatic voice context; explicit 'telegram_voice' actions still work.",
|
|
248
|
+
"<code>-</code> <code>mirror</code>: voice input activates automatic voice delivery; text input follows 'manual' behavior.",
|
|
251
249
|
"<code>-</code> <code>always</code>: activate automatic voice delivery for every reply.",
|
|
252
250
|
].join("\n");
|
|
253
251
|
}
|
|
@@ -502,7 +500,7 @@ export function buildVoiceReplyModeSettingsReplyMarkup(
|
|
|
502
500
|
configured = true,
|
|
503
501
|
): TelegramSettingsMenuReplyMarkup {
|
|
504
502
|
const activeMode = getVoiceReplyModeSetting(mode, configured);
|
|
505
|
-
const modes:
|
|
503
|
+
const modes: TelegramVoiceReplyMode[] = ["manual", "mirror", "always"];
|
|
506
504
|
return {
|
|
507
505
|
inline_keyboard: [
|
|
508
506
|
[{ text: "⬆️ Back", callback_data: "settings:list" }],
|
|
@@ -661,12 +659,18 @@ export async function handleTelegramSettingsMenuCallbackAction(
|
|
|
661
659
|
}
|
|
662
660
|
if (data.startsWith("settings:set:voice-reply:")) {
|
|
663
661
|
const mode = data.slice("settings:set:voice-reply:".length);
|
|
664
|
-
if (
|
|
665
|
-
|
|
662
|
+
if (
|
|
663
|
+
mode === "manual" ||
|
|
664
|
+
mode === "hidden" ||
|
|
665
|
+
mode === "mirror" ||
|
|
666
|
+
mode === "always"
|
|
667
|
+
) {
|
|
668
|
+
const normalizedMode = mode === "hidden" ? "manual" : mode;
|
|
669
|
+
await deps.setVoiceReplyMode(normalizedMode);
|
|
666
670
|
await updateVoiceReplyModeSettingsMessage(deps);
|
|
667
671
|
await deps.answerCallbackQuery(
|
|
668
672
|
callbackQueryId,
|
|
669
|
-
`Voice reply mode: ${
|
|
673
|
+
`Voice reply mode: ${normalizedMode}`,
|
|
670
674
|
);
|
|
671
675
|
return true;
|
|
672
676
|
}
|
|
@@ -100,17 +100,6 @@ async function ensureTelegramVoiceFileFormat(
|
|
|
100
100
|
);
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
function extractVoiceResult(result: any): {
|
|
104
|
-
filePath: string;
|
|
105
|
-
transcriptText?: string;
|
|
106
|
-
} {
|
|
107
|
-
if (typeof result === "string") return { filePath: result };
|
|
108
|
-
return {
|
|
109
|
-
filePath: result.audioPath,
|
|
110
|
-
transcriptText: result.transcriptText,
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
103
|
async function sendVoiceChatAction(
|
|
115
104
|
deps: TelegramVoiceReplySenderDeps,
|
|
116
105
|
chatId: number,
|
|
@@ -132,7 +121,6 @@ export function createTelegramVoiceReplySender<THandler = unknown>(
|
|
|
132
121
|
options?: {
|
|
133
122
|
replyToPrompt?: boolean;
|
|
134
123
|
replyMarkup?: unknown;
|
|
135
|
-
transcriptText?: string;
|
|
136
124
|
},
|
|
137
125
|
): Promise<void> => {
|
|
138
126
|
const voiceFilePath = await ensureTelegramVoiceFileFormat(filePath);
|
|
@@ -148,7 +136,6 @@ export function createTelegramVoiceReplySender<THandler = unknown>(
|
|
|
148
136
|
"sendVoice",
|
|
149
137
|
{
|
|
150
138
|
chat_id: String(turn.chatId),
|
|
151
|
-
...(options?.transcriptText ? { caption: options.transcriptText } : {}),
|
|
152
139
|
...(replyParameters ? { reply_parameters: replyParameters } : {}),
|
|
153
140
|
...(turn.target
|
|
154
141
|
? Object.fromEntries(
|
|
@@ -257,13 +244,11 @@ export function createTelegramVoiceReplySender<THandler = unknown>(
|
|
|
257
244
|
continue;
|
|
258
245
|
}
|
|
259
246
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
await uploadVoiceFile(turn, filePath, {
|
|
247
|
+
voiceFilePath = providerResult;
|
|
248
|
+
originalFilePath = providerResult;
|
|
249
|
+
await uploadVoiceFile(turn, providerResult, {
|
|
264
250
|
replyToPrompt: options?.replyToPrompt,
|
|
265
251
|
replyMarkup: options?.replyMarkup,
|
|
266
|
-
transcriptText,
|
|
267
252
|
});
|
|
268
253
|
return;
|
|
269
254
|
} catch (error) {
|
|
@@ -38,15 +38,9 @@ function getNextAvailableProviderId<T>(
|
|
|
38
38
|
return id;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export type TelegramVoiceReplyMode = "
|
|
41
|
+
export type TelegramVoiceReplyMode = "manual" | "mirror" | "always";
|
|
42
42
|
|
|
43
|
-
export type TelegramVoiceSynthesisProviderResult =
|
|
44
|
-
| string
|
|
45
|
-
| {
|
|
46
|
-
audioPath: string;
|
|
47
|
-
transcriptText?: string;
|
|
48
|
-
}
|
|
49
|
-
| undefined;
|
|
43
|
+
export type TelegramVoiceSynthesisProviderResult = string | undefined;
|
|
50
44
|
|
|
51
45
|
export interface TelegramVoiceTurnView {
|
|
52
46
|
voiceReplyPreferred?: boolean;
|
|
@@ -215,7 +209,7 @@ export function clearTelegramVoiceTranscriptionProviders(): void {
|
|
|
215
209
|
// --- Voice Reply Modes ---
|
|
216
210
|
|
|
217
211
|
export const TELEGRAM_VOICE_REPLY_MODES = [
|
|
218
|
-
"
|
|
212
|
+
"manual",
|
|
219
213
|
"mirror",
|
|
220
214
|
"always",
|
|
221
215
|
] as const;
|
|
@@ -224,33 +218,15 @@ export const TELEGRAM_VOICE_REPLY_MODES = [
|
|
|
224
218
|
* Returns the active voice reply mode for the current session.
|
|
225
219
|
*
|
|
226
220
|
* Pi-telegram owns reply-mode policy through telegram.json. If
|
|
227
|
-
* config.voice.replyMode is missing, invalid, or legacy `
|
|
228
|
-
* mode is
|
|
221
|
+
* config.voice.replyMode is missing, invalid, or legacy `hidden`, the effective
|
|
222
|
+
* mode is manual.
|
|
229
223
|
*/
|
|
230
224
|
export function getTelegramVoiceReplyMode(config?: {
|
|
231
225
|
voice?: { replyMode?: string };
|
|
232
226
|
}): TelegramVoiceReplyMode {
|
|
233
227
|
const configMode = config?.voice?.replyMode;
|
|
234
|
-
if (
|
|
235
|
-
|
|
236
|
-
(TELEGRAM_VOICE_REPLY_MODES as readonly string[]).includes(configMode)
|
|
237
|
-
) {
|
|
238
|
-
return configMode as TelegramVoiceReplyMode;
|
|
239
|
-
}
|
|
240
|
-
return "hidden";
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* Returns whether the user wants the voice synthesis provider's transcript attached
|
|
245
|
-
* as a caption on the voice message.
|
|
246
|
-
*
|
|
247
|
-
* Reads from `config.voice.sendTranscript`.
|
|
248
|
-
* Default: false (no transcript text sent at all).
|
|
249
|
-
*/
|
|
250
|
-
export function getTelegramVoiceSendTranscript(config?: {
|
|
251
|
-
voice?: { sendTranscript?: boolean };
|
|
252
|
-
}): boolean {
|
|
253
|
-
return !!config?.voice?.sendTranscript;
|
|
228
|
+
if (configMode === "mirror" || configMode === "always") return configMode;
|
|
229
|
+
return "manual";
|
|
254
230
|
}
|
|
255
231
|
|
|
256
232
|
// --- Voice Turn Helpers ---
|
|
@@ -122,7 +122,7 @@ Prefer no-code command-template configuration in `telegram.json` before adding a
|
|
|
122
122
|
- `outboundHandlers` transforms final replies.
|
|
123
123
|
- Voice transcription handlers can match `type: "voice"` or `mime: "audio/*"`; stdout becomes `[outputs]`.
|
|
124
124
|
|
|
125
|
-
When asked to configure voice rather than merely operate it, follow the provider-neutral contracts in `docs/voice.md`, `docs/inbound.md`, `docs/outbound.md`, and `docs/command-templates.md` from the pi-telegram package or repository. Inspect the available Skill catalog and trusted local executables for STT, TTS, and media conversion capabilities; check only whether required environment variables exist, never reveal their values. Preserve unrelated `telegram.json` fields, order multiple matching inbound handlers as fallbacks, require OGG/Opus output for native voice delivery, and validate each stage before a live Telegram smoke test. Keep `voice.replyMode` at its existing value unless the user requests a policy change: the default `
|
|
125
|
+
When asked to configure voice rather than merely operate it, follow the provider-neutral contracts in `docs/voice.md`, `docs/inbound.md`, `docs/outbound.md`, and `docs/command-templates.md` from the pi-telegram package or repository. Inspect the available Skill catalog and trusted local executables for STT, TTS, and media conversion capabilities; check only whether required environment variables exist, never reveal their values. Preserve unrelated `telegram.json` fields, order multiple matching inbound handlers as fallbacks, require OGG/Opus output for native voice delivery, and validate each stage before a live Telegram smoke test. Keep `voice.replyMode` at its existing value unless the user requests a policy change: the default `manual` mode is fully functional because explicit top-level `telegram_voice` actions still use the configured synthesis pipeline.
|
|
126
126
|
|
|
127
127
|
When configuration is insufficient, use documented `@llblab/pi-telegram/*` public API subpaths. Never import package-private `lib/*`, start another polling loop, or bypass bridge ownership with raw Bot API access.
|
|
128
128
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llblab/pi-kit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"@llblab/pi-actors": "0.50.0",
|
|
44
44
|
"@llblab/pi-codex-usage": "0.9.3",
|
|
45
45
|
"@llblab/pi-grow-loop": "0.7.2",
|
|
46
|
-
"@llblab/pi-telegram": "0.
|
|
46
|
+
"@llblab/pi-telegram": "0.38.0"
|
|
47
47
|
},
|
|
48
48
|
"bundledDependencies": [
|
|
49
49
|
"@llblab/pi-actors",
|