Files
oh-my-pi-telegram/lib/outbound.ts
Evandro Fonseca Junior d1d04001d0
Release / release (push) Failing after 5s
Validate / validate (push) Failing after 5m21s
chore: fork @llblab/[email protected] as @evandrodevbr/oh-my-pi-telegram
Fork of upstream llblab/[email protected].0 (SHA afe09c5) repackaged as a
drop-in extension for can1357/oh-my-pi (omp >= 16.5.2).

Changes vs upstream:
- Rename package: @llblab/pi-telegram -> @evandrodevbr/oh-my-pi-telegram
- Bump version: 0.22.0 -> 0.22.0-evandro.1
- Update repository/homepage/bugs URLs to evandrodevbr
- Add omp field in package.json (displayName + compatibleWith)
- README: prepend 'Oh My Pi (omp) Compatibility' section with install
  commands, verified-components table, upstream lineage, and rationale
- CHANGELOG: add '0.22.0-evandro.1' entry under Unreleased
- docs/: rename @llblab/pi-telegram imports to @evandrodevbr/oh-my-pi-telegram
- tests/public-api.test.ts: rename package import paths

Gates re-run on the fork:
- npm run typecheck  -> tsc --noEmit (success)
- npm test           -> 1229 pass / 0 fail / 1 platform-only skip
- npm run audit      -> found 0 vulnerabilities
- npm run pack:check -> evandrodevbr-oh-my-pi-telegram-0.22.0-evandro.1.tgz

Verified:
- omp install --link ./pi-telegram -> Linked OK
- omp plugin list -> @evandrodevbr/[email protected].0-evandro.1

Upstream lineage: badlogic/pi-telegram -> llblab/pi-telegram (0.22.0) -> evandrodevbr/oh-my-pi-telegram (this fork)
2026-07-15 14:18:16 -03:00

908 lines
27 KiB
TypeScript

/**
* Telegram outbound surface helpers
* Zones: telegram outbound, command templates, voice delivery
* Owns configured outbound handler execution, text transforms, voice-file generation/delivery, runtime-event bridge, and compatibility re-exports; assistant markup parsing lives in outbound-markup and button callback actions live in outbound-buttons
*/
import { randomUUID } from "node:crypto";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { resolveTelegramTempDir } from "./paths.ts";
import {
planTelegramButtonReply,
type TelegramButtonActionStore,
type TelegramOutboundButtonMarkup,
} from "./outbound-buttons.ts";
import {
planTelegramVoiceReply,
type TelegramVoiceReplyItem,
} from "./outbound-markup.ts";
import { createTelegramVoiceReplySender as createTelegramVoiceReplySenderWithPorts } from "./outbound-voice.ts";
import type { TelegramTarget } from "./target.ts";
const OUTBOUND_HANDLER_REGISTRY_KEY = "__piTelegramOutboundHandlers__";
const VOICE_EVENT_RECORDER_KEY = "__piTelegramVoiceEventRecorder__";
import {
buildCommandTemplateInvocation,
expandCommandTemplateConfigs,
substituteCommandTemplateToken,
type CommandTemplateObjectConfig,
} from "./command-templates.ts";
const DEFAULT_VOICE_TIMEOUT_MS = 120_000;
// --- Types ---
/**
* Record a runtime event that appears in `/telegram-status`.
* Voice synthesis provider extensions can call this to surface diagnostics
* alongside pi-telegram's own events. Events are silently dropped
* when pi-telegram is not loaded.
*/
export type TelegramRuntimeEventRecorder = (
category: string,
error: unknown,
details?: Record<string, unknown>,
) => void;
export function bindTelegramRuntimeEventRecorder(
recorder: TelegramRuntimeEventRecorder,
): void {
(globalThis as Record<string, unknown>)[VOICE_EVENT_RECORDER_KEY] = recorder;
}
export function recordTelegramRuntimeEvent(
category: string,
error: unknown,
details?: Record<string, unknown>,
): void {
const recorder = (globalThis as Record<string, unknown>)[
VOICE_EVENT_RECORDER_KEY
];
if (typeof recorder === "function") {
(recorder as TelegramRuntimeEventRecorder)(category, error, details);
}
}
export type TelegramOutboundCommandTemplateConfig =
string | CommandTemplateObjectConfig;
export interface TelegramOutboundHandlerConfig extends CommandTemplateObjectConfig {
type?: string;
match?: string | string[];
output?: string;
timeout?: number | string;
}
export {
normalizeMarkdownAfterVoiceExtraction,
planTelegramVoiceReply,
stripTelegramCommentMarkupForDelivery,
stripTelegramCommentMarkupForPreview,
stripTelegramVoiceMarkupForPreview,
type TelegramVoiceReplyItem,
type TelegramVoiceReplyPlan,
} from "./outbound-markup.ts";
export interface TelegramVoiceExecOptions {
cwd?: string;
timeout?: number;
signal?: AbortSignal;
stdin?: string;
retry?: number;
}
export interface TelegramVoiceExecResult {
stdout: string;
stderr: string;
code: number;
killed: boolean;
}
export interface TelegramVoiceReplyTurnView {
chatId: number;
replyToMessageId: number;
target?: TelegramTarget;
}
export interface TelegramVoiceReplySenderDeps {
execCommand: (
command: string,
args: string[],
options?: TelegramVoiceExecOptions,
) => Promise<TelegramVoiceExecResult>;
sendMultipart: (
method: string,
fields: Record<string, string>,
fileField: string,
filePath: string,
fileName: string,
) => Promise<unknown>;
sendTextReply?: (
chatId: number,
replyToMessageId: number | undefined,
text: string,
options?: { parseMode?: "HTML" },
) => Promise<unknown>;
sendChatAction?: (chatId: number, action: string) => Promise<unknown>;
sendRecordVoiceAction?: (chatId: number) => Promise<unknown>;
getHandlers?: () => TelegramOutboundHandlerConfig[] | undefined;
cwd?: string;
tempDir?: string;
recordRuntimeEvent?: (
category: string,
error: unknown,
details?: Record<string, unknown>,
) => void;
}
// --- Programmatic Outbound Handler Registry ---
export type TelegramOutboundProgrammaticHandler = (
text: string,
options?: { lang?: string; rate?: string },
) => Promise<string>;
export interface TelegramOutboundHandlerRegistry {
handlers: Map<string, TelegramOutboundProgrammaticHandler[]>;
}
// --- Programmatic Outbound Handler Registry Runtime ---
function getOrCreateOutboundHandlerRegistry(): TelegramOutboundHandlerRegistry {
const existing = (globalThis as Record<string, unknown>)[
OUTBOUND_HANDLER_REGISTRY_KEY
];
if (
existing &&
typeof existing === "object" &&
existing !== null &&
"handlers" in existing &&
existing.handlers instanceof Map
) {
return existing as TelegramOutboundHandlerRegistry;
}
const registry: TelegramOutboundHandlerRegistry = {
handlers: new Map(),
};
(globalThis as Record<string, unknown>)[OUTBOUND_HANDLER_REGISTRY_KEY] =
registry;
return registry;
}
export function registerTelegramOutboundHandler(
kind: string,
handler: TelegramOutboundProgrammaticHandler,
): () => void {
const registry = getOrCreateOutboundHandlerRegistry();
const list = registry.handlers.get(kind) ?? [];
list.push(handler);
registry.handlers.set(kind, list);
return () => {
const updated = registry.handlers.get(kind) ?? [];
const index = updated.indexOf(handler);
if (index !== -1) {
updated.splice(index, 1);
registry.handlers.set(kind, updated);
}
};
}
export function hasTelegramOutboundHandler(kind: string): boolean {
const registry = getOrCreateOutboundHandlerRegistry();
const list = registry.handlers.get(kind);
return !!list && list.length > 0;
}
export function getTelegramOutboundProgrammaticHandlers(
kind: string,
): TelegramOutboundProgrammaticHandler[] {
const registry = getOrCreateOutboundHandlerRegistry();
return [...(registry.handlers.get(kind) ?? [])];
}
export interface TelegramOutboundTextReplyRuntimeDeps<TReplyMarkup = unknown> {
execCommand: TelegramVoiceReplySenderDeps["execCommand"];
getHandlers?: () => TelegramOutboundHandlerConfig[] | undefined;
sendTextReply: (
chatId: number,
replyToMessageId: number | undefined,
text: string,
options?: { parseMode?: "HTML"; target?: TelegramTarget },
) => Promise<number | undefined>;
sendMarkdownReply: (
chatId: number,
replyToMessageId: number | undefined,
markdown: string,
options?: { replyMarkup?: TReplyMarkup; target?: TelegramTarget },
) => Promise<number | undefined>;
cwd?: string;
recordRuntimeEvent?: TelegramVoiceReplySenderDeps["recordRuntimeEvent"];
}
export interface TelegramInlineKeyboardLike {
inline_keyboard: Array<Array<{ text: string; callback_data: string }>>;
}
export interface TelegramOutboundTextTransformOptions<TReplyMarkup = unknown> {
handlers?: TelegramOutboundHandlerConfig[];
cwd?: string;
execCommand: TelegramVoiceReplySenderDeps["execCommand"];
recordRuntimeEvent?: TelegramVoiceReplySenderDeps["recordRuntimeEvent"];
replyMarkup?: TReplyMarkup;
}
export interface TelegramOutboundTextTransformResult<TReplyMarkup = unknown> {
text: string;
replyMarkup?: TReplyMarkup;
}
export interface TelegramOutboundTextPreviewRuntimeDeps<
TReplyMarkup = unknown,
> {
execCommand: TelegramVoiceReplySenderDeps["execCommand"];
getHandlers?: () => TelegramOutboundHandlerConfig[] | undefined;
finalizeMarkdownPreview: (
chatId: number,
markdown: string,
replyToMessageId: number,
options?: { replyMarkup?: TReplyMarkup },
) => Promise<boolean>;
cwd?: string;
recordRuntimeEvent?: TelegramVoiceReplySenderDeps["recordRuntimeEvent"];
}
// --- Voice Reply Timeout Helpers ---
function resolveOutboundNumericControlField(
value: number | string | undefined,
values: Record<string, unknown>,
label: string,
): number | undefined {
if (value === undefined) return undefined;
const resolved =
typeof value === "string"
? substituteCommandTemplateToken(value, values, label)
: value;
if (resolved === "") return undefined;
const numeric = Number(resolved);
if (!Number.isFinite(numeric) || numeric < 0)
throw new Error(`Command template ${label} must be a non-negative number.`);
return numeric;
}
function getVoiceReplyConfiguredTimeout(
config: TelegramOutboundCommandTemplateConfig | undefined,
): number | undefined {
const timeout = typeof config === "string" ? undefined : config?.timeout;
return resolveOutboundNumericControlField(timeout, {}, "timeout");
}
function getVoiceReplyTimeout(
config: TelegramOutboundCommandTemplateConfig | undefined,
): number {
return getVoiceReplyConfiguredTimeout(config) ?? DEFAULT_VOICE_TIMEOUT_MS;
}
function getRemainingVoiceReplyTimeout(
timeout: number,
startedAt: number,
): number {
return Math.max(1, timeout - (Date.now() - startedAt));
}
function getVoiceReplyCompositionStepTimeout(
handlerTimeout: number,
step: TelegramOutboundCommandTemplateConfig,
startedAt: number,
): number {
const remaining = getRemainingVoiceReplyTimeout(handlerTimeout, startedAt);
const stepTimeout = getVoiceReplyConfiguredTimeout(step);
return stepTimeout === undefined
? remaining
: Math.min(stepTimeout, remaining);
}
function formatVoiceReplyExecutionFailure(
label: string,
result: TelegramVoiceExecResult,
): string {
const parts = [
`${label} exited with code ${result.code}${result.killed ? " (killed)" : ""}`,
];
if (result.stderr.trim()) parts.push(`stderr:\n${result.stderr.trimEnd()}`);
if (result.stdout.trim()) parts.push(`stdout:\n${result.stdout.trimEnd()}`);
return parts.join("\n\n");
}
async function runVoiceReplyCommand(
label: string,
config: TelegramOutboundCommandTemplateConfig,
values: Record<string, string>,
options: {
cwd: string;
timeout: number;
execCommand: TelegramVoiceReplySenderDeps["execCommand"];
stdin?: string;
},
): Promise<TelegramVoiceExecResult> {
if (!options.execCommand) {
throw new Error("execCommand is required for command template execution");
}
const invocation = buildCommandTemplateInvocation(
config,
values,
options.cwd,
{
emptyMessage: "Outbound voice template is empty",
missingLabel: "outbound voice template",
},
);
const result = await options.execCommand(
invocation.command,
invocation.args,
{
cwd: options.cwd,
timeout: options.timeout,
...(typeof config === "object" && config.retry !== undefined
? {
retry: resolveOutboundNumericControlField(
config.retry,
{},
"retry",
),
}
: {}),
...(options.stdin !== undefined ? { stdin: options.stdin } : {}),
},
);
if (result.code !== 0)
throw new Error(formatVoiceReplyExecutionFailure(label, result));
return result;
}
function normalizeOutboundHandlerStringList(
value: string | string[] | undefined,
): string[] {
if (Array.isArray(value))
return value
.map(String)
.map((item) => item.trim())
.filter(Boolean);
if (typeof value === "string" && value.trim()) return [value.trim()];
return [];
}
function outboundHandlerMatchesType(
handler: TelegramOutboundHandlerConfig,
type: string,
): boolean {
const selectors = [
...normalizeOutboundHandlerStringList(handler.type),
...normalizeOutboundHandlerStringList(handler.match),
];
if (selectors.length === 0) return false;
return selectors.includes(type);
}
export function findTelegramOutboundHandlers(
handlers: TelegramOutboundHandlerConfig[] | undefined,
type: string,
): TelegramOutboundHandlerConfig[] {
if (!Array.isArray(handlers)) return [];
return handlers.filter(
(handler) =>
!!handler &&
typeof handler === "object" &&
outboundHandlerMatchesType(handler, type),
);
}
function getTelegramVoiceHandlerCompositionSteps(
handler: TelegramOutboundHandlerConfig,
): TelegramOutboundCommandTemplateConfig[] {
if (Array.isArray(handler.template)) {
return expandCommandTemplateConfigs(
handler,
) as TelegramOutboundCommandTemplateConfig[];
}
return [];
}
function extractVoiceReplyPath(stdout: string): string {
const path = stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
if (!path) throw new Error("Voice generator did not print an output path");
return path;
}
function getVoiceReplyOutputPath(
config: TelegramOutboundHandlerConfig,
values: Record<string, string>,
stdout: string,
): string {
const output = config.output ?? "stdout";
if (output === "stdout") return extractVoiceReplyPath(stdout);
const keyMatch = output.match(/^\{?([A-Za-z_][A-Za-z0-9_-]*)\}?$/);
if (keyMatch && Object.hasOwn(values, keyMatch[1])) {
return values[keyMatch[1]] ?? "";
}
return output.replace(
/\{([A-Za-z_][A-Za-z0-9_-]*)\}/g,
(_match, key: string) => values[key] ?? "",
);
}
function getVoiceReplyTemplateValues(
text: string,
options: { lang?: string; rate?: string; mp3Path: string; oggPath: string },
): Record<string, string> {
return {
text,
type: "voice",
mp3: options.mp3Path,
ogg: options.oggPath,
...(options.lang ? { lang: options.lang } : {}),
...(options.rate ? { rate: options.rate } : {}),
};
}
function getDefaultTelegramVoiceTempDir(): string {
return resolveTelegramTempDir();
}
async function generateTelegramVoiceReplyFileWithHandler(
text: string,
options: {
lang?: string;
rate?: string;
handler: TelegramOutboundHandlerConfig;
tempDir: string;
cwd: string;
timeout: number;
execCommand: TelegramVoiceReplySenderDeps["execCommand"];
},
): Promise<string> {
await mkdir(options.tempDir, { recursive: true });
const artifactId = randomUUID();
const values = getVoiceReplyTemplateValues(text, {
lang: options.lang,
rate: options.rate,
mp3Path: join(options.tempDir, `${artifactId}-voice.mp3`),
oggPath: join(options.tempDir, `${artifactId}-voice.ogg`),
});
const steps = getTelegramVoiceHandlerCompositionSteps(options.handler);
if (steps.length > 0) {
const startedAt = Date.now();
let stdout = text;
for (const [index, step] of steps.entries()) {
try {
const result = await runVoiceReplyCommand(
`Outbound voice template step ${index + 1}`,
step,
values,
{
cwd: options.cwd,
timeout: getVoiceReplyCompositionStepTimeout(
options.timeout,
step,
startedAt,
),
execCommand: options.execCommand,
stdin: stdout,
},
);
stdout = result.stdout;
} catch (error) {
if (typeof step === "object" && step.failure === "root") throw error;
stdout = "";
}
}
return getVoiceReplyOutputPath(options.handler, values, stdout);
}
const result = await runVoiceReplyCommand(
"Outbound voice template",
options.handler,
values,
{
cwd: options.cwd,
timeout: options.timeout,
execCommand: options.execCommand,
stdin: text,
},
);
return getVoiceReplyOutputPath(options.handler, values, result.stdout);
}
export async function generateTelegramVoiceReplyFile(
text: string,
options: {
lang?: string;
rate?: string;
handler?: TelegramOutboundHandlerConfig;
tempDir?: string;
cwd?: string;
execCommand: TelegramVoiceReplySenderDeps["execCommand"];
},
): Promise<string | undefined> {
const handler = options.handler;
if (!handler?.template) return undefined;
return generateTelegramVoiceReplyFileWithHandler(text, {
lang: options.lang,
rate: options.rate,
handler,
tempDir: options.tempDir ?? getDefaultTelegramVoiceTempDir(),
cwd: options.cwd ?? process.cwd(),
timeout: getVoiceReplyTimeout(handler),
execCommand: options.execCommand,
});
}
function getOutboundTextTemplateValues(text: string): Record<string, string> {
return { text, type: "text" };
}
async function transformTelegramOutboundTextWithHandler(
text: string,
options: {
handler: TelegramOutboundHandlerConfig;
cwd: string;
execCommand: TelegramVoiceReplySenderDeps["execCommand"];
},
): Promise<string> {
const values = getOutboundTextTemplateValues(text);
const steps = getTelegramVoiceHandlerCompositionSteps(options.handler);
if (steps.length > 0) {
const startedAt = Date.now();
let stdout = text;
for (const [index, step] of steps.entries()) {
try {
const result = await runVoiceReplyCommand(
`Outbound text template step ${index + 1}`,
step,
values,
{
cwd: options.cwd,
timeout: getVoiceReplyCompositionStepTimeout(
getVoiceReplyTimeout(options.handler),
step,
startedAt,
),
execCommand: options.execCommand,
stdin: stdout,
},
);
stdout = result.stdout;
} catch (error) {
if (typeof step === "object" && step.failure === "root") throw error;
stdout = "";
}
if (!stdout) stdout = text;
}
return stdout.trim() || text;
}
const result = await runVoiceReplyCommand(
"Outbound text template",
options.handler,
values,
{
cwd: options.cwd,
timeout: getVoiceReplyTimeout(options.handler),
execCommand: options.execCommand,
stdin: text,
},
);
return result.stdout.trim() || text;
}
export async function transformTelegramOutboundText(
text: string,
options: {
handlers?: TelegramOutboundHandlerConfig[];
cwd?: string;
execCommand: TelegramVoiceReplySenderDeps["execCommand"];
recordRuntimeEvent?: TelegramVoiceReplySenderDeps["recordRuntimeEvent"];
},
): Promise<string> {
let transformed = text;
for (const handler of findTelegramOutboundHandlers(
options.handlers,
"text",
)) {
try {
transformed = await transformTelegramOutboundTextWithHandler(
transformed,
{
handler,
cwd: options.cwd ?? process.cwd(),
execCommand: options.execCommand,
},
);
} catch (error) {
options.recordRuntimeEvent?.("outbound-text-handler", error, {
handler: outboundHandlerMatchesType(handler, "text")
? "text"
: "unknown",
});
}
}
return transformed;
}
function isTelegramInlineKeyboardLike(
replyMarkup: unknown,
): replyMarkup is TelegramInlineKeyboardLike {
if (!replyMarkup || typeof replyMarkup !== "object") return false;
const keyboard = (replyMarkup as { inline_keyboard?: unknown })
.inline_keyboard;
return Array.isArray(keyboard);
}
async function transformTelegramOutboundReplyMarkup<TReplyMarkup>(
replyMarkup: TReplyMarkup | undefined,
options: Omit<TelegramOutboundTextTransformOptions, "replyMarkup">,
): Promise<TReplyMarkup | undefined> {
if (!isTelegramInlineKeyboardLike(replyMarkup)) return replyMarkup;
const translatedRows = [];
for (const row of replyMarkup.inline_keyboard) {
const translatedRow = [];
for (const button of row) {
const text = await transformTelegramOutboundText(button.text, options);
translatedRow.push({ ...button, text });
}
translatedRows.push(translatedRow);
}
return { ...replyMarkup, inline_keyboard: translatedRows } as TReplyMarkup;
}
export async function transformTelegramOutboundTextReply<
TReplyMarkup = unknown,
>(
text: string,
options: TelegramOutboundTextTransformOptions<TReplyMarkup>,
): Promise<TelegramOutboundTextTransformResult<TReplyMarkup>> {
const transformOptions = {
handlers: options.handlers,
cwd: options.cwd,
execCommand: options.execCommand,
recordRuntimeEvent: options.recordRuntimeEvent,
};
const transformedText = await transformTelegramOutboundText(
text,
transformOptions,
);
const replyMarkup = await transformTelegramOutboundReplyMarkup(
options.replyMarkup,
transformOptions,
);
return { text: transformedText, ...(replyMarkup ? { replyMarkup } : {}) };
}
export function createTelegramOutboundTextReplyRuntime<TReplyMarkup = unknown>(
deps: TelegramOutboundTextReplyRuntimeDeps<TReplyMarkup>,
): Pick<
TelegramOutboundTextReplyRuntimeDeps<TReplyMarkup>,
"sendTextReply" | "sendMarkdownReply"
> {
return {
sendTextReply: async (chatId, replyToMessageId, text, options) => {
const transformed = await transformTelegramOutboundText(text, {
handlers: deps.getHandlers?.(),
cwd: deps.cwd,
execCommand: deps.execCommand,
recordRuntimeEvent: deps.recordRuntimeEvent,
});
return deps.sendTextReply(chatId, replyToMessageId, transformed, options);
},
sendMarkdownReply: async (chatId, replyToMessageId, markdown, options) => {
const transformed = await transformTelegramOutboundTextReply(markdown, {
handlers: deps.getHandlers?.(),
cwd: deps.cwd,
execCommand: deps.execCommand,
recordRuntimeEvent: deps.recordRuntimeEvent,
replyMarkup: options?.replyMarkup,
});
return deps.sendMarkdownReply(
chatId,
replyToMessageId,
transformed.text,
{
...options,
...(transformed.replyMarkup
? { replyMarkup: transformed.replyMarkup }
: {}),
},
);
},
};
}
export function createTelegramOutboundTextPreviewRuntime<
TReplyMarkup = unknown,
>(
deps: TelegramOutboundTextPreviewRuntimeDeps<TReplyMarkup>,
): Pick<
TelegramOutboundTextPreviewRuntimeDeps<TReplyMarkup>,
"finalizeMarkdownPreview"
> {
return {
finalizeMarkdownPreview: async (
chatId,
markdown,
replyToMessageId,
options,
) => {
const transformed = await transformTelegramOutboundTextReply(markdown, {
handlers: deps.getHandlers?.(),
cwd: deps.cwd,
execCommand: deps.execCommand,
recordRuntimeEvent: deps.recordRuntimeEvent,
replyMarkup: options?.replyMarkup,
});
return deps.finalizeMarkdownPreview(
chatId,
transformed.text,
replyToMessageId,
{
...options,
...(transformed.replyMarkup
? { replyMarkup: transformed.replyMarkup }
: {}),
},
);
},
};
}
export interface TelegramOutboundReplyPlan<TReplyMarkup = unknown> {
markdown: string;
replyMarkup?: TReplyMarkup;
voiceText?: string;
voiceReplies?: TelegramVoiceReplyItem[];
lang?: string;
rate?: string;
}
// --- Voice Policy Re-Exports ---
export {
clearTelegramVoiceSynthesisProviders,
clearTelegramVoiceTranscriptionProviders,
computeVoicePromptContribution,
computeVoiceTurnFlags,
getTelegramVoiceReplyMode,
getTelegramVoiceSynthesisProviders,
getTelegramVoiceTranscriptionProviders,
hasTelegramVoiceSynthesisProvider,
hasTelegramVoiceTranscriptionProvider,
isVoiceTurn,
registerTelegramVoiceSynthesisProvider,
registerTelegramVoiceTranscriptionProvider,
shouldSuppressPreviewForVoice,
type TelegramVoiceReplyMode,
type TelegramVoiceSynthesisProvider,
type TelegramVoiceSynthesisProviderResult,
type TelegramVoiceTranscriptionFile,
type TelegramVoiceTranscriptionProvider,
type TelegramVoiceTranscriptionProviderResult,
type TelegramVoiceTurnView,
} from "./voice.ts";
export function createTelegramVoiceReplySender(
deps: TelegramVoiceReplySenderDeps,
) {
return createTelegramVoiceReplySenderWithPorts(deps, {
findVoiceHandlers: (handlers) =>
findTelegramOutboundHandlers(
handlers as TelegramOutboundHandlerConfig[] | undefined,
"voice",
),
generateVoiceFile: (text, options) =>
generateTelegramVoiceReplyFile(text, {
lang: options.lang,
rate: options.rate,
handler: options.handler,
tempDir: options.tempDir,
cwd: options.cwd,
execCommand: options.execCommand,
}),
getProgrammaticVoiceHandlers: () =>
getTelegramOutboundProgrammaticHandlers("voice"),
});
}
export {
createTelegramButtonActionStore,
createTelegramButtonPromptTurn,
createTelegramButtonReplyPlanner,
handleTelegramButtonCallbackQuery,
planTelegramButtonReply,
type TelegramButtonActionStore,
type TelegramButtonCallbackHandlerDeps,
type TelegramButtonCallbackQuery,
type TelegramButtonReplyPlan,
type TelegramOutboundButtonAction,
type TelegramOutboundButtonMarkup,
type TelegramOutboundButtonStoredAction,
} from "./outbound-buttons.ts";
export function createTelegramOutboundReplyPlanner(
store: Pick<TelegramButtonActionStore, "register">,
): (
markdown: string,
) => TelegramOutboundReplyPlan<TelegramOutboundButtonMarkup> {
return (markdown) => {
const buttonReply = planTelegramButtonReply(markdown, {
registerAction: store.register,
});
// Button replies can also contain <!-- telegram_voice --> markup
const voiceReply = planTelegramVoiceReply(buttonReply.markdown);
return {
markdown: voiceReply.markdown,
...(buttonReply.replyMarkup
? { replyMarkup: buttonReply.replyMarkup }
: {}),
...(voiceReply.voiceText ? { voiceText: voiceReply.voiceText } : {}),
...(voiceReply.voiceReplies
? { voiceReplies: voiceReply.voiceReplies }
: {}),
...(voiceReply.lang ? { lang: voiceReply.lang } : {}),
...(voiceReply.rate ? { rate: voiceReply.rate } : {}),
};
};
}
/**
* Create an artifact sender that delivers planned voice replies for a turn.
* Iterates over `voiceReplies` (or a single `voiceText`) and sends each as
* a Telegram voice message via the voice reply sender. Throws if no voice
* reply could be delivered.
*/
// --- Outbound Reply Artifacts ---
export function createTelegramOutboundReplyArtifactSender(
deps: TelegramVoiceReplySenderDeps,
) {
const sendVoiceReply = createTelegramVoiceReplySender(deps);
return async (
turn: TelegramVoiceReplyTurnView,
plan: Pick<
TelegramOutboundReplyPlan,
"voiceText" | "voiceReplies" | "lang" | "rate" | "replyMarkup"
>,
options?: { replyToPrompt?: boolean },
): Promise<void> => {
// Normalize voice replies: either use explicit voiceReplies array or fall back to voiceText
const voiceReplies = plan.voiceReplies?.length
? plan.voiceReplies
: plan.voiceText
? [{ text: plan.voiceText, lang: plan.lang, rate: plan.rate }]
: [];
let anyDelivered = false;
for (const reply of voiceReplies) {
try {
await sendVoiceReply(turn, reply.text, {
lang: reply.lang ?? plan.lang,
rate: reply.rate ?? plan.rate,
// Only attach reply parameters to the first voice message
replyToPrompt: options?.replyToPrompt === true && !anyDelivered,
replyMarkup: !anyDelivered ? plan.replyMarkup : undefined,
});
anyDelivered = true;
} catch {
// sendVoiceReply already recorded the error; continue to next reply
}
}
if (!anyDelivered) {
throw new Error(
"Failed to send voice reply: every voice synthesis provider failed.",
);
}
};
}