@llblab/pi-telegram 0.10.7 → 0.11.0
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 +10 -6
- package/BACKLOG.md +5 -0
- package/CHANGELOG.md +26 -1
- package/README.md +48 -24
- package/docs/README.md +3 -2
- package/docs/architecture.md +3 -3
- package/docs/extension-sections.md +6 -4
- package/docs/inbound-handlers.md +41 -1
- package/docs/outbound-handlers.md +32 -49
- package/docs/voice.md +210 -0
- package/index.ts +77 -1
- package/lib/api.ts +35 -2
- package/lib/commands.ts +18 -0
- package/lib/config.ts +70 -0
- package/lib/extension-sections.ts +39 -20
- package/lib/external-handlers.ts +3 -4
- package/lib/inbound-handlers.ts +197 -0
- package/lib/media.ts +3 -0
- package/lib/menu-settings.ts +134 -8
- package/lib/menu-status.ts +17 -3
- package/lib/menu-thinking.ts +12 -1
- package/lib/menu.ts +10 -1
- package/lib/outbound-handlers.ts +719 -277
- package/lib/preview.ts +9 -0
- package/lib/prompts.ts +2 -1
- package/lib/queue.ts +84 -5
- package/lib/routing.ts +14 -1
- package/lib/turns.ts +84 -20
- package/lib/voice.ts +295 -0
- package/package.json +1 -1
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,49 @@ 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 Voice from "./lib/voice.ts";
|
|
42
|
+
|
|
43
|
+
const VOICE_EVENT_RECORDER_KEY = "__piTelegramVoiceEventRecorder__";
|
|
40
44
|
|
|
41
45
|
type ActivePiModel = NonNullable<Pi.ExtensionContext["model"]>;
|
|
42
46
|
type RuntimeTelegramQueueItem = Queue.TelegramQueueItem<Pi.ExtensionContext>;
|
|
43
47
|
|
|
48
|
+
export {
|
|
49
|
+
registerTelegramOutboundHandler,
|
|
50
|
+
hasTelegramOutboundHandler,
|
|
51
|
+
getTelegramOutboundProgrammaticHandlers,
|
|
52
|
+
recordTelegramRuntimeEvent,
|
|
53
|
+
} from "./lib/outbound-handlers.ts";
|
|
54
|
+
|
|
55
|
+
// --- Voice Integration Exports ---
|
|
56
|
+
// Prefer domain imports from ./lib/voice.ts; root exports stay for compatibility.
|
|
57
|
+
export {
|
|
58
|
+
registerTelegramVoiceSynthesisProvider,
|
|
59
|
+
getTelegramVoiceSynthesisProviders,
|
|
60
|
+
hasTelegramVoiceSynthesisProvider,
|
|
61
|
+
clearTelegramVoiceSynthesisProviders,
|
|
62
|
+
planTelegramVoiceReply,
|
|
63
|
+
getTelegramVoiceReplyMode,
|
|
64
|
+
computeVoiceTurnFlags,
|
|
65
|
+
isVoiceTurn,
|
|
66
|
+
shouldSuppressPreviewForVoice,
|
|
67
|
+
computeVoicePromptContribution,
|
|
68
|
+
type TelegramVoiceSynthesisProvider,
|
|
69
|
+
type TelegramVoiceTurnView,
|
|
70
|
+
type TelegramVoiceSynthesisProviderResult,
|
|
71
|
+
type TelegramVoiceReplyMode,
|
|
72
|
+
} from "./lib/voice.ts";
|
|
73
|
+
|
|
74
|
+
// --- Extension Section Exports ---
|
|
75
|
+
export {
|
|
76
|
+
registerTelegramSection,
|
|
77
|
+
type TelegramSectionRegistration,
|
|
78
|
+
type TelegramSectionContext,
|
|
79
|
+
type TelegramSectionCallbackContext,
|
|
80
|
+
type TelegramSectionView,
|
|
81
|
+
type TelegramSectionSettingsRegistration,
|
|
82
|
+
} from "./lib/extension-sections.ts";
|
|
83
|
+
|
|
44
84
|
// --- Extension Runtime ---
|
|
45
85
|
|
|
46
86
|
export default function (pi: Pi.ExtensionAPI) {
|
|
@@ -55,10 +95,24 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
55
95
|
const bridgeRuntime = Runtime.createTelegramBridgeRuntime();
|
|
56
96
|
const { abort, lifecycle, queue, setup, typing } = bridgeRuntime;
|
|
57
97
|
const configStore = Config.createTelegramConfigStore();
|
|
98
|
+
Config.setGlobalTelegramConfigRuntime({
|
|
99
|
+
updateVoiceConfig(voice) {
|
|
100
|
+
const current = configStore.get();
|
|
101
|
+
const next = { ...current, voice: { ...(current.voice ?? {}), ...voice } };
|
|
102
|
+
configStore.set(next);
|
|
103
|
+
void configStore.persist(next);
|
|
104
|
+
},
|
|
105
|
+
});
|
|
58
106
|
const isProactivePushEnabled =
|
|
59
107
|
Config.createTelegramProactivePushChecker(configStore);
|
|
60
108
|
const setProactivePushEnabled =
|
|
61
109
|
Config.createTelegramProactivePushSetter(configStore);
|
|
110
|
+
const getVoiceReplyMode =
|
|
111
|
+
Config.createTelegramVoiceReplyModeGetter(configStore);
|
|
112
|
+
const isVoiceReplyModeConfigured =
|
|
113
|
+
Config.createTelegramVoiceReplyModeConfiguredChecker(configStore);
|
|
114
|
+
const setVoiceReplyMode =
|
|
115
|
+
Config.createTelegramVoiceReplyModeSetter(configStore);
|
|
62
116
|
const lockRuntime = Locks.createTelegramLockRuntime<Pi.ExtensionContext>();
|
|
63
117
|
const lockOwnershipGuard =
|
|
64
118
|
Locks.createTelegramLockOwnershipGuard(lockRuntime);
|
|
@@ -77,10 +131,15 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
77
131
|
const sectionRegistry: TelegramSectionRegistry =
|
|
78
132
|
createTelegramExtensionSectionRegistry();
|
|
79
133
|
setGlobalTelegramSectionRegistry(sectionRegistry);
|
|
134
|
+
|
|
135
|
+
|
|
80
136
|
const runtimeEvents = Status.createTelegramRuntimeEventRecorder({
|
|
81
137
|
getBotToken: configStore.getBotToken,
|
|
82
138
|
});
|
|
83
139
|
const recordRuntimeEvent = runtimeEvents.record;
|
|
140
|
+
(globalThis as Record<string, unknown>)[
|
|
141
|
+
VOICE_EVENT_RECORDER_KEY
|
|
142
|
+
] = recordRuntimeEvent;
|
|
84
143
|
const getContextModel = Pi.getExtensionContextModel;
|
|
85
144
|
const isIdle = Pi.isExtensionContextIdle;
|
|
86
145
|
const hasPendingMessages = Pi.hasExtensionContextPendingMessages;
|
|
@@ -151,6 +210,8 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
151
210
|
getUpdates,
|
|
152
211
|
setMyCommands,
|
|
153
212
|
sendTypingAction,
|
|
213
|
+
sendChatAction,
|
|
214
|
+
sendRecordVoiceAction,
|
|
154
215
|
sendMessageDraft,
|
|
155
216
|
sendMessage,
|
|
156
217
|
downloadFile: downloadTelegramBridgeFile,
|
|
@@ -287,6 +348,12 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
287
348
|
editInteractiveMessage,
|
|
288
349
|
sendInteractiveMessage,
|
|
289
350
|
sectionRegistry,
|
|
351
|
+
|
|
352
|
+
// Used by the menu/status system to know whether the current turn is a voice reply
|
|
353
|
+
isVoiceReplyActive: function () {
|
|
354
|
+
const turn = activeTurnRuntime.get();
|
|
355
|
+
return Voice.isVoiceTurn(turn);
|
|
356
|
+
},
|
|
290
357
|
});
|
|
291
358
|
|
|
292
359
|
// --- Queue Menu ---
|
|
@@ -317,7 +384,10 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
317
384
|
sendInteractiveMessage,
|
|
318
385
|
answerCallbackQuery,
|
|
319
386
|
isProactivePushEnabled,
|
|
387
|
+
getVoiceReplyMode,
|
|
388
|
+
isVoiceReplyModeConfigured,
|
|
320
389
|
setProactivePushEnabled,
|
|
390
|
+
setVoiceReplyMode,
|
|
321
391
|
},
|
|
322
392
|
sectionRegistry,
|
|
323
393
|
);
|
|
@@ -354,6 +424,8 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
354
424
|
dispatchNextQueuedTelegramTurn,
|
|
355
425
|
requestDeferredDispatchNextQueuedTelegramTurn:
|
|
356
426
|
deferredQueueDispatchRuntime.request,
|
|
427
|
+
startTypingLoop: promptDispatchRuntime.startTypingLoop,
|
|
428
|
+
stopTypingLoop: typing.stop,
|
|
357
429
|
answerCallbackQuery,
|
|
358
430
|
editInteractiveMessage,
|
|
359
431
|
sendInteractiveMessage,
|
|
@@ -431,7 +503,9 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
431
503
|
});
|
|
432
504
|
const sessionLifecycleRuntime = Lifecycle.appendTelegramLifecycleHooks(
|
|
433
505
|
queueSessionLifecycle,
|
|
434
|
-
{
|
|
506
|
+
{
|
|
507
|
+
onSessionStart: lockedPollingRuntime.onSessionStart,
|
|
508
|
+
},
|
|
435
509
|
);
|
|
436
510
|
|
|
437
511
|
// --- Extension API Bindings ---
|
|
@@ -483,6 +557,8 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
483
557
|
execCommand: CommandTemplates.execCommandTemplate,
|
|
484
558
|
sendMultipart: callMultipart,
|
|
485
559
|
sendTextReply,
|
|
560
|
+
sendChatAction,
|
|
561
|
+
sendRecordVoiceAction,
|
|
486
562
|
getHandlers: configStore.getOutboundHandlers,
|
|
487
563
|
recordRuntimeEvent,
|
|
488
564
|
});
|
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
|
-
*
|
|
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:
|
|
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 {
|
package/lib/commands.ts
CHANGED
|
@@ -320,6 +320,8 @@ export interface TelegramCompactCommandDeps extends TelegramRuntimeEventRecorder
|
|
|
320
320
|
requestDeferredDispatchNextQueuedTelegramTurn?: (
|
|
321
321
|
dispatch: () => void,
|
|
322
322
|
) => void;
|
|
323
|
+
startTypingLoop?: () => void;
|
|
324
|
+
stopTypingLoop?: () => void;
|
|
323
325
|
compact: (callbacks: {
|
|
324
326
|
onComplete: () => void;
|
|
325
327
|
onError: (error: unknown) => void;
|
|
@@ -553,6 +555,8 @@ export interface TelegramCommandRuntimeDeps<
|
|
|
553
555
|
requestDeferredDispatchNextQueuedTelegramTurn?: (
|
|
554
556
|
dispatch: (ctx: TContext) => void,
|
|
555
557
|
) => void;
|
|
558
|
+
startTypingLoop?: (ctx: TContext, chatId?: number) => void;
|
|
559
|
+
stopTypingLoop?: () => void;
|
|
556
560
|
enqueueContinueTurn: (message: TMessage, ctx: TContext) => Promise<void>;
|
|
557
561
|
compact: (
|
|
558
562
|
ctx: TContext,
|
|
@@ -798,15 +802,20 @@ export async function handleTelegramCompactCommand(
|
|
|
798
802
|
}
|
|
799
803
|
deps.setCompactionInProgress(true);
|
|
800
804
|
deps.updateStatus();
|
|
805
|
+
let compactionStillInProgress = true;
|
|
801
806
|
try {
|
|
802
807
|
deps.compact({
|
|
803
808
|
onComplete: () => {
|
|
809
|
+
compactionStillInProgress = false;
|
|
810
|
+
deps.stopTypingLoop?.();
|
|
804
811
|
deps.setCompactionInProgress(false);
|
|
805
812
|
deps.updateStatus();
|
|
806
813
|
dispatchNextQueuedTelegramTurnAfterCompact(deps);
|
|
807
814
|
void deps.sendTextReply("Compaction completed.");
|
|
808
815
|
},
|
|
809
816
|
onError: (error) => {
|
|
817
|
+
compactionStillInProgress = false;
|
|
818
|
+
deps.stopTypingLoop?.();
|
|
810
819
|
deps.setCompactionInProgress(false);
|
|
811
820
|
deps.updateStatus();
|
|
812
821
|
dispatchNextQueuedTelegramTurnAfterCompact(deps);
|
|
@@ -816,6 +825,8 @@ export async function handleTelegramCompactCommand(
|
|
|
816
825
|
},
|
|
817
826
|
});
|
|
818
827
|
} catch (error) {
|
|
828
|
+
compactionStillInProgress = false;
|
|
829
|
+
deps.stopTypingLoop?.();
|
|
819
830
|
deps.setCompactionInProgress(false);
|
|
820
831
|
deps.updateStatus();
|
|
821
832
|
deps.recordRuntimeEvent?.("compact", error);
|
|
@@ -824,6 +835,7 @@ export async function handleTelegramCompactCommand(
|
|
|
824
835
|
return;
|
|
825
836
|
}
|
|
826
837
|
await deps.sendTextReply("Compaction started.");
|
|
838
|
+
if (compactionStillInProgress) deps.startTypingLoop?.();
|
|
827
839
|
}
|
|
828
840
|
|
|
829
841
|
function isTelegramStaleContextError(error: unknown): boolean {
|
|
@@ -962,6 +974,8 @@ export function createTelegramCommandHandlerTargetRuntime<
|
|
|
962
974
|
setCompactionInProgress: deps.setCompactionInProgress,
|
|
963
975
|
updateStatus: deps.updateStatus,
|
|
964
976
|
dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
|
|
977
|
+
startTypingLoop: deps.startTypingLoop,
|
|
978
|
+
stopTypingLoop: deps.stopTypingLoop,
|
|
965
979
|
enqueueContinueTurn: deps.enqueueContinueTurn,
|
|
966
980
|
compact: deps.compact,
|
|
967
981
|
enqueueControlItem: commandTargetRuntime.enqueueControlItem,
|
|
@@ -1115,6 +1129,10 @@ async function handleTelegramCommandRuntime<
|
|
|
1115
1129
|
)
|
|
1116
1130
|
: undefined,
|
|
1117
1131
|
compact: (callbacks) => deps.compact(commandCtx, callbacks),
|
|
1132
|
+
startTypingLoop: deps.startTypingLoop
|
|
1133
|
+
? () => deps.startTypingLoop?.(commandCtx, nextMessage.chat.id)
|
|
1134
|
+
: undefined,
|
|
1135
|
+
stopTypingLoop: deps.stopTypingLoop,
|
|
1118
1136
|
sendTextReply: sendReplyFor(nextMessage),
|
|
1119
1137
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
1120
1138
|
});
|
package/lib/config.ts
CHANGED
|
@@ -12,6 +12,8 @@ import { join, resolve } from "node:path";
|
|
|
12
12
|
import type { TelegramInboundHandlerConfig } from "./inbound-handlers.ts";
|
|
13
13
|
import type { CommandTemplateObjectConfig } from "./command-templates.ts";
|
|
14
14
|
|
|
15
|
+
const CONFIG_RUNTIME_KEY = "__piTelegramConfigRuntime__";
|
|
16
|
+
|
|
15
17
|
function getAgentDir(): string {
|
|
16
18
|
return process.env.PI_CODING_AGENT_DIR
|
|
17
19
|
? resolve(process.env.PI_CODING_AGENT_DIR)
|
|
@@ -43,6 +45,11 @@ export interface TelegramConfig {
|
|
|
43
45
|
attachmentHandlers?: TelegramInboundHandlerConfig[];
|
|
44
46
|
outboundHandlers?: TelegramOutboundHandlerConfig[];
|
|
45
47
|
proactivePush?: boolean;
|
|
48
|
+
voice?: {
|
|
49
|
+
replyMode?: "manual" | "mirror" | "always";
|
|
50
|
+
/** Whether to attach the provider's transcriptText as caption on voice messages */
|
|
51
|
+
sendTranscript?: boolean;
|
|
52
|
+
};
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
export interface TelegramConfigStore {
|
|
@@ -66,6 +73,29 @@ export interface TelegramConfigStoreOptions {
|
|
|
66
73
|
configPath?: string;
|
|
67
74
|
}
|
|
68
75
|
|
|
76
|
+
export interface TelegramConfigRuntime {
|
|
77
|
+
updateVoiceConfig: (voice: NonNullable<TelegramConfig["voice"]>) => void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function setGlobalTelegramConfigRuntime(
|
|
81
|
+
runtime: TelegramConfigRuntime | undefined,
|
|
82
|
+
): void {
|
|
83
|
+
const globals = globalThis as Record<string, unknown>;
|
|
84
|
+
if (runtime) globals[CONFIG_RUNTIME_KEY] = runtime;
|
|
85
|
+
else delete globals[CONFIG_RUNTIME_KEY];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function updateTelegramVoiceConfig(
|
|
89
|
+
voice: NonNullable<TelegramConfig["voice"]>,
|
|
90
|
+
): boolean {
|
|
91
|
+
const runtime = (globalThis as Record<string, unknown>)[
|
|
92
|
+
CONFIG_RUNTIME_KEY
|
|
93
|
+
] as TelegramConfigRuntime | undefined;
|
|
94
|
+
if (!runtime || typeof runtime.updateVoiceConfig !== "function") return false;
|
|
95
|
+
runtime.updateVoiceConfig(voice);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
69
99
|
export async function readTelegramConfig(
|
|
70
100
|
configPath: string,
|
|
71
101
|
): Promise<TelegramConfig> {
|
|
@@ -141,6 +171,46 @@ export function createTelegramProactivePushSetter(
|
|
|
141
171
|
};
|
|
142
172
|
}
|
|
143
173
|
|
|
174
|
+
export function createTelegramVoiceReplyModeGetter(
|
|
175
|
+
configStore: Pick<TelegramConfigStore, "get">,
|
|
176
|
+
): () => "manual" | "mirror" | "always" {
|
|
177
|
+
return () => {
|
|
178
|
+
const mode = configStore.get().voice?.replyMode;
|
|
179
|
+
return mode === "mirror" || mode === "always" || mode === "manual"
|
|
180
|
+
? mode
|
|
181
|
+
: "manual";
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function createTelegramVoiceReplyModeConfiguredChecker(
|
|
186
|
+
configStore: Pick<TelegramConfigStore, "get">,
|
|
187
|
+
): () => boolean {
|
|
188
|
+
return () => {
|
|
189
|
+
const mode = configStore.get().voice?.replyMode;
|
|
190
|
+
return mode === "mirror" || mode === "always" || mode === "manual";
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function createTelegramVoiceReplyModeSetter(
|
|
195
|
+
configStore: Pick<TelegramConfigStore, "get" | "set" | "persist">,
|
|
196
|
+
): (replyMode: "manual" | "mirror" | "always" | undefined) => Promise<void> {
|
|
197
|
+
return async (replyMode) => {
|
|
198
|
+
const current = configStore.get();
|
|
199
|
+
if (replyMode === undefined) {
|
|
200
|
+
const { replyMode: _replyMode, ...remainingVoice } = current.voice ?? {};
|
|
201
|
+
const next = { ...current };
|
|
202
|
+
if (Object.keys(remainingVoice).length > 0) next.voice = remainingVoice;
|
|
203
|
+
else delete next.voice;
|
|
204
|
+
configStore.set(next);
|
|
205
|
+
await configStore.persist(next);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const next = { ...current, voice: { ...(current.voice ?? {}), replyMode } };
|
|
209
|
+
configStore.set(next);
|
|
210
|
+
await configStore.persist(next);
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
144
214
|
export function createTelegramProactivePushChatIdGetter(deps: {
|
|
145
215
|
getActiveTurnChatId: () => number | undefined;
|
|
146
216
|
getAllowedUserId: () => number | undefined;
|