@llblab/pi-telegram 0.11.2 → 0.13.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 +20 -15
- package/BACKLOG.md +1 -11
- package/CHANGELOG.md +41 -1
- package/README.md +15 -41
- package/api/inbound.ts +14 -0
- package/api/keyboard.ts +10 -0
- package/api/outbound.ts +11 -0
- package/api/sections.ts +17 -0
- package/api/updates.ts +11 -0
- package/api/voice.ts +24 -0
- package/docs/README.md +7 -5
- package/docs/architecture.md +162 -226
- package/docs/callback-namespaces.md +3 -3
- package/docs/command-templates.md +18 -16
- package/docs/{inbound-handlers.md → inbound.md} +14 -11
- package/docs/locks.md +3 -3
- package/docs/{outbound-handlers.md → outbound.md} +14 -11
- package/docs/public-api.md +420 -0
- package/docs/{extension-sections.md → sections.md} +34 -30
- package/docs/ui-style.md +165 -0
- package/docs/{external-handlers.md → updates.md} +33 -31
- package/docs/voice.md +27 -19
- package/index.ts +88 -242
- package/lib/bindings.ts +299 -0
- package/lib/command-templates.ts +249 -60
- package/lib/commands.ts +114 -1
- package/lib/config.ts +44 -4
- package/lib/{inbound-handlers.ts → inbound.ts} +31 -21
- package/lib/lifecycle.ts +41 -6
- package/lib/locks.ts +4 -1
- package/lib/menu-model.ts +3 -3
- package/lib/menu-queue.ts +1 -1
- package/lib/menu-settings.ts +21 -10
- package/lib/menu-status.ts +1 -1
- package/lib/menu.ts +1 -1
- package/lib/outbound-buttons.ts +226 -0
- package/lib/outbound-markup.ts +357 -0
- package/lib/outbound-voice.ts +263 -0
- package/lib/outbound.ts +908 -0
- package/lib/polling.ts +4 -3
- package/lib/preview.ts +2 -2
- package/lib/queue.ts +3 -0
- package/lib/replies.ts +4 -1
- package/lib/routing.ts +44 -3
- package/lib/{extension-sections.ts → sections.ts} +37 -8
- package/lib/status.ts +13 -0
- package/lib/{api.ts → telegram-api.ts} +4 -4
- package/lib/text-groups.ts +3 -2
- package/lib/updates.ts +121 -1
- package/lib/voice.ts +67 -21
- package/package.json +13 -3
- package/lib/external-handlers.ts +0 -166
- package/lib/outbound-handlers.ts +0 -1663
package/lib/polling.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Telegram polling domain helpers
|
|
2
|
+
* Telegram polling runtime domain helpers
|
|
3
3
|
* Zones: telegram transport, polling runtime
|
|
4
4
|
* Owns polling request builders, stop conditions, and the long-poll loop runtime for Telegram updates
|
|
5
5
|
*/
|
|
@@ -91,8 +91,9 @@ export function createTelegramPollingActivityReader(
|
|
|
91
91
|
return () => isTelegramPollingControllerActive(state);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
export interface TelegramPollingRuntimeDeps<
|
|
95
|
-
|
|
94
|
+
export interface TelegramPollingRuntimeDeps<
|
|
95
|
+
TContext,
|
|
96
|
+
> extends TelegramRuntimeEventRecorderPort {
|
|
96
97
|
hasBotToken: () => boolean;
|
|
97
98
|
getPollingPromise: () => Promise<void> | undefined;
|
|
98
99
|
setPollingPromise: (promise: Promise<void> | undefined) => void;
|
package/lib/preview.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type {
|
|
|
9
9
|
TelegramReplyParameters,
|
|
10
10
|
TelegramSendMessageBody,
|
|
11
11
|
TelegramSentMessage,
|
|
12
|
-
} from "./api.ts";
|
|
12
|
+
} from "./telegram-api.ts";
|
|
13
13
|
import {
|
|
14
14
|
buildTelegramPreviewSnapshot,
|
|
15
15
|
MAX_MESSAGE_LENGTH,
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
} from "./rendering.ts";
|
|
23
23
|
|
|
24
24
|
import { buildTelegramReplyParameters } from "./replies.ts";
|
|
25
|
-
import { stripTelegramCommentMarkupForPreview } from "./outbound
|
|
25
|
+
import { stripTelegramCommentMarkupForPreview } from "./outbound.ts";
|
|
26
26
|
import { shouldSuppressPreviewForVoice } from "./voice.ts";
|
|
27
27
|
|
|
28
28
|
const TELEGRAM_PREVIEW_THROTTLE_MS = 750;
|
package/lib/queue.ts
CHANGED
|
@@ -44,6 +44,9 @@ export interface TelegramQueueLaneContract {
|
|
|
44
44
|
|
|
45
45
|
export const TELEGRAM_QUEUE_LANE_CONTRACTS: readonly TelegramQueueLaneContract[] =
|
|
46
46
|
[
|
|
47
|
+
// Control lane intentionally accepts both direct controls and resume prompts.
|
|
48
|
+
// Model-switch continuations need prompt semantics but must run before queued user work.
|
|
49
|
+
// Do not admit ordinary user prompts here without an explicit control-flow reason.
|
|
47
50
|
{
|
|
48
51
|
lane: "control",
|
|
49
52
|
admissionMode: "control-queue",
|
package/lib/replies.ts
CHANGED
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
* Owns rendered-message delivery, reply transport wiring, and plain or markdown final replies
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
TelegramReplyParameters,
|
|
9
|
+
TelegramSentMessage,
|
|
10
|
+
} from "./telegram-api.ts";
|
|
8
11
|
import {
|
|
9
12
|
renderTelegramMessage,
|
|
10
13
|
type TelegramRenderedChunk,
|
package/lib/routing.ts
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
import { readFile } from "node:fs/promises";
|
|
8
8
|
import * as Commands from "./commands.ts";
|
|
9
9
|
import type { TelegramConfigStore } from "./config.ts";
|
|
10
|
-
import type { TelegramSectionRegistry } from "./
|
|
11
|
-
import type { TelegramInboundHandlerRuntime } from "./inbound
|
|
10
|
+
import type { TelegramSectionRegistry } from "./sections.ts";
|
|
11
|
+
import type { TelegramInboundHandlerRuntime } from "./inbound.ts";
|
|
12
12
|
import * as Media from "./media.ts";
|
|
13
13
|
import * as Menu from "./menu.ts";
|
|
14
14
|
import * as Model from "./model.ts";
|
|
15
|
-
import * as OutboundHandlers from "./outbound
|
|
15
|
+
import * as OutboundHandlers from "./outbound.ts";
|
|
16
16
|
import * as PromptTemplates from "./prompt-templates.ts";
|
|
17
17
|
import * as Queue from "./queue.ts";
|
|
18
18
|
import type { TelegramBridgeRuntime } from "./runtime.ts";
|
|
@@ -138,6 +138,7 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
const TELEGRAM_OWNED_CALLBACK_PREFIXES = [
|
|
141
|
+
"compact:",
|
|
141
142
|
"menu:",
|
|
142
143
|
"model:",
|
|
143
144
|
"queue:",
|
|
@@ -262,6 +263,45 @@ export function createTelegramInboundRouteRuntime<
|
|
|
262
263
|
);
|
|
263
264
|
if (handled) return;
|
|
264
265
|
}
|
|
266
|
+
const handledByCompact =
|
|
267
|
+
await Commands.handleTelegramCompactConfirmationCallback(query, {
|
|
268
|
+
ctx,
|
|
269
|
+
answerCallbackQuery: deps.answerCallbackQuery,
|
|
270
|
+
editInteractiveMessage: deps.editInteractiveMessage ?? (async () => {}),
|
|
271
|
+
runCompact: async (compactCtx, chatId, replyToMessageId) => {
|
|
272
|
+
await Commands.handleTelegramCompactCommand({
|
|
273
|
+
isIdle: () => deps.isIdle(compactCtx),
|
|
274
|
+
hasPendingMessages: () => deps.hasPendingMessages(compactCtx),
|
|
275
|
+
hasActiveTelegramTurn: deps.activeTurnRuntime.has,
|
|
276
|
+
hasDispatchPending: deps.bridgeRuntime.lifecycle.hasDispatchPending,
|
|
277
|
+
hasQueuedTelegramItems: deps.telegramQueueStore.hasQueuedItems,
|
|
278
|
+
isCompactionInProgress:
|
|
279
|
+
deps.bridgeRuntime.lifecycle.isCompactionInProgress,
|
|
280
|
+
setCompactionInProgress:
|
|
281
|
+
deps.bridgeRuntime.lifecycle.setCompactionInProgress,
|
|
282
|
+
updateStatus: () => deps.updateStatus(compactCtx),
|
|
283
|
+
dispatchNextQueuedTelegramTurn: () =>
|
|
284
|
+
deps.dispatchNextQueuedTelegramTurn(compactCtx),
|
|
285
|
+
requestDeferredDispatchNextQueuedTelegramTurn:
|
|
286
|
+
deps.requestDeferredDispatchNextQueuedTelegramTurn
|
|
287
|
+
? (dispatch) =>
|
|
288
|
+
deps.requestDeferredDispatchNextQueuedTelegramTurn?.(() =>
|
|
289
|
+
dispatch(),
|
|
290
|
+
)
|
|
291
|
+
: undefined,
|
|
292
|
+
compact: (callbacks) => deps.compact(compactCtx, callbacks),
|
|
293
|
+
startTypingLoop: deps.startTypingLoop
|
|
294
|
+
? () => deps.startTypingLoop?.(compactCtx, chatId)
|
|
295
|
+
: undefined,
|
|
296
|
+
stopTypingLoop: deps.stopTypingLoop,
|
|
297
|
+
sendTextReply: (text) =>
|
|
298
|
+
deps.sendTextReply(chatId, replyToMessageId, text).then(() => {}),
|
|
299
|
+
suppressStartNotice: true,
|
|
300
|
+
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
301
|
+
});
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
if (handledByCompact) return;
|
|
265
305
|
const handledByQueue = await deps.queueMenuCallbackHandler(query, ctx);
|
|
266
306
|
if (handledByQueue) return;
|
|
267
307
|
const handledBySettings = await deps.settingsMenuCallbackHandler?.(
|
|
@@ -372,6 +412,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
372
412
|
getPromptTemplateCommands,
|
|
373
413
|
persistConfig: deps.configStore.persist,
|
|
374
414
|
sendTextReply: deps.sendTextReply,
|
|
415
|
+
sendInteractiveMessage: deps.sendInteractiveMessage,
|
|
375
416
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
376
417
|
});
|
|
377
418
|
const promptEnqueue = Queue.createTelegramPromptEnqueueController<
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Telegram Extension Sections registry and callback routing
|
|
3
3
|
* Zones: telegram ui, extension platform, callback routing
|
|
4
|
-
* Owns section registration, token mapping, main-menu/settings row injection, and section callback dispatch
|
|
4
|
+
* Owns section registration, global registry binding, token mapping, main-menu/settings row injection, and section callback dispatch
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { TelegramInlineKeyboardMarkup } from "./keyboard.ts";
|
|
8
8
|
|
|
9
9
|
const SECTION_REGISTRY_KEY = "__piTelegramSectionRegistry__";
|
|
10
|
+
const TELEGRAM_CALLBACK_DATA_MAX_BYTES = 64;
|
|
10
11
|
|
|
11
12
|
// --- Core Types ---
|
|
12
13
|
|
|
@@ -181,9 +182,7 @@ function buildTelegramSectionContext(
|
|
|
181
182
|
.then(() => {}),
|
|
182
183
|
enqueuePrompt: deps.enqueuePrompt,
|
|
183
184
|
callbackData: (action, payload) =>
|
|
184
|
-
payload
|
|
185
|
-
? `section:${token}:${action}:${payload}`
|
|
186
|
-
: `section:${token}:${action}`,
|
|
185
|
+
buildTelegramSectionCallbackData(token, action, payload),
|
|
187
186
|
deleteMessage: () =>
|
|
188
187
|
messageId !== undefined
|
|
189
188
|
? deps.deleteMessage(chatId, messageId)
|
|
@@ -231,9 +230,7 @@ function buildTelegramSectionCallbackContext(
|
|
|
231
230
|
.then(() => {}),
|
|
232
231
|
enqueuePrompt: deps.enqueuePrompt,
|
|
233
232
|
callbackData: (action, payload) =>
|
|
234
|
-
payload
|
|
235
|
-
? `section:${token}:${action}:${payload}`
|
|
236
|
-
: `section:${token}:${action}`,
|
|
233
|
+
buildTelegramSectionCallbackData(token, action, payload),
|
|
237
234
|
deleteMessage: () =>
|
|
238
235
|
messageId !== undefined
|
|
239
236
|
? deps.deleteMessage(chatId, messageId)
|
|
@@ -250,6 +247,13 @@ export function setGlobalTelegramSectionRegistry(
|
|
|
250
247
|
(globalThis as Record<string, unknown>)[SECTION_REGISTRY_KEY] = registry;
|
|
251
248
|
}
|
|
252
249
|
|
|
250
|
+
/** @internal */
|
|
251
|
+
export function createAndBindTelegramSectionRegistry(): TelegramSectionRegistry {
|
|
252
|
+
const registry = createTelegramExtensionSectionRegistry();
|
|
253
|
+
setGlobalTelegramSectionRegistry(registry);
|
|
254
|
+
return registry;
|
|
255
|
+
}
|
|
256
|
+
|
|
253
257
|
/**
|
|
254
258
|
* Register a Telegram Extension Section from any pi extension.
|
|
255
259
|
* Returns a disposer. Throws if no section registry is active.
|
|
@@ -271,8 +275,8 @@ export function registerTelegramSection(
|
|
|
271
275
|
|
|
272
276
|
/**
|
|
273
277
|
* Get current section diagnostics. Returns empty array when registry is absent.
|
|
278
|
+
* @internal
|
|
274
279
|
*/
|
|
275
|
-
/** @internal */
|
|
276
280
|
export function getTelegramSectionDiagnostics(): TelegramSectionDiagnostic[] {
|
|
277
281
|
const registry = (globalThis as Record<string, unknown>)[
|
|
278
282
|
SECTION_REGISTRY_KEY
|
|
@@ -291,6 +295,27 @@ const BACK_NAV_ROW = {
|
|
|
291
295
|
text: "⬆️ Back",
|
|
292
296
|
} as const;
|
|
293
297
|
|
|
298
|
+
function getUtf8ByteLength(value: string): number {
|
|
299
|
+
return new TextEncoder().encode(value).byteLength;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function buildTelegramSectionCallbackData(
|
|
303
|
+
token: TelegramSectionToken,
|
|
304
|
+
action: string,
|
|
305
|
+
payload?: string,
|
|
306
|
+
): string {
|
|
307
|
+
const data = payload
|
|
308
|
+
? `section:${token}:${action}:${payload}`
|
|
309
|
+
: `section:${token}:${action}`;
|
|
310
|
+
const byteLength = getUtf8ByteLength(data);
|
|
311
|
+
if (byteLength > TELEGRAM_CALLBACK_DATA_MAX_BYTES) {
|
|
312
|
+
throw new Error(
|
|
313
|
+
`Telegram section callback_data exceeds ${TELEGRAM_CALLBACK_DATA_MAX_BYTES} bytes (${byteLength}). Use a shorter action/payload or store state behind a compact key.`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
return data;
|
|
317
|
+
}
|
|
318
|
+
|
|
294
319
|
function prependBackRow(
|
|
295
320
|
replyMarkup: TelegramInlineKeyboardMarkup | undefined,
|
|
296
321
|
backCallback: string,
|
|
@@ -319,6 +344,10 @@ export function createTelegramExtensionSectionRegistry(): TelegramSectionRegistr
|
|
|
319
344
|
let nextToken = 0;
|
|
320
345
|
|
|
321
346
|
function register(section: TelegramSectionRegistration): () => void {
|
|
347
|
+
const duplicate = [...sections.values()].find((s) => s.id === section.id);
|
|
348
|
+
if (duplicate) {
|
|
349
|
+
throw new Error(`Telegram section id already registered: ${section.id}`);
|
|
350
|
+
}
|
|
322
351
|
const token = String(nextToken++);
|
|
323
352
|
const registered: RegisteredTelegramSection = {
|
|
324
353
|
id: section.id,
|
package/lib/status.ts
CHANGED
|
@@ -289,12 +289,25 @@ function formatTelegramRuntimeEvent(event: TelegramRuntimeEvent): string {
|
|
|
289
289
|
return `${new Date(event.at).toISOString()} ${formatTelegramRuntimeEventSummary(event)}`;
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
+
function buildTelegramRuntimeEventSummary(events: TelegramRuntimeEvent[]): string {
|
|
293
|
+
const counts = new Map<string, number>();
|
|
294
|
+
for (const event of events) {
|
|
295
|
+
const category = formatTelegramRuntimeEventCategory(event);
|
|
296
|
+
counts.set(category, (counts.get(category) ?? 0) + 1);
|
|
297
|
+
}
|
|
298
|
+
return Array.from(counts.entries())
|
|
299
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
300
|
+
.map(([category, count]) => `${category}=${count}`)
|
|
301
|
+
.join(", ");
|
|
302
|
+
}
|
|
303
|
+
|
|
292
304
|
export function buildTelegramRuntimeEventLines(
|
|
293
305
|
events: TelegramRuntimeEvent[],
|
|
294
306
|
): string[] {
|
|
295
307
|
if (events.length === 0) return ["recent runtime events: none"];
|
|
296
308
|
return [
|
|
297
309
|
"recent runtime events:",
|
|
310
|
+
`- summary: ${buildTelegramRuntimeEventSummary(events)}`,
|
|
298
311
|
...events
|
|
299
312
|
.slice()
|
|
300
313
|
.reverse()
|
|
@@ -416,13 +416,13 @@ async function writeTelegramDownloadResponse(
|
|
|
416
416
|
if (!response.body) {
|
|
417
417
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
418
418
|
assertTelegramFileSizeWithinLimit(buffer.byteLength, maxFileSizeBytes);
|
|
419
|
-
await writeFile(targetPath, buffer);
|
|
419
|
+
await writeFile(targetPath, buffer, { mode: 0o600 });
|
|
420
420
|
return;
|
|
421
421
|
}
|
|
422
422
|
await pipeline(
|
|
423
423
|
Readable.from(response.body, { objectMode: false }),
|
|
424
424
|
createTelegramDownloadLimitTransform(maxFileSizeBytes),
|
|
425
|
-
createWriteStream(targetPath),
|
|
425
|
+
createWriteStream(targetPath, { mode: 0o600 }),
|
|
426
426
|
);
|
|
427
427
|
}
|
|
428
428
|
|
|
@@ -536,7 +536,7 @@ export async function prepareTelegramTempDir(
|
|
|
536
536
|
tempDir: string,
|
|
537
537
|
maxAgeMs: number,
|
|
538
538
|
): Promise<number> {
|
|
539
|
-
await mkdir(tempDir, { recursive: true });
|
|
539
|
+
await mkdir(tempDir, { recursive: true, mode: 0o700 });
|
|
540
540
|
return cleanupTelegramTempFiles(tempDir, maxAgeMs);
|
|
541
541
|
}
|
|
542
542
|
|
|
@@ -630,7 +630,7 @@ export async function downloadTelegramFile(
|
|
|
630
630
|
{ signal: options?.signal },
|
|
631
631
|
);
|
|
632
632
|
assertTelegramFileSizeWithinLimit(file.file_size, options?.maxFileSizeBytes);
|
|
633
|
-
await mkdir(tempDir, { recursive: true });
|
|
633
|
+
await mkdir(tempDir, { recursive: true, mode: 0o700 });
|
|
634
634
|
const targetPath = join(
|
|
635
635
|
tempDir,
|
|
636
636
|
`${randomUUID()}-${sanitizeFileName(suggestedName)}`,
|
package/lib/text-groups.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
const TELEGRAM_TEXT_GROUP_DEBOUNCE_MS = 1000;
|
|
8
8
|
const TELEGRAM_TEXT_GROUP_MIN_SPLIT_LENGTH = 3600;
|
|
9
|
-
const TELEGRAM_TEXT_GROUP_MAX_MESSAGE_ID_GAP =
|
|
9
|
+
const TELEGRAM_TEXT_GROUP_MAX_MESSAGE_ID_GAP = 12;
|
|
10
10
|
|
|
11
11
|
export interface TelegramTextGroupMessage {
|
|
12
12
|
message_id: number;
|
|
@@ -92,7 +92,8 @@ function canAppendTelegramTextGroupMessage<
|
|
|
92
92
|
return (
|
|
93
93
|
!!previous &&
|
|
94
94
|
message.message_id > previous.message_id &&
|
|
95
|
-
message.message_id <=
|
|
95
|
+
message.message_id <=
|
|
96
|
+
previous.message_id + TELEGRAM_TEXT_GROUP_MAX_MESSAGE_ID_GAP &&
|
|
96
97
|
text.length > 0 &&
|
|
97
98
|
!isTelegramTextGroupCommand(text)
|
|
98
99
|
);
|
package/lib/updates.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Telegram updates domain helpers
|
|
3
3
|
* Zones: telegram inbound, authorization, routing plans
|
|
4
|
-
* Owns update extraction, authorization, classification, execution planning,
|
|
4
|
+
* Owns update extraction, authorization, classification, execution planning, runtime execution, and the public update-handler registry
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import {
|
|
@@ -851,3 +851,123 @@ export async function executeTelegramUpdatePlan<
|
|
|
851
851
|
if (!isTelegramStaleContextError(error)) throw error;
|
|
852
852
|
}
|
|
853
853
|
}
|
|
854
|
+
|
|
855
|
+
// --- Public update handler registry ---
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Verdict returned by a public Telegram update handler.
|
|
859
|
+
*
|
|
860
|
+
* - `"consume"` — the handler processed this update; pi-telegram skips default routing.
|
|
861
|
+
* - `"pass"` (or `void`/`undefined`) — pi-telegram routes the update normally.
|
|
862
|
+
*/
|
|
863
|
+
export type TelegramUpdateHandlerVerdict = "consume" | "pass";
|
|
864
|
+
|
|
865
|
+
export type TelegramUpdateHandler = (
|
|
866
|
+
update: unknown,
|
|
867
|
+
) =>
|
|
868
|
+
| TelegramUpdateHandlerVerdict
|
|
869
|
+
| void
|
|
870
|
+
| Promise<TelegramUpdateHandlerVerdict | void>;
|
|
871
|
+
|
|
872
|
+
export interface TelegramUpdateHandlerRegistry {
|
|
873
|
+
/** Schema version of this registry shape. */
|
|
874
|
+
readonly version: 1;
|
|
875
|
+
/**
|
|
876
|
+
* Register an update handler. Returns a disposer that removes it.
|
|
877
|
+
*
|
|
878
|
+
* Handlers are invoked in registration order on every Telegram update,
|
|
879
|
+
* before pi-telegram's own routing. The first handler that returns
|
|
880
|
+
* `"consume"` wins and stops the chain for that update.
|
|
881
|
+
*/
|
|
882
|
+
add: (handler: TelegramUpdateHandler) => () => void;
|
|
883
|
+
/**
|
|
884
|
+
* Run all registered handlers against an update.
|
|
885
|
+
*
|
|
886
|
+
* Used by pi-telegram's polling runtime; companion extensions should call
|
|
887
|
+
* {@link registerTelegramUpdateHandler} or `add` instead of dispatching directly.
|
|
888
|
+
*/
|
|
889
|
+
dispatch: (update: unknown) => Promise<TelegramUpdateHandlerVerdict>;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const UPDATE_HANDLER_REGISTRY_KEY = "__piTelegramUpdateHandlerRegistry__";
|
|
893
|
+
|
|
894
|
+
function isValidV1UpdateHandlerRegistry(
|
|
895
|
+
candidate: unknown,
|
|
896
|
+
): candidate is TelegramUpdateHandlerRegistry {
|
|
897
|
+
if (!candidate || typeof candidate !== "object") return false;
|
|
898
|
+
const r = candidate as Partial<TelegramUpdateHandlerRegistry>;
|
|
899
|
+
return (
|
|
900
|
+
r.version === 1 &&
|
|
901
|
+
typeof r.add === "function" &&
|
|
902
|
+
typeof r.dispatch === "function"
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function getOrCreateUpdateHandlerRegistry(): TelegramUpdateHandlerRegistry {
|
|
907
|
+
const g = globalThis as Record<string, unknown>;
|
|
908
|
+
const existing = g[UPDATE_HANDLER_REGISTRY_KEY];
|
|
909
|
+
if (isValidV1UpdateHandlerRegistry(existing)) return existing;
|
|
910
|
+
const handlers = new Set<TelegramUpdateHandler>();
|
|
911
|
+
const registry: TelegramUpdateHandlerRegistry = {
|
|
912
|
+
version: 1,
|
|
913
|
+
add(handler) {
|
|
914
|
+
handlers.add(handler);
|
|
915
|
+
return () => handlers.delete(handler);
|
|
916
|
+
},
|
|
917
|
+
async dispatch(update) {
|
|
918
|
+
for (const handler of handlers) {
|
|
919
|
+
try {
|
|
920
|
+
const result = await handler(update);
|
|
921
|
+
if (result === "consume") return "consume";
|
|
922
|
+
} catch {
|
|
923
|
+
// Update handler errors must not break polling.
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
return "pass";
|
|
927
|
+
},
|
|
928
|
+
};
|
|
929
|
+
g[UPDATE_HANDLER_REGISTRY_KEY] = registry;
|
|
930
|
+
return registry;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Called by pi-telegram's own runtime to obtain the registry it dispatches
|
|
935
|
+
* through. Companion extensions should not call this; use
|
|
936
|
+
* {@link registerTelegramUpdateHandler} instead.
|
|
937
|
+
*/
|
|
938
|
+
export function getTelegramUpdateHandlerRegistry(): TelegramUpdateHandlerRegistry {
|
|
939
|
+
return getOrCreateUpdateHandlerRegistry();
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
export interface TelegramUpdateHandlerWrapDeps<TUpdate, TContext> {
|
|
943
|
+
defaultHandle: (update: TUpdate, ctx: TContext) => Promise<void>;
|
|
944
|
+
registry?: TelegramUpdateHandlerRegistry;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Wrap a default polling `handleUpdate` with the public update handler registry.
|
|
949
|
+
*/
|
|
950
|
+
export function createTelegramUpdateHandle<TUpdate, TContext>(
|
|
951
|
+
deps: TelegramUpdateHandlerWrapDeps<TUpdate, TContext>,
|
|
952
|
+
): (update: TUpdate, ctx: TContext) => Promise<void> {
|
|
953
|
+
const registry = deps.registry ?? getOrCreateUpdateHandlerRegistry();
|
|
954
|
+
const { defaultHandle } = deps;
|
|
955
|
+
return async function handleTelegramUpdate(update, ctx) {
|
|
956
|
+
const verdict = await registry.dispatch(update);
|
|
957
|
+
if (verdict === "consume") return;
|
|
958
|
+
await defaultHandle(update, ctx);
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Register a handler that runs before pi-telegram routes a Telegram update
|
|
964
|
+
* through its built-in handlers.
|
|
965
|
+
*
|
|
966
|
+
* This is the low-level public surface for companion extensions that share
|
|
967
|
+
* the same bot and pi process with pi-telegram.
|
|
968
|
+
*/
|
|
969
|
+
export function registerTelegramUpdateHandler(
|
|
970
|
+
handler: TelegramUpdateHandler,
|
|
971
|
+
): () => void {
|
|
972
|
+
return getOrCreateUpdateHandlerRegistry().add(handler);
|
|
973
|
+
}
|
package/lib/voice.ts
CHANGED
|
@@ -12,16 +12,32 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Separation of concerns:
|
|
14
14
|
* - All decision logic and domain rules live here.
|
|
15
|
-
* - Actual delivery (sending the audio via Telegram) stays in outbound
|
|
15
|
+
* - Actual delivery (sending the audio via Telegram) stays in outbound.ts.
|
|
16
16
|
*
|
|
17
17
|
* Keeps voice policy, turn tagging, prompt contributions, and markup helpers
|
|
18
18
|
* out of the queue, preview, turn-building, and delivery domains.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
const VOICE_SYNTHESIS_PROVIDER_REGISTRY_KEY =
|
|
21
|
+
const VOICE_SYNTHESIS_PROVIDER_REGISTRY_KEY =
|
|
22
|
+
"__piTelegramVoiceSynthesisProviders__";
|
|
22
23
|
const VOICE_TRANSCRIPTION_PROVIDER_REGISTRY_KEY =
|
|
23
24
|
"__piTelegramVoiceTranscriptionProviders__";
|
|
24
25
|
|
|
26
|
+
let nextGeneratedVoiceSynthesisProviderId = 0;
|
|
27
|
+
let nextGeneratedVoiceTranscriptionProviderId = 0;
|
|
28
|
+
|
|
29
|
+
function getNextAvailableProviderId<T>(
|
|
30
|
+
registry: Map<string, T>,
|
|
31
|
+
prefix: string,
|
|
32
|
+
nextId: () => number,
|
|
33
|
+
): string {
|
|
34
|
+
let id: string;
|
|
35
|
+
do {
|
|
36
|
+
id = `${prefix}-${nextId()}`;
|
|
37
|
+
} while (registry.has(id));
|
|
38
|
+
return id;
|
|
39
|
+
}
|
|
40
|
+
|
|
25
41
|
export type TelegramVoiceReplyMode = "mirror" | "always" | "manual";
|
|
26
42
|
|
|
27
43
|
export type TelegramVoiceSynthesisProviderResult =
|
|
@@ -81,11 +97,20 @@ function getOrCreateVoiceSynthesisProviderRegistry(): Map<
|
|
|
81
97
|
if (existing instanceof Map)
|
|
82
98
|
return existing as Map<string, TelegramVoiceSynthesisProvider>;
|
|
83
99
|
const registry = new Map<string, TelegramVoiceSynthesisProvider>();
|
|
84
|
-
(globalThis as Record<string, unknown>)[
|
|
85
|
-
|
|
100
|
+
(globalThis as Record<string, unknown>)[
|
|
101
|
+
VOICE_SYNTHESIS_PROVIDER_REGISTRY_KEY
|
|
102
|
+
] = registry;
|
|
86
103
|
return registry;
|
|
87
104
|
}
|
|
88
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Register a high-level Telegram voice synthesis provider.
|
|
108
|
+
*
|
|
109
|
+
* Stable public API callers must pass a stable `options.id` so diagnostics,
|
|
110
|
+
* replacement, and cleanup can identify the provider. Omitted ids remain a
|
|
111
|
+
* compatibility path for pre-matrix callers and receive generated session-local
|
|
112
|
+
* ids.
|
|
113
|
+
*/
|
|
89
114
|
export function registerTelegramVoiceSynthesisProvider(
|
|
90
115
|
provider:
|
|
91
116
|
| TelegramVoiceSynthesisProvider
|
|
@@ -96,22 +121,30 @@ export function registerTelegramVoiceSynthesisProvider(
|
|
|
96
121
|
options?: { id?: string },
|
|
97
122
|
): () => void {
|
|
98
123
|
const registry = getOrCreateVoiceSynthesisProviderRegistry();
|
|
99
|
-
const id =
|
|
124
|
+
const id =
|
|
125
|
+
options?.id ??
|
|
126
|
+
getNextAvailableProviderId(
|
|
127
|
+
registry,
|
|
128
|
+
"voice-synthesis-provider",
|
|
129
|
+
() => nextGeneratedVoiceSynthesisProviderId++,
|
|
130
|
+
);
|
|
100
131
|
const normalized =
|
|
101
132
|
typeof provider === "function"
|
|
102
133
|
? (Object.assign(
|
|
103
134
|
(text: string, options?: { lang?: string; rate?: string }) =>
|
|
104
135
|
provider(text, options),
|
|
105
136
|
{
|
|
106
|
-
getVoicePolicy: (provider as TelegramVoiceSynthesisProvider)
|
|
107
|
-
|
|
108
|
-
|
|
137
|
+
getVoicePolicy: (provider as TelegramVoiceSynthesisProvider)
|
|
138
|
+
.getVoicePolicy,
|
|
139
|
+
getVoicePromptContribution: (
|
|
140
|
+
provider as TelegramVoiceSynthesisProvider
|
|
141
|
+
).getVoicePromptContribution,
|
|
109
142
|
},
|
|
110
143
|
) as TelegramVoiceSynthesisProvider)
|
|
111
144
|
: provider;
|
|
112
145
|
registry.set(id, normalized);
|
|
113
146
|
return () => {
|
|
114
|
-
registry.delete(id);
|
|
147
|
+
if (registry.get(id) === normalized) registry.delete(id);
|
|
115
148
|
};
|
|
116
149
|
}
|
|
117
150
|
|
|
@@ -144,15 +177,28 @@ function getOrCreateVoiceTranscriptionProviderRegistry(): Map<
|
|
|
144
177
|
return registry;
|
|
145
178
|
}
|
|
146
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Register a high-level Telegram voice transcription provider.
|
|
182
|
+
*
|
|
183
|
+
* Stable public API callers must pass a stable `options.id`. Omitted ids remain
|
|
184
|
+
* a compatibility path for pre-matrix callers and receive generated
|
|
185
|
+
* session-local ids.
|
|
186
|
+
*/
|
|
147
187
|
export function registerTelegramVoiceTranscriptionProvider(
|
|
148
188
|
provider: TelegramVoiceTranscriptionProvider,
|
|
149
189
|
options?: { id?: string },
|
|
150
190
|
): () => void {
|
|
151
191
|
const registry = getOrCreateVoiceTranscriptionProviderRegistry();
|
|
152
|
-
const id =
|
|
192
|
+
const id =
|
|
193
|
+
options?.id ??
|
|
194
|
+
getNextAvailableProviderId(
|
|
195
|
+
registry,
|
|
196
|
+
"voice-transcription-provider",
|
|
197
|
+
() => nextGeneratedVoiceTranscriptionProviderId++,
|
|
198
|
+
);
|
|
153
199
|
registry.set(id, provider);
|
|
154
200
|
return () => {
|
|
155
|
-
registry.delete(id);
|
|
201
|
+
if (registry.get(id) === provider) registry.delete(id);
|
|
156
202
|
};
|
|
157
203
|
}
|
|
158
204
|
|
|
@@ -182,9 +228,9 @@ export const TELEGRAM_VOICE_REPLY_MODES = [
|
|
|
182
228
|
* Pi-telegram owns reply-mode policy through telegram.json. If
|
|
183
229
|
* config.voice.replyMode is missing or invalid, the safe default is manual.
|
|
184
230
|
*/
|
|
185
|
-
export function getTelegramVoiceReplyMode(
|
|
186
|
-
|
|
187
|
-
): TelegramVoiceReplyMode {
|
|
231
|
+
export function getTelegramVoiceReplyMode(config?: {
|
|
232
|
+
voice?: { replyMode?: string };
|
|
233
|
+
}): TelegramVoiceReplyMode {
|
|
188
234
|
const configMode = config?.voice?.replyMode;
|
|
189
235
|
if (
|
|
190
236
|
configMode &&
|
|
@@ -202,9 +248,9 @@ export function getTelegramVoiceReplyMode(
|
|
|
202
248
|
* Reads from `config.voice.sendTranscript`.
|
|
203
249
|
* Default: false (no transcript text sent at all).
|
|
204
250
|
*/
|
|
205
|
-
export function getTelegramVoiceSendTranscript(
|
|
206
|
-
|
|
207
|
-
): boolean {
|
|
251
|
+
export function getTelegramVoiceSendTranscript(config?: {
|
|
252
|
+
voice?: { sendTranscript?: boolean };
|
|
253
|
+
}): boolean {
|
|
208
254
|
return !!config?.voice?.sendTranscript;
|
|
209
255
|
}
|
|
210
256
|
|
|
@@ -284,12 +330,12 @@ export function shouldSuppressPreviewForVoice(
|
|
|
284
330
|
return !!(turn?.voiceReplyPreferred || turn?.voiceReplyRequired);
|
|
285
331
|
}
|
|
286
332
|
|
|
287
|
-
// --- Outbound
|
|
333
|
+
// --- Outbound Markup Re-Exports ---
|
|
288
334
|
|
|
289
335
|
export {
|
|
336
|
+
normalizeMarkdownAfterVoiceExtraction,
|
|
290
337
|
planTelegramVoiceReply,
|
|
291
|
-
stripTelegramCommentMarkupForPreview,
|
|
292
338
|
stripTelegramCommentMarkupForDelivery,
|
|
339
|
+
stripTelegramCommentMarkupForPreview,
|
|
293
340
|
stripTelegramVoiceMarkupForPreview,
|
|
294
|
-
|
|
295
|
-
} from "./outbound-handlers.ts";
|
|
341
|
+
} from "./outbound-markup.ts";
|
package/package.json
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llblab/pi-telegram",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
8
8
|
"description": "Telegram runtime adapter for π",
|
|
9
|
-
"type": "module",
|
|
10
9
|
"keywords": [
|
|
11
10
|
"pi-package",
|
|
12
11
|
"pi",
|
|
@@ -14,6 +13,7 @@
|
|
|
14
13
|
"bot",
|
|
15
14
|
"extension"
|
|
16
15
|
],
|
|
16
|
+
"type": "module",
|
|
17
17
|
"license": "MIT",
|
|
18
18
|
"repository": {
|
|
19
19
|
"type": "git",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"url": "https://github.com/llblab/pi-telegram/issues"
|
|
25
25
|
},
|
|
26
26
|
"engines": {
|
|
27
|
-
"node": ">=22.
|
|
27
|
+
"node": ">=22.19.0"
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
30
|
"test": "node --experimental-strip-types --test tests/*.test.ts",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
},
|
|
36
36
|
"files": [
|
|
37
37
|
"index.ts",
|
|
38
|
+
"api/",
|
|
38
39
|
"lib/",
|
|
39
40
|
"README.md",
|
|
40
41
|
"AGENTS.md",
|
|
@@ -43,6 +44,15 @@
|
|
|
43
44
|
"docs/",
|
|
44
45
|
"screenshot.png"
|
|
45
46
|
],
|
|
47
|
+
"exports": {
|
|
48
|
+
".": "./index.ts",
|
|
49
|
+
"./inbound": "./api/inbound.ts",
|
|
50
|
+
"./outbound": "./api/outbound.ts",
|
|
51
|
+
"./updates": "./api/updates.ts",
|
|
52
|
+
"./sections": "./api/sections.ts",
|
|
53
|
+
"./voice": "./api/voice.ts",
|
|
54
|
+
"./keyboard": "./api/keyboard.ts"
|
|
55
|
+
},
|
|
46
56
|
"pi": {
|
|
47
57
|
"extensions": [
|
|
48
58
|
"./index.ts"
|