Files
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

286 lines
8.2 KiB
TypeScript

/**
* Telegram outbound voice delivery helpers
* Zones: telegram outbound, voice delivery
* Owns native Telegram voice upload orchestration across configured voice handlers, programmatic outbound voice handlers, and registered synthesis providers
*/
import { unlink } from "node:fs/promises";
import { basename, extname } from "node:path";
import { assertTelegramInlineKeyboardCallbackData } from "./keyboard.ts";
import { buildTelegramMultipartReplyParameters } from "./replies.ts";
import {
getTelegramTargetThreadParams,
type TelegramTarget,
} from "./target.ts";
import { getTelegramVoiceSynthesisProviders } from "./voice.ts";
export interface TelegramVoiceReplyTurnView {
chatId: number;
replyToMessageId: number;
target?: TelegramTarget;
}
export interface TelegramVoiceReplySenderDeps {
execCommand: (
command: string,
args: string[],
options?: {
cwd?: string;
timeout?: number;
signal?: AbortSignal;
stdin?: string;
retry?: number;
},
) => Promise<{
stdout: string;
stderr: string;
code: number;
killed: boolean;
}>;
sendMultipart: (
method: string,
fields: Record<string, string>,
fileField: string,
filePath: string,
fileName: string,
) => Promise<unknown>;
sendChatAction?: (chatId: number, action: string) => Promise<unknown>;
sendRecordVoiceAction?: (chatId: number) => Promise<unknown>;
getHandlers?: () => unknown[] | undefined;
cwd?: string;
tempDir?: string;
recordRuntimeEvent?: (
category: string,
error: unknown,
details?: Record<string, unknown>,
) => void;
}
export type TelegramOutboundProgrammaticVoiceHandler = (
text: string,
options?: { lang?: string; rate?: string },
) => Promise<string>;
export interface TelegramVoiceReplySenderPorts<THandler = unknown> {
findVoiceHandlers?: (handlers: unknown[] | undefined) => THandler[];
generateVoiceFile?: (
text: string,
options: {
lang?: string;
rate?: string;
handler: THandler;
tempDir?: string;
cwd?: string;
execCommand: TelegramVoiceReplySenderDeps["execCommand"];
},
) => Promise<string | undefined>;
getProgrammaticVoiceHandlers?: () => TelegramOutboundProgrammaticVoiceHandler[];
}
function buildVoiceReplyParameters(
chatId: number,
replyToPrompt: boolean | undefined,
replyToMessageId: number | undefined,
target?: TelegramTarget,
): string | undefined {
if (replyToPrompt === false || replyToMessageId === undefined)
return undefined;
return buildTelegramMultipartReplyParameters(chatId, replyToMessageId, target);
}
async function ensureTelegramVoiceFileFormat(
filePath: string,
): Promise<string> {
const ext = extname(filePath).toLowerCase();
if (ext === ".opus" || ext === ".ogg") return filePath;
throw new Error(
`Voice synthesis provider must return .ogg or .opus files, got ${ext}. ` +
`Providers should handle format conversion internally.`,
);
}
function extractVoiceResult(result: any): {
filePath: string;
transcriptText?: string;
} {
if (typeof result === "string") return { filePath: result };
return {
filePath: result.audioPath,
transcriptText: result.transcriptText,
};
}
async function sendVoiceChatAction(
deps: TelegramVoiceReplySenderDeps,
chatId: number,
) {
if (deps.sendRecordVoiceAction) {
await deps.sendRecordVoiceAction(chatId).catch(() => {});
} else {
await deps.sendChatAction?.(chatId, "record_voice").catch(() => {});
}
}
export function createTelegramVoiceReplySender<THandler = unknown>(
deps: TelegramVoiceReplySenderDeps,
ports: TelegramVoiceReplySenderPorts<THandler> = {},
) {
const uploadVoiceFile = async (
turn: TelegramVoiceReplyTurnView,
filePath: string,
options?: {
replyToPrompt?: boolean;
replyMarkup?: unknown;
transcriptText?: string;
},
): Promise<void> => {
const voiceFilePath = await ensureTelegramVoiceFileFormat(filePath);
assertTelegramInlineKeyboardCallbackData(options?.replyMarkup);
await sendVoiceChatAction(deps, turn.chatId);
const replyParameters = buildVoiceReplyParameters(
turn.chatId,
options?.replyToPrompt,
turn.replyToMessageId,
turn.target,
);
await deps.sendMultipart(
"sendVoice",
{
chat_id: String(turn.chatId),
...(options?.transcriptText ? { caption: options.transcriptText } : {}),
...(replyParameters ? { reply_parameters: replyParameters } : {}),
...(turn.target
? Object.fromEntries(
Object.entries(getTelegramTargetThreadParams(turn.target)).map(
([key, value]) => [key, String(value)],
),
)
: {}),
...(options?.replyMarkup !== undefined && options.replyMarkup !== null
? {
reply_markup:
typeof options.replyMarkup === "string"
? options.replyMarkup
: JSON.stringify(options.replyMarkup),
}
: {}),
},
"voice",
voiceFilePath,
basename(voiceFilePath),
);
};
return async (
turn: TelegramVoiceReplyTurnView,
text: string,
options?: {
lang?: string;
rate?: string;
replyToPrompt?: boolean;
replyMarkup?: unknown;
},
): Promise<void> => {
for (const handler of ports.findVoiceHandlers?.(deps.getHandlers?.()) ??
[]) {
try {
const filePath = await ports.generateVoiceFile?.(text, {
lang: options?.lang,
rate: options?.rate,
handler,
tempDir: deps.tempDir,
cwd: deps.cwd,
execCommand: deps.execCommand,
});
if (!filePath) continue;
await uploadVoiceFile(turn, filePath, {
replyToPrompt: options?.replyToPrompt,
replyMarkup: options?.replyMarkup,
});
return;
} catch (error) {
deps.recordRuntimeEvent?.("voice", error, {
phase: "template-handler-send",
});
}
}
for (const handler of ports.getProgrammaticVoiceHandlers?.() ?? []) {
try {
const filePath = await handler(text, {
lang: options?.lang,
rate: options?.rate,
});
if (!filePath) continue;
await uploadVoiceFile(turn, filePath, {
replyToPrompt: options?.replyToPrompt,
replyMarkup: options?.replyMarkup,
});
return;
} catch (error) {
deps.recordRuntimeEvent?.("voice", error, {
phase: "programmatic-handler-send",
});
}
}
const providers = getTelegramVoiceSynthesisProviders();
for (const provider of providers) {
let voiceFilePath: string | undefined;
let originalFilePath: string | undefined;
try {
if (typeof provider !== "function") {
deps.recordRuntimeEvent?.(
"voice",
new Error(
"Registered voice synthesis provider is not callable (policy-only object?)",
),
{ phase: "voice-provider-skip" },
);
continue;
}
const providerResult = await provider(text, {
lang: options?.lang,
rate: options?.rate,
});
if (!providerResult) {
deps.recordRuntimeEvent?.(
"voice",
new Error("Voice synthesis provider returned empty path"),
{ phase: "voice-provider-skip" },
);
continue;
}
const { filePath, transcriptText } = extractVoiceResult(providerResult);
voiceFilePath = filePath;
originalFilePath = filePath;
await uploadVoiceFile(turn, filePath, {
replyToPrompt: options?.replyToPrompt,
replyMarkup: options?.replyMarkup,
transcriptText,
});
return;
} catch (error) {
deps.recordRuntimeEvent?.("voice", error, { phase: "send" });
} finally {
if (voiceFilePath && voiceFilePath !== originalFilePath) {
await unlink(voiceFilePath).catch(() => {});
}
}
}
const errorMessage =
"Failed to send voice reply: every voice synthesis provider and outbound voice handler failed.";
deps.recordRuntimeEvent?.("voice", new Error(errorMessage), {
phase: "send",
});
throw new Error(errorMessage);
};
}