@llblab/pi-telegram 0.10.8 → 0.11.1

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.
@@ -2,21 +2,23 @@
2
2
 
3
3
  `pi-telegram` maps hidden assistant-authored HTML comments to Telegram-native outbound actions.
4
4
 
5
- This is intentionally prompt-driven: the agent writes normal Markdown plus small hidden top-level blocks, and the bridge performs the transport work after `agent_end`. `telegram_voice` and `telegram_button` are not π tools. Outbound behavior is an emergent result of the assistant prompt, configured command-template handlers, generated artifacts, and reply delivery. That avoids extra agent-side tool calls, avoids fragile parameter plumbing inside the conversation, and minimizes latency because text, voice, and buttons are planned in one standard assistant reply.
5
+ This is intentionally prompt-driven: the agent writes normal Markdown plus small hidden top-level blocks, and the bridge performs the transport work after `agent_end`. `telegram_voice` and `telegram_button` are not π tools. Outbound behavior is an emergent result of the assistant prompt, text command-template handlers, registered voice synthesis providers, generated artifacts, and reply delivery. That avoids extra agent-side tool calls, avoids fragile parameter plumbing inside the conversation, and minimizes latency because text, voice, and buttons are planned in one standard assistant reply.
6
6
 
7
- This document is the local outbound adaptation of the portable [Command Template Standard](./command-templates.md).
7
+ Text handlers use the portable [Command Template Standard](./command-templates.md). Programmatic outbound handlers use `registerTelegramOutboundHandler(kind, handler)`. Voice replies can use configured command-template handlers or the provider API described in [Voice Integration](./voice.md).
8
8
 
9
9
  ## Standard
10
10
 
11
11
  An outbound handler is selected by `type`. Text replies and assistant markup map to handler types:
12
12
 
13
- | Source | Type | Action |
14
- | --- | --- | --- |
15
- | Final text | `text` | Transform before render |
16
- | `telegram_voice` | `voice` | Generate OGG/Opus |
17
- | `telegram_button` | Built-in | Attach inline button |
13
+ | Source | Handler | Action |
14
+ | ----------------- | ----------------------------- | ----------------------- |
15
+ | Final text | `outboundHandlers[type=text]` | Transform before render |
16
+ | `telegram_voice` | Voice pipeline | OGG/Opus `sendVoice` |
17
+ | `telegram_button` | Built-in | Attach inline button |
18
18
 
19
- Configured command-template handlers provide `template`. A string is one command; an array is ordered composition. Top-level `args` and `defaults` apply to all composed steps unless a step defines private values. The command-template default timeout applies automatically. `output` selects the primary artifact path when the handler produces a file instead of stdout text. Legacy configs may still use `pipe`, but `template: [...]` is the preferred standard shape.
19
+ The voice pipeline is detailed below: configured `type: "voice"` handlers first, then programmatic handlers, then registered synthesis providers.
20
+
21
+ Configured text handlers provide `template`. A string is one command; an array is ordered composition. Top-level `args` and `defaults` apply to all composed steps unless a step defines private values. The command-template default timeout applies automatically. Legacy configs may still use `pipe`, but `template: [...]` is the preferred standard shape.
20
22
 
21
23
  ## Text Handler Config
22
24
 
@@ -52,27 +54,30 @@ Stdin-based or subagent-backed translation can omit `{text}` from the template b
52
54
 
53
55
  A text handler should preserve the full message unless shortening is intentional; for translation prompts, explicitly ask the tool to keep Markdown, line breaks, and details unchanged.
54
56
 
55
- ## Voice Handler Config
57
+ ## Voice Delivery Priority
56
58
 
57
- `telegram.json` may define `outboundHandlers`:
59
+ Voice replies use one fallback pipeline:
58
60
 
59
- ```json
60
- {
61
- "outboundHandlers": [
62
- {
63
- "type": "voice",
64
- "template": [
65
- "/path/to/translate-stdin --lang {lang=ru}",
66
- "/path/to/tts-from-stdin --lang {lang=ru} --rate {rate=+30%} --write-media {mp3}",
67
- "ffmpeg -y -i {mp3} -c:a libopus -b:a 32k -ar 16000 -ac 1 -vbr on {ogg}"
68
- ],
69
- "output": "ogg"
70
- }
71
- ]
72
- }
61
+ 1. configured `outboundHandlers` with `type: "voice"` in `telegram.json` order
62
+ 2. programmatic `registerTelegramOutboundHandler("voice", ...)` handlers
63
+ 3. registered voice synthesis providers from `@llblab/pi-telegram/lib/voice.ts`
64
+
65
+ This makes provider extensions a zero-config convenience without overriding explicit operator-owned `telegram.json` handlers. If several synthesis providers are registered, they are tried in registration order; the first provider that returns a valid `.ogg`/`.opus` artifact handles the reply. Returning `undefined` passes to the next provider, while thrown errors or invalid files are recorded before the next fallback is tried.
66
+
67
+ ## Voice Synthesis Provider API
68
+
69
+ Voice replies can be delivered by synthesis providers registered through `@llblab/pi-telegram/lib/voice.ts`:
70
+
71
+ ```ts
72
+ import { registerTelegramVoiceSynthesisProvider } from "@llblab/pi-telegram/lib/voice.ts";
73
+
74
+ const dispose = registerTelegramVoiceSynthesisProvider(async (text, options) => {
75
+ const audioPath = await synthesizeToOggOpus(text, options);
76
+ return { audioPath, transcriptText: text };
77
+ });
73
78
  ```
74
79
 
75
- In this example, the first step receives the `telegram_voice` text on stdin and returns translated text; the second step reads that translated text from stdin and writes `{mp3}`; the final step converts `{mp3}` to Telegram-ready `{ogg}`. If you do not need voice translation, omit the first step and call a TTS command that accepts `{text}` directly. If a matching voice handler fails, the bridge tries the next matching `type: "voice"` handler.
80
+ Synthesis providers receive the extracted `telegram_voice` text plus optional `lang`/`rate` hints. They own translation, TTS, speech rewriting, transcript choice, 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.
76
81
 
77
82
  ## Voice Markup
78
83
 
@@ -90,29 +95,7 @@ Text to synthesize as a Telegram voice message.
90
95
  <!-- telegram_voice: Short spoken companion summary. -->
91
96
  ```
92
97
 
93
- The bridge strips the comment from Telegram text. On `agent_end`, it maps each `telegram_voice` block to `type: "voice"`, generates one file per block, and sends each file as an independent Telegram-native voice message. The opening `<!-- telegram_voice` marker must start at column zero on a top-level line outside fenced code, quotes, and lists; otherwise it is rendered as literal Markdown. Body-form comments leave the opening line unclosed until the body-ending `-->`; closed heads can use `text="..."` for explicit one-line spoken text.
94
-
95
- ## Built-In Voice Placeholders
96
-
97
- Voice outbound handlers receive these runtime placeholders:
98
-
99
- | Placeholder | Value |
100
- | --- | --- |
101
- | `{text}` | Voice text from body, attr, or colon form |
102
- | `{lang}` | Optional override, e.g. `lang=ru` |
103
- | `{rate}` | Optional override, e.g. `rate=+30%` |
104
- | `{mp3}` | Temp MP3 path under agent temp |
105
- | `{ogg}` | Temp OGG path under agent temp |
106
-
107
- Temp artifacts use unique flat names such as `<uuid>-voice.mp3` and `<uuid>-voice.ogg`. The bridge does not create per-handler directory trees.
108
-
109
- ## Output
110
-
111
- For composed handlers, `output` selects the primary artifact after the composition completes. Omitted `output` means `"stdout"`, so the final step should print the generated OGG/Opus path. `"output": "ogg"` means the generated file path comes from `{ogg}`. A value such as `"{ogg}"` is equivalent. Composition also follows the command-template standard where each step's stdout is provided as stdin to the next step by default.
112
-
113
- For one-step `template` handlers, stdout remains the default result channel: the command should print the generated OGG/Opus path.
114
-
115
- **Critical steps:** voice synthesis is often a multi-step transform → TTS → conversion pipeline. The final audio conversion step is inherently critical — if it fails, the voice output is invalid. Mark conversion steps as `"critical": true` when a composed handler must abort after conversion failure instead of continuing to later non-critical steps. Use multiple matching `type: "voice"` handlers when you need provider or command fallbacks. See [Command Template Standard](./command-templates.md) for semantics.
98
+ The bridge strips the comment from Telegram text. On `agent_end`, it maps each `telegram_voice` block to a provider call, generates one file per block, and sends each file as an independent Telegram-native voice message. The opening `<!-- telegram_voice` marker must start at column zero on a top-level line outside fenced code, quotes, and lists; otherwise it is rendered as literal Markdown. Body-form comments leave the opening line unclosed until the body-ending `-->`; closed heads can use `text="..."` for explicit one-line spoken text.
116
99
 
117
100
  ## Buttons Markup
118
101
 
@@ -149,6 +132,6 @@ The extension injects Telegram-specific system prompt guidance so agents know th
149
132
  - Write the full technical answer as normal Markdown.
150
133
  - Add `telegram_voice` when a Telegram-native voice message is useful; use body text, `text="..."`, or colon shorthand for the text to synthesize. A companion summary is optional, no specific summary format is required.
151
134
  - Add `telegram_button: ...` when label equals prompt, `telegram_button label="..." prompt="..."` for one-line prompts, or `telegram_button label="..."` with a body for multiline prompts. If the reply contains only button/voice comment blocks, add a short visible marker (for example `Choose one:`) before them so Telegram always has a visible parent message for attachment.
152
- - Do not call or register TTS/text-to-OGG/Telegram transport tools for voice or buttons; the bridge owns the configured outbound-handler pipeline and delivery.
135
+ - Do not call Telegram transport tools for voice or buttons; the bridge owns delivery, while registered voice synthesis providers own TTS and OGG/Opus conversion.
153
136
 
154
137
  This keeps the agent focused on semantics and lets the bridge handle low-latency Telegram adaptation.
package/docs/voice.md ADDED
@@ -0,0 +1,210 @@
1
+ # Voice Integration
2
+
3
+ Voice messages flow through an **inbound transcription → outbound voice reply** pipeline. This document describes the bridge's role in that pipeline; provider-specific mechanics (TTS/STT backends, voice IDs, languages) are owned by voice provider extensions. In `0.11.0`, this is a first-class extension surface: one companion extension can provide STT fallbacks for inbound voice/audio files and TTS fallbacks for outbound Telegram voice replies without owning a second bot poller.
4
+
5
+ ## Overview
6
+
7
+ 1. **Inbound:** A voice message arrives via Telegram. Inbound handlers transcribe it to text.
8
+ 2. **Processing:** The transcription becomes the agent prompt. The bridge tags the turn if it originated from voice.
9
+ 3. **Outbound:** If voice replies are enabled, the agent's text response is converted to voice and sent back. No text draft appears in Telegram during generation.
10
+
11
+ The bridge owns Telegram transport, queue integration, reply-mode policy, preview suppression, fallback text delivery, and Settings UI. Provider extensions own STT/TTS calls, speech rewriting, provider-specific menus, transcript preference, and OGG/Opus conversion.
12
+
13
+ ## Voice Detection
14
+
15
+ Voice messages arrive as `message.voice` in Telegram updates. The bridge's media processing detects these and sets `kind: "voice"` on the downloaded file. Regular audio files (`message.audio`) get `kind: "audio"`; `mirror` mode treats both voice notes and audio uploads as voice input for reply-policy tagging.
16
+
17
+ Inbound handlers match `kind: "voice"` or `mime: "audio/*"` to run a transcription command:
18
+
19
+ ```json
20
+ {
21
+ "inboundHandlers": [
22
+ {
23
+ "mime": "audio/*",
24
+ "template": ["/path/to/stt", "--file={file}", "--mime={mime}"]
25
+ }
26
+ ]
27
+ }
28
+ ```
29
+
30
+ The transcription output becomes the raw text of the prompt.
31
+
32
+ Voice provider extensions can also register STT backends with `registerTelegramVoiceTranscriptionProvider()` from `@llblab/pi-telegram/lib/voice.ts`. Inbound command-template handlers and programmatic inbound handlers remain the stronger generic paths and run first; if no matching handler produces output for a voice/audio file, registered transcription providers are tried as fallback in registration order. The first provider that returns non-empty text wins; providers that return `undefined` pass to the next provider, and provider failures are recorded before trying the next provider. This lets a full voice extension provide both TTS and STT without requiring `telegram.json` handler templates, while still preserving operator-configured inbound handlers as the stronger choice.
33
+
34
+ ## Voice Reply Policy
35
+
36
+ The bridge decides **when** to reply with voice from `voice.replyMode` in `TelegramConfig` (stored in `telegram.json`). If config does not set a valid mode, the bridge uses the `hidden` default: manual behavior without adding voice policy text to the prompt context.
37
+
38
+ ### Modes
39
+
40
+ - **`hidden` (default):** no `voice.replyMode` is stored; same behavior as manual, no prompt voice context.
41
+ - **`manual`:** only reply with voice when the agent authors `<!-- telegram_voice -->` markup; explicit prompt context is added.
42
+ - **`mirror`:** reply with voice when the inbound message was a voice note or audio file. Text input stays on the manual path where explicit `telegram_voice` markup still works.
43
+ - **`always`:** always reply with voice.
44
+
45
+ **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
+
47
+ When a message is received, the bridge resolves the active voice reply mode and tags the turn:
48
+
49
+ - `voiceReplyPreferred`: `true` when mode is `mirror` and the turn has a voice file
50
+ - `voiceReplyRequired`: `true` when mode is `always`
51
+
52
+ At `agent_end`, if the turn is voice-tagged and the agent response has no explicit `telegram_voice` markup, the bridge transparently intercepts the text reply and converts it to voice. If the agent uses multiple `telegram_voice` blocks, each becomes a separate voice message. The same reply-mode decision applies to both registered voice synthesis providers and configured outbound voice handlers.
53
+
54
+ ### Preview Suppression
55
+
56
+ When a turn is voice-tagged, the bridge suppresses text preview streaming during LLM generation. This prevents draft text from appearing in Telegram before the voice message is delivered.
57
+
58
+ ## Voice Provider Extension Surface
59
+
60
+ A voice extension may combine three public seams:
61
+
62
+ - `registerTelegramVoiceTranscriptionProvider()` for inbound STT fallback on voice/audio files
63
+ - `registerTelegramVoiceSynthesisProvider()` for outbound TTS/synthesis fallback to Telegram voice messages
64
+ - `registerTelegramSection()` for provider-specific Telegram UI such as voice, language, style, transcript, or provider on/off controls
65
+
66
+ The reply policy itself remains a built-in pi-telegram setting (`voice.replyMode`) rather than a provider-owned menu.
67
+
68
+ ## Outbound Voice Synthesis Provider Registration
69
+
70
+ Voice synthesis provider extensions (such as `pi-xai-voice`) register themselves through `registerTelegramVoiceSynthesisProvider()`. The bridge only provides the registration seam and the actual delivery to Telegram. **The provider is fully responsible for**:
71
+
72
+ - Text optimisation / speech-style rewriting
73
+ - Adding speech tags (when desired)
74
+ - Running TTS + ffmpeg conversion to OGG/Opus
75
+ - Deciding whether to return `transcriptText` at all (based on the user's "Send Transcript" toggle)
76
+ - `transcriptText` (when returned) is attached by the bridge as the voice message **caption** only. Separate transcript messages are no longer sent.
77
+
78
+ The bridge shows a `record_voice` action while delivering and sends the final audio with Telegram `sendVoice`. When a provider returns `transcriptText`, the bridge attaches it as the voice caption.
79
+
80
+ 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.
81
+
82
+ See the TSDoc on `registerTelegramVoiceSynthesisProvider` and `TelegramVoiceSynthesisProviderResult` in `lib/voice.ts` for the exact interface.
83
+
84
+ The provider receives the raw agent text plus optional `{ lang?, rate? }`.
85
+
86
+ It must return one of:
87
+
88
+ - `string` — path to a ready `.ogg` or `.opus` file
89
+ - `{ audioPath: string, transcriptText?: string }` — `audioPath` must be OGG/Opus. When `transcriptText` is present it is attached as the voice message **caption**. A provider UI can expose a "Send Transcript" toggle by returning `transcriptText` only when that toggle is enabled.
90
+ - `undefined` — skip this text block
91
+
92
+ **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.
93
+
94
+ **File format:** Telegram `sendVoice` requires **OGG/Opus** to display the message as a native voice note (waveform, inline playback). MP3 and other formats are accepted by the API but render as regular audio attachments (music note icon, filename visible). **Providers and outbound voice handlers must return `.ogg` or `.opus` files.** Returning non-OGG files causes the bridge to throw and fall back to text delivery.
95
+
96
+ Registration returns a disposer function for cleanup. Extensions should call it on shutdown or re-register safely on session start when their runtime is recreated.
97
+
98
+ ## Outbound Voice Handlers
99
+
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 (`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.
101
+
102
+ 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
+
104
+ ```json
105
+ {
106
+ "voice": { "replyMode": "mirror" },
107
+ "outboundHandlers": [
108
+ {
109
+ "type": "voice",
110
+ "template": [
111
+ "/path/to/tts --write-media {mp3}",
112
+ "ffmpeg -y -i {mp3} -c:a libopus -b:a 32k -ar 16000 -ac 1 {ogg}"
113
+ ],
114
+ "output": "ogg"
115
+ }
116
+ ]
117
+ }
118
+ ```
119
+
120
+ 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.
121
+
122
+ ### Provider with transcript caption (controlled by user toggle)
123
+
124
+ 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`).
125
+
126
+ ```typescript
127
+ import { registerTelegramVoiceSynthesisProvider } from "@llblab/pi-telegram/lib/voice.ts";
128
+
129
+ registerTelegramVoiceSynthesisProvider(async (text, options) => {
130
+ const rewritten = rewriteWithSpeechTags(text);
131
+ const audioPath = await myTTS(rewritten, { language: options?.lang });
132
+ const sendTranscript = getUserSendTranscriptPreference(); // from your UI + telegram.json
133
+ return sendTranscript ? { audioPath, transcriptText: text } : { audioPath };
134
+ });
135
+ ```
136
+
137
+ The bridge never sends a separate transcript message. Caption-only is the "ON" behavior.
138
+
139
+ ### Surfacing provider diagnostics
140
+
141
+ Voice provider extensions can record runtime events that appear in `/telegram-status` alongside pi-telegram's own events:
142
+
143
+ ```typescript
144
+ import { recordTelegramRuntimeEvent } from "@llblab/pi-telegram/lib/outbound-handlers.ts";
145
+
146
+ recordTelegramRuntimeEvent("xai-voice", new Error("TTS failed"), {
147
+ phase: "tts",
148
+ text: text.slice(0, 50),
149
+ });
150
+ ```
151
+
152
+ `recordTelegramRuntimeEvent` writes to the same event ring that pi-telegram uses. Events are visible via `/telegram-status` in Telegram. Calls are silently dropped if pi-telegram is not loaded.
153
+
154
+ ## Voice Extension Section
155
+
156
+ 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, transcript behavior, or STT/TTS enablement. Reply mode is a core pi-telegram setting and belongs in the built-in Settings menu.
157
+
158
+ **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` in `lib/extension-sections.ts`.
159
+
160
+ ## Prompt Guidance
161
+
162
+ The bridge keeps voice prompt context compact and policy-owned. It adds `[voice] reply mode: ...` only when `telegram.json` explicitly contains a valid `voice.replyMode`. `hidden`/no configured mode behaves like manual, but prompts stay silent. When explicit, voice-originated `manual` turns add `[voice] reply mode: manual`, voice-originated `mirror` turns add `[voice] reply mode: mirror`, and `always` mode adds `[voice] reply mode: always` for every turn. If voice context later contains multiple fields, the bridge renders it as a `[voice]` 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.
163
+
164
+ 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.
165
+
166
+ ## Fallback Behavior
167
+
168
+ ### If voice generation fails
169
+
170
+ 1. The bridge records the failure via `recordRuntimeEvent`
171
+ 2. The voice sender throws an error, which the runtime catches
172
+ 3. The runtime falls back to sending the planned text reply (outbound markup stripped, `replyMarkup` preserved)
173
+
174
+ ### If no voice synthesis provider is registered
175
+
176
+ - The voice sender throws because no configured handler or synthesis provider can deliver the voice reply
177
+ - The runtime catches the error and falls back to text delivery
178
+
179
+ ### If the provider returns a non-OGG file
180
+
181
+ - `ensureTelegramVoiceFileFormat` rejects the file (only `.ogg` and `.opus` are accepted)
182
+ - The voice sender throws and the runtime falls back to text delivery
183
+ - The provider should handle format conversion internally before returning the path
184
+
185
+ ## Telegram Voice Limits
186
+
187
+ - **Duration:** Up to ~60 minutes per voice message
188
+ - **File size:** Up to 20 MB for voice uploads via `sendVoice`
189
+ - **Format:** OGG Opus is native; MP3 and other formats render as regular audio attachments
190
+ - **Splitting:** The bridge does not split long responses into multiple voice messages. Chunking is the provider's responsibility
191
+
192
+ ## Configuration
193
+
194
+ ### Bridge config (`telegram.json`)
195
+
196
+ ```json
197
+ {
198
+ "voice": {
199
+ "replyMode": "manual"
200
+ }
201
+ }
202
+ ```
203
+
204
+ Valid stored values: `"manual"`, `"mirror"`, `"always"`. Missing or invalid values are shown in Settings as `hidden`, behave like manual, and stay silent in prompt context.
205
+
206
+ The bridge reads `voice.replyMode` from the config when building a turn.
207
+
208
+ ### Provider config
209
+
210
+ Provider-specific settings (voice ID, language, speech style, transcript behavior, 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.
package/index.ts CHANGED
@@ -11,6 +11,7 @@ import * as Config from "./lib/config.ts";
11
11
  import {
12
12
  createTelegramExtensionSectionRegistry,
13
13
  setGlobalTelegramSectionRegistry,
14
+ registerTelegramSection,
14
15
  type TelegramSectionRegistry,
15
16
  } from "./lib/extension-sections.ts";
16
17
  import { createTelegramExternalHandleUpdate } from "./lib/external-handlers.ts";
@@ -37,10 +38,50 @@ import * as Runtime from "./lib/runtime.ts";
37
38
  import * as Setup from "./lib/setup.ts";
38
39
  import * as Status from "./lib/status.ts";
39
40
  import * as TextGroups from "./lib/text-groups.ts";
41
+ import * as TimeInjection from "./lib/time-injection.ts";
42
+ import * as Voice from "./lib/voice.ts";
43
+
44
+ const VOICE_EVENT_RECORDER_KEY = "__piTelegramVoiceEventRecorder__";
40
45
 
41
46
  type ActivePiModel = NonNullable<Pi.ExtensionContext["model"]>;
42
47
  type RuntimeTelegramQueueItem = Queue.TelegramQueueItem<Pi.ExtensionContext>;
43
48
 
49
+ export {
50
+ registerTelegramOutboundHandler,
51
+ hasTelegramOutboundHandler,
52
+ getTelegramOutboundProgrammaticHandlers,
53
+ recordTelegramRuntimeEvent,
54
+ } from "./lib/outbound-handlers.ts";
55
+
56
+ // --- Voice Integration Exports ---
57
+ // Prefer domain imports from ./lib/voice.ts; root exports stay for compatibility.
58
+ export {
59
+ registerTelegramVoiceSynthesisProvider,
60
+ getTelegramVoiceSynthesisProviders,
61
+ hasTelegramVoiceSynthesisProvider,
62
+ clearTelegramVoiceSynthesisProviders,
63
+ planTelegramVoiceReply,
64
+ getTelegramVoiceReplyMode,
65
+ computeVoiceTurnFlags,
66
+ isVoiceTurn,
67
+ shouldSuppressPreviewForVoice,
68
+ computeVoicePromptContribution,
69
+ type TelegramVoiceSynthesisProvider,
70
+ type TelegramVoiceTurnView,
71
+ type TelegramVoiceSynthesisProviderResult,
72
+ type TelegramVoiceReplyMode,
73
+ } from "./lib/voice.ts";
74
+
75
+ // --- Extension Section Exports ---
76
+ export {
77
+ registerTelegramSection,
78
+ type TelegramSectionRegistration,
79
+ type TelegramSectionContext,
80
+ type TelegramSectionCallbackContext,
81
+ type TelegramSectionView,
82
+ type TelegramSectionSettingsRegistration,
83
+ } from "./lib/extension-sections.ts";
84
+
44
85
  // --- Extension Runtime ---
45
86
 
46
87
  export default function (pi: Pi.ExtensionAPI) {
@@ -55,10 +96,28 @@ export default function (pi: Pi.ExtensionAPI) {
55
96
  const bridgeRuntime = Runtime.createTelegramBridgeRuntime();
56
97
  const { abort, lifecycle, queue, setup, typing } = bridgeRuntime;
57
98
  const configStore = Config.createTelegramConfigStore();
99
+ Config.setGlobalTelegramConfigRuntime({
100
+ updateVoiceConfig(voice) {
101
+ const current = configStore.get();
102
+ const next = { ...current, voice: { ...(current.voice ?? {}), ...voice } };
103
+ configStore.set(next);
104
+ void configStore.persist(next);
105
+ },
106
+ });
58
107
  const isProactivePushEnabled =
59
108
  Config.createTelegramProactivePushChecker(configStore);
60
109
  const setProactivePushEnabled =
61
110
  Config.createTelegramProactivePushSetter(configStore);
111
+ const getVoiceReplyMode =
112
+ Config.createTelegramVoiceReplyModeGetter(configStore);
113
+ const isVoiceReplyModeConfigured =
114
+ Config.createTelegramVoiceReplyModeConfiguredChecker(configStore);
115
+ const setVoiceReplyMode =
116
+ Config.createTelegramVoiceReplyModeSetter(configStore);
117
+ const getTimeInjectionMode =
118
+ Config.createTelegramTimeInjectionModeGetter(configStore);
119
+ const setTimeInjectionMode =
120
+ Config.createTelegramTimeInjectionModeSetter(configStore);
62
121
  const lockRuntime = Locks.createTelegramLockRuntime<Pi.ExtensionContext>();
63
122
  const lockOwnershipGuard =
64
123
  Locks.createTelegramLockOwnershipGuard(lockRuntime);
@@ -77,10 +136,19 @@ export default function (pi: Pi.ExtensionAPI) {
77
136
  const sectionRegistry: TelegramSectionRegistry =
78
137
  createTelegramExtensionSectionRegistry();
79
138
  setGlobalTelegramSectionRegistry(sectionRegistry);
139
+
140
+
80
141
  const runtimeEvents = Status.createTelegramRuntimeEventRecorder({
81
142
  getBotToken: configStore.getBotToken,
82
143
  });
83
144
  const recordRuntimeEvent = runtimeEvents.record;
145
+ const timeInjectionRuntime = TimeInjection.createTimeInjectionRuntime({
146
+ getConfig: Config.createTelegramTimeConfigGetter(configStore),
147
+ recordRuntimeEvent,
148
+ });
149
+ (globalThis as Record<string, unknown>)[
150
+ VOICE_EVENT_RECORDER_KEY
151
+ ] = recordRuntimeEvent;
84
152
  const getContextModel = Pi.getExtensionContextModel;
85
153
  const isIdle = Pi.isExtensionContextIdle;
86
154
  const hasPendingMessages = Pi.hasExtensionContextPendingMessages;
@@ -151,6 +219,8 @@ export default function (pi: Pi.ExtensionAPI) {
151
219
  getUpdates,
152
220
  setMyCommands,
153
221
  sendTypingAction,
222
+ sendChatAction,
223
+ sendRecordVoiceAction,
154
224
  sendMessageDraft,
155
225
  sendMessage,
156
226
  downloadFile: downloadTelegramBridgeFile,
@@ -287,6 +357,12 @@ export default function (pi: Pi.ExtensionAPI) {
287
357
  editInteractiveMessage,
288
358
  sendInteractiveMessage,
289
359
  sectionRegistry,
360
+
361
+ // Used by the menu/status system to know whether the current turn is a voice reply
362
+ isVoiceReplyActive: function () {
363
+ const turn = activeTurnRuntime.get();
364
+ return Voice.isVoiceTurn(turn);
365
+ },
290
366
  });
291
367
 
292
368
  // --- Queue Menu ---
@@ -317,7 +393,12 @@ export default function (pi: Pi.ExtensionAPI) {
317
393
  sendInteractiveMessage,
318
394
  answerCallbackQuery,
319
395
  isProactivePushEnabled,
396
+ getVoiceReplyMode,
397
+ isVoiceReplyModeConfigured,
398
+ getTimeInjectionMode,
320
399
  setProactivePushEnabled,
400
+ setVoiceReplyMode,
401
+ setTimeInjectionMode,
321
402
  },
322
403
  sectionRegistry,
323
404
  );
@@ -365,6 +446,7 @@ export default function (pi: Pi.ExtensionAPI) {
365
446
  setMyCommands,
366
447
  getCommands,
367
448
  downloadFile: downloadTelegramBridgeFile,
449
+ resolveTimeLine: timeInjectionRuntime.resolveLine,
368
450
  getThinkingLevel,
369
451
  setThinkingLevel,
370
452
  persistScopedModelPatterns: Pi.createScopedModelPatternPersister({
@@ -433,7 +515,9 @@ export default function (pi: Pi.ExtensionAPI) {
433
515
  });
434
516
  const sessionLifecycleRuntime = Lifecycle.appendTelegramLifecycleHooks(
435
517
  queueSessionLifecycle,
436
- { onSessionStart: lockedPollingRuntime.onSessionStart },
518
+ {
519
+ onSessionStart: lockedPollingRuntime.onSessionStart,
520
+ },
437
521
  );
438
522
 
439
523
  // --- Extension API Bindings ---
@@ -485,6 +569,8 @@ export default function (pi: Pi.ExtensionAPI) {
485
569
  execCommand: CommandTemplates.execCommandTemplate,
486
570
  sendMultipart: callMultipart,
487
571
  sendTextReply,
572
+ sendChatAction,
573
+ sendRecordVoiceAction,
488
574
  getHandlers: configStore.getOutboundHandlers,
489
575
  recordRuntimeEvent,
490
576
  });
package/lib/api.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * Telegram API transport helpers
3
3
  * Zones: telegram transport, filesystem, runtime diagnostics
4
- * Wraps bot API calls, file downloads, runtime transport binding, and Telegram temp-file cleanup
4
+ *
5
+ * Wraps bot API calls, file uploads/downloads (including voice messages),
6
+ * multipart sending, runtime transport binding, and Telegram temp-file lifecycle.
5
7
  */
6
8
 
7
9
  import { randomUUID } from "node:crypto";
@@ -298,8 +300,9 @@ export interface TelegramBridgeApiRuntime {
298
300
  setMyCommands: (
299
301
  commands: readonly { command: string; description: string }[],
300
302
  ) => Promise<boolean>;
301
- sendChatAction: (chatId: number, action: "typing") => Promise<boolean>;
303
+ sendChatAction: (chatId: number, action: string) => Promise<boolean>;
302
304
  sendTypingAction: (chatId: number) => Promise<unknown>;
305
+ sendRecordVoiceAction: (chatId: number) => Promise<unknown>;
303
306
  sendMessageDraft: (
304
307
  chatId: number,
305
308
  draftId: number,
@@ -577,6 +580,12 @@ export async function fetchTelegramBotIdentity(
577
580
  return response.json() as Promise<TelegramBotIdentityResponse>;
578
581
  }
579
582
 
583
+ /**
584
+ * Low-level helper to send a multipart/form-data request to the Telegram Bot API.
585
+ * This is the core implementation used for uploading voice messages, photos,
586
+ * documents, animations, etc. It handles FormData construction, retry logic
587
+ * (via callTelegramWithRetry), and error recording under the "multipart" category.
588
+ */
580
589
  export async function callTelegramMultipart<TResponse>(
581
590
  botToken: string | undefined,
582
591
  method: string,
@@ -721,6 +730,12 @@ export function createTelegramBridgeApiRuntime(
721
730
  };
722
731
  return {
723
732
  call: callRecorded,
733
+
734
+ /**
735
+ * Sends a multipart/form-data request (used for sending voice messages,
736
+ * photos, documents, animations, etc.).
737
+ * Errors are recorded under the "multipart" category for diagnostics.
738
+ */
724
739
  callMultipart: async (
725
740
  method,
726
741
  fields,
@@ -743,6 +758,11 @@ export function createTelegramBridgeApiRuntime(
743
758
  throw error;
744
759
  }
745
760
  },
761
+
762
+ /**
763
+ * Downloads a file from the Telegram servers into the local temp directory.
764
+ * Used for inbound voice messages, photos, documents, etc.
765
+ */
746
766
  downloadFile: async (fileId, suggestedName) => {
747
767
  try {
748
768
  return await deps.client.downloadFile(
@@ -781,6 +801,14 @@ export function createTelegramBridgeApiRuntime(
781
801
  }),
782
802
  "typing",
783
803
  ),
804
+ sendRecordVoiceAction: createTelegramChatActionSender(
805
+ (chatId, action) =>
806
+ callRecorded<boolean>("sendChatAction", {
807
+ chat_id: chatId,
808
+ action,
809
+ }),
810
+ "record_voice",
811
+ ),
784
812
  sendMessageDraft: (chatId, draftId, text, options) => {
785
813
  const body: Record<string, unknown> = {
786
814
  chat_id: chatId,
@@ -841,6 +869,11 @@ export function createTelegramBridgeApiRuntime(
841
869
  };
842
870
  }
843
871
 
872
+ /**
873
+ * Creates a low-level Telegram Bot API client.
874
+ * This is the main entry point for all direct Bot API communication
875
+ * (both JSON calls and multipart uploads for files/voice).
876
+ */
844
877
  export function createTelegramApiClient(
845
878
  getBotToken: () => string | undefined,
846
879
  ): TelegramApiClient {