Files
oh-my-pi-telegram/tests/outbound.test.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

1464 lines
41 KiB
TypeScript

/**
* Regression tests for Telegram outbound handler helpers
* Exercises assistant-authored voice/button markup extraction, artifact generation, callbacks, and Telegram upload wiring
*/
import assert from "node:assert/strict";
import test from "node:test";
import { resetTransportReplyDedup } from "../lib/replies.ts";
test.beforeEach(() => {
resetTransportReplyDedup();
clearTelegramVoiceSynthesisProviders();
});
import {
createTelegramButtonActionStore,
createTelegramButtonPromptTurn,
createTelegramOutboundReplyArtifactSender,
createTelegramOutboundReplyPlanner,
createTelegramOutboundTextPreviewRuntime,
createTelegramOutboundTextReplyRuntime,
createTelegramVoiceReplySender,
handleTelegramButtonCallbackQuery,
planTelegramButtonReply,
planTelegramVoiceReply,
registerTelegramVoiceSynthesisProvider,
getTelegramVoiceSynthesisProviders,
hasTelegramVoiceSynthesisProvider,
clearTelegramVoiceSynthesisProviders,
stripTelegramCommentMarkupForPreview,
stripTelegramVoiceMarkupForPreview,
} from "../lib/outbound.ts";
const testReplyMarkup = {
inline_keyboard: [[{ text: "Continue", callback_data: "btn:1" }]],
};
test("Outbound text handler transforms text and markdown replies", async () => {
const sent: string[] = [];
const markdownOptions: unknown[] = [];
const calls: Array<{ command: string; stdin?: string }> = [];
const runtime = createTelegramOutboundTextReplyRuntime({
getHandlers: () => [{ type: "text", template: "/tools/translate" }],
execCommand: async (command, _args, options) => {
calls.push({ command, stdin: options?.stdin });
return {
stdout: `translated:${options?.stdin ?? ""}`,
stderr: "",
code: 0,
killed: false,
};
},
sendTextReply: async (_chatId, _replyToMessageId, text) => {
sent.push(`text:${text}`);
return 1;
},
sendMarkdownReply: async (
_chatId,
_replyToMessageId,
markdown,
options,
) => {
sent.push(`markdown:${markdown}`);
markdownOptions.push(options);
return 2;
},
});
assert.equal(await runtime.sendTextReply(1, 2, "hello"), 1);
assert.equal(
await runtime.sendMarkdownReply(1, 2, "**hello**", {
replyMarkup: testReplyMarkup,
}),
2,
);
assert.deepEqual(calls, [
{ command: "/tools/translate", stdin: "hello" },
{ command: "/tools/translate", stdin: "**hello**" },
{ command: "/tools/translate", stdin: "Continue" },
]);
assert.deepEqual(sent, [
"text:translated:hello",
"markdown:translated:**hello**",
]);
assert.deepEqual(markdownOptions, [
{
replyMarkup: {
inline_keyboard: [
[{ text: "translated:Continue", callback_data: "btn:1" }],
],
},
},
]);
});
test("Outbound text handler preserves inline buttons on transformed replies", async () => {
const sent: Array<{ markdown: string; replyMarkup: unknown }> = [];
const actions: unknown[] = [];
const plan = planTelegramButtonReply(
["Answer.", "", "<!-- telegram_button: Continue -->"].join("\n"),
{
registerAction: (action) => {
actions.push(action);
return `btn:${actions.length}`;
},
},
);
const runtime = createTelegramOutboundTextReplyRuntime({
getHandlers: () => [{ type: "text", template: "/tools/translate" }],
execCommand: async (_command, _args, options) => ({
stdout: `translated:${options?.stdin ?? ""}`,
stderr: "",
code: 0,
killed: false,
}),
sendTextReply: async () => 1,
sendMarkdownReply: async (
_chatId,
_replyToMessageId,
markdown,
options,
) => {
sent.push({ markdown, replyMarkup: options?.replyMarkup });
return 2;
},
});
await runtime.sendMarkdownReply(1, 2, plan.markdown, {
replyMarkup: plan.replyMarkup,
});
assert.deepEqual(actions, [{ text: "Continue", prompt: "Continue" }]);
assert.deepEqual(sent, [
{
markdown: "translated:Answer.",
replyMarkup: {
inline_keyboard: [
[{ text: "translated:Continue", callback_data: "btn:1" }],
],
},
},
]);
});
test("Outbound text handler transforms finalized markdown previews", async () => {
const finalized: string[] = [];
const previewOptions: unknown[] = [];
const runtime = createTelegramOutboundTextPreviewRuntime({
getHandlers: () => [{ type: "text", template: "/tools/translate" }],
execCommand: async (_command, _args, options) => ({
stdout: `translated:${options?.stdin ?? ""}`,
stderr: "",
code: 0,
killed: false,
}),
finalizeMarkdownPreview: async (
_chatId,
markdown,
_replyToMessageId,
options,
) => {
finalized.push(markdown);
previewOptions.push(options);
return true;
},
});
assert.equal(
await runtime.finalizeMarkdownPreview(1, "**hello**", 2, {
replyMarkup: testReplyMarkup,
}),
true,
);
assert.deepEqual(finalized, ["translated:**hello**"]);
assert.deepEqual(previewOptions, [
{
replyMarkup: {
inline_keyboard: [
[{ text: "translated:Continue", callback_data: "btn:1" }],
],
},
},
]);
});
test("Outbound text handler keeps original reply on failure", async () => {
const events: string[] = [];
const sent: string[] = [];
const runtime = createTelegramOutboundTextReplyRuntime({
getHandlers: () => [{ type: "text", template: "/tools/fail" }],
execCommand: async () => ({
stdout: "",
stderr: "boom",
code: 1,
killed: false,
}),
sendTextReply: async (_chatId, _replyToMessageId, text) => {
sent.push(text);
return 1;
},
sendMarkdownReply: async () => 2,
recordRuntimeEvent: (category) => {
events.push(category);
},
});
assert.equal(await runtime.sendTextReply(1, 2, "original"), 1);
assert.deepEqual(sent, ["original"]);
assert.deepEqual(events, ["outbound-text-handler"]);
});
test("Voice reply planner extracts multiline telegram_voice comments", () => {
const plan = planTelegramVoiceReply(
[
"Technical answer.",
"",
"<!-- telegram_voice lang=ru rate=+20%",
"Short speakable summary.",
"-->",
].join("\n"),
);
assert.deepEqual(plan, {
markdown: "Technical answer.",
voiceText: "Short speakable summary.",
voiceReplies: [
{ text: "Short speakable summary.", lang: "ru", rate: "+20%" },
],
lang: "ru",
rate: "+20%",
});
});
test("Voice reply planner supports paired voice comments", () => {
const plan = planTelegramVoiceReply(
[
"Visible answer.",
"",
"<!-- telegram_voice lang=ru rate=+5% -->",
"Первая строка.",
"Вторая строка.",
"<!-- /telegram_voice -->",
].join("\n"),
);
assert.deepEqual(plan, {
markdown: "Visible answer.",
voiceText: "Первая строка.\nВторая строка.",
voiceReplies: [
{ text: "Первая строка.\nВторая строка.", lang: "ru", rate: "+5%" },
],
lang: "ru",
rate: "+5%",
});
});
test("Voice reply planner supports compact inline comments", () => {
const plan = planTelegramVoiceReply(
"Text before.\n\n<!-- telegram_voice: Inline summary. -->",
);
assert.deepEqual(plan, {
markdown: "Text before.",
voiceText: "Inline summary.",
voiceReplies: [{ text: "Inline summary." }],
});
});
test("Voice reply planner supports text attribute comments", () => {
const plan = planTelegramVoiceReply(
'Text before.\n\n<!-- telegram_voice lang=ru rate=+10% text="Inline spoken summary." -->',
);
assert.deepEqual(plan, {
markdown: "Text before.",
voiceText: "Inline spoken summary.",
voiceReplies: [
{ text: "Inline spoken summary.", lang: "ru", rate: "+10%" },
],
lang: "ru",
rate: "+10%",
});
});
test("Voice reply planner recovers one-line action bodies", () => {
const plan = planTelegramVoiceReply(
[
"Text before.",
"",
"<!-- telegram_voice lang=ru rate=+30% -->",
"Speak this instead of leaking it as text.",
"-->",
].join("\n"),
);
assert.deepEqual(plan, {
markdown: "Text before.",
voiceText: "Speak this instead of leaking it as text.",
voiceReplies: [
{
text: "Speak this instead of leaking it as text.",
lang: "ru",
rate: "+30%",
},
],
lang: "ru",
rate: "+30%",
});
});
test("Voice reply planner keeps multiple telegram_voice blocks as independent artifacts", () => {
const plan = planTelegramVoiceReply(
[
"Technical answer.",
"",
"<!-- telegram_voice lang=ru rate=+20%",
"First summary.",
"-->",
"",
"<!-- telegram_voice lang=en rate=+10%",
"Second summary.",
"-->",
].join("\n"),
);
assert.deepEqual(plan, {
markdown: "Technical answer.",
voiceText: "First summary.\n\nSecond summary.",
voiceReplies: [
{ text: "First summary.", lang: "ru", rate: "+20%" },
{ text: "Second summary.", lang: "en", rate: "+10%" },
],
lang: "en",
rate: "+10%",
});
});
test("Voice reply planner strips non-voice comments from delivered markdown", () => {
const plan = planTelegramVoiceReply(
["Visible text.", "", "<!-- internal note -->", "", "Visible tail."].join(
"\n",
),
);
assert.deepEqual(plan, {
markdown: "Visible text.\n\nVisible tail.",
});
});
test("Voice preview stripping hides closed and currently open telegram_voice blocks", () => {
assert.equal(
stripTelegramVoiceMarkupForPreview(
[
"Visible text.",
"",
"<!-- telegram_voice lang=ru rate=+30%",
"Hidden voice text streaming now",
].join("\n"),
),
"Visible text.",
);
assert.equal(
stripTelegramVoiceMarkupForPreview(
[
"Visible text.",
"",
"<!-- telegram_voice",
"Hidden voice text.",
"-->",
"",
"Visible tail.",
].join("\n"),
),
"Visible text.\n\nVisible tail.",
);
});
test("Comment preview stripping hides generic and partial comments", () => {
assert.equal(
stripTelegramCommentMarkupForPreview(
"Visible text.\n\n<!-- hidden -->\n\nVisible tail.",
),
"Visible text.\n\nVisible tail.",
);
assert.equal(
stripTelegramCommentMarkupForPreview(
[
"Visible text.",
"",
'<!-- telegram_button label="Hidden" -->',
"Hidden prompt.",
"-->",
].join("\n"),
),
"Visible text.",
);
assert.equal(
stripTelegramCommentMarkupForPreview("Visible text.\n\n<"),
"Visible text.",
);
assert.equal(
stripTelegramCommentMarkupForPreview("Visible text.\n\n<!"),
"Visible text.",
);
assert.equal(
stripTelegramCommentMarkupForPreview("Visible text.\n\n<!-"),
"Visible text.",
);
assert.equal(
stripTelegramCommentMarkupForPreview(
"Visible text.\n\n<!-- internal note streaming",
),
"Visible text.",
);
});
test("Outbound comments inside fenced code stay literal", () => {
const markdown = [
"Example:",
"",
"```md",
"<!-- telegram_voice lang=ru",
"Do not speak.",
"-->",
"",
'<!-- telegram_button label="Run"',
"Do not queue.",
"-->",
"```",
].join("\n");
const actions: unknown[] = [];
assert.deepEqual(planTelegramVoiceReply(markdown), { markdown });
assert.deepEqual(
planTelegramButtonReply(markdown, {
registerAction: (action) => {
actions.push(action);
return `btn:${actions.length}`;
},
}),
{ markdown },
);
assert.deepEqual(actions, []);
assert.equal(stripTelegramCommentMarkupForPreview(markdown), markdown);
});
test("Outbound comments resume after indented and longer closing fences", () => {
const markdown = [
"Example:",
"",
"````md",
"<!-- telegram_button label=Skip -->",
" ````",
"",
"<!-- telegram_button: OK -->",
].join("\n");
const actions: unknown[] = [];
const plan = planTelegramButtonReply(markdown, {
registerAction: (action) => {
actions.push(action);
return `btn:${actions.length}`;
},
});
assert.equal(
plan.markdown,
[
"Example:",
"",
"````md",
"<!-- telegram_button label=Skip -->",
" ````",
].join("\n"),
);
assert.deepEqual(actions, [{ text: "OK", prompt: "OK" }]);
});
test("Outbound action comments require top-level column-zero markers", () => {
const markdown = [
"Visible answer.",
"",
" <!-- telegram_voice lang=ru",
"Indented voice.",
" -->",
"",
'> <!-- telegram_button label="OK"',
"> Quoted prompt.",
"> -->",
].join("\n");
const actions: unknown[] = [];
assert.deepEqual(planTelegramVoiceReply(markdown), { markdown });
assert.deepEqual(
planTelegramButtonReply(markdown, {
registerAction: (action) => {
actions.push(action);
return `btn:${actions.length}`;
},
}),
{ markdown },
);
assert.deepEqual(actions, []);
});
test("Button reply planner supports independent label blocks", () => {
const actions: unknown[] = [];
const plan = planTelegramButtonReply(
[
"Visible answer.",
"",
'<!-- telegram_button label="OK"',
"PROMPT",
"-->",
"",
"<!-- telegram_button label='More'",
"Continue with more detail",
"-->",
].join("\n"),
{
registerAction: (action) => {
actions.push(action);
return `btn:${actions.length}`;
},
},
);
assert.equal(plan.markdown, "Visible answer.");
assert.deepEqual(actions, [
{ text: "OK", prompt: "PROMPT" },
{ text: "More", prompt: "Continue with more detail" },
]);
assert.deepEqual(plan.replyMarkup, {
inline_keyboard: [
[{ text: "OK", callback_data: "btn:1" }],
[{ text: "More", callback_data: "btn:2" }],
],
});
});
test("Outbound reply planner strips voice and button markup without losing artifacts", () => {
const actions: unknown[] = [];
const plan = createTelegramOutboundReplyPlanner({
register: (action) => {
actions.push(action);
return `btn:${actions.length}`;
},
})(
[
"Visible answer.",
"",
"<!-- telegram_voice: Speak this summary. -->",
"",
'<!-- telegram_button label=Continue prompt="Continue with context." -->',
].join("\n"),
);
assert.equal(plan.markdown, "Visible answer.");
assert.equal(plan.voiceText, "Speak this summary.");
assert.deepEqual(plan.voiceReplies, [{ text: "Speak this summary." }]);
assert.deepEqual(actions, [
{ text: "Continue", prompt: "Continue with context." },
]);
assert.deepEqual(plan.replyMarkup, {
inline_keyboard: [[{ text: "Continue", callback_data: "btn:1" }]],
});
});
test("Button reply planner supports colon label-only shortcut", () => {
const actions: unknown[] = [];
const plan = planTelegramButtonReply(
["Visible answer.", "", "<!-- telegram_button: OK -->"].join("\n"),
{
registerAction: (action) => {
actions.push(action);
return `btn:${actions.length}`;
},
},
);
assert.equal(plan.markdown, "Visible answer.");
assert.deepEqual(actions, [{ text: "OK", prompt: "OK" }]);
assert.deepEqual(plan.replyMarkup, {
inline_keyboard: [[{ text: "OK", callback_data: "btn:1" }]],
});
});
test("Button reply planner supports prompt attribute shortcut", () => {
const actions: unknown[] = [];
const plan = planTelegramButtonReply(
[
"Visible answer.",
"",
'<!-- telegram_button label=Continue prompt="Continue with the current plan." -->',
].join("\n"),
{
registerAction: (action) => {
actions.push(action);
return `btn:${actions.length}`;
},
},
);
assert.equal(plan.markdown, "Visible answer.");
assert.deepEqual(actions, [
{ text: "Continue", prompt: "Continue with the current plan." },
]);
assert.deepEqual(plan.replyMarkup, {
inline_keyboard: [[{ text: "Continue", callback_data: "btn:1" }]],
});
});
test("Button reply planner recovers one-line action bodies", () => {
const actions: unknown[] = [];
const plan = planTelegramButtonReply(
[
"Visible answer.",
"",
'<!-- telegram_button label="Boundary" -->',
"Where does JAM end and DEOS begin?",
"-->",
"",
'<!-- telegram_button label="Sources" -->',
"Which DEOS sources are canonical?",
"-->",
].join("\n"),
{
registerAction: (action) => {
actions.push(action);
return `btn:${actions.length}`;
},
},
);
assert.equal(plan.markdown, "Visible answer.");
assert.deepEqual(actions, [
{ text: "Boundary", prompt: "Where does JAM end and DEOS begin?" },
{ text: "Sources", prompt: "Which DEOS sources are canonical?" },
]);
assert.deepEqual(plan.replyMarkup, {
inline_keyboard: [
[{ text: "Boundary", callback_data: "btn:1" }],
[{ text: "Sources", callback_data: "btn:2" }],
],
});
});
test("Button reply planner requires prompt attribute for closed heads", () => {
const actions: unknown[] = [];
const plan = planTelegramButtonReply(
[
"Visible answer.",
"",
'<!-- telegram_button label="Closed" -->',
"",
"Visible tail.",
].join("\n"),
{
registerAction: (action) => {
actions.push(action);
return `btn:${actions.length}`;
},
},
);
assert.equal(plan.markdown, "Visible answer.\n\nVisible tail.");
assert.deepEqual(actions, []);
});
test("Button reply planner bounds inline-closed body recovery", () => {
const actions: unknown[] = [];
const plan = planTelegramButtonReply(
[
"Visible answer.",
"",
'<!-- telegram_button label="Long" -->',
"First body line.",
"Second body line.",
"-->",
].join("\n"),
{
registerAction: (action) => {
actions.push(action);
return `btn:${actions.length}`;
},
},
);
assert.equal(
plan.markdown,
[
"Visible answer.",
"",
"First body line.",
"Second body line.",
"-->",
].join("\n"),
);
assert.deepEqual(actions, []);
});
test("Button action store resolves generated callback data", () => {
const store = createTelegramButtonActionStore();
const callbackData = store.register({ text: "Next", prompt: "Go next" });
assert.match(callbackData, /^tgbtn:[a-f0-9-]+/);
assert.deepEqual(store.resolve(callbackData), {
text: "Next",
prompt: "Go next",
});
assert.equal(store.resolve("other:1"), undefined);
});
test("Button callback handler enqueues prompt actions", async () => {
const events: unknown[] = [];
await assert.equal(
await handleTelegramButtonCallbackQuery(
{
id: "callback-1",
data: "btn:1",
message: { message_id: 22, chat: { id: 7 } },
},
{ id: "ctx" },
{
resolveAction: () => ({ text: "Continue", prompt: "Continue now" }),
answerCallbackQuery: async (id, text) => {
events.push({ answer: id, text });
},
enqueueButtonPrompt: (query, action, ctx) => {
events.push({ query, action, ctx });
},
},
),
true,
);
assert.deepEqual(events, [
{
query: {
id: "callback-1",
data: "btn:1",
message: { message_id: 22, chat: { id: 7 } },
},
action: { text: "Continue", prompt: "Continue now" },
ctx: { id: "ctx" },
},
{ answer: "callback-1", text: "Queued." },
]);
});
test("Button callback handler consumes expired button callbacks", async () => {
const events: unknown[] = [];
assert.equal(
await handleTelegramButtonCallbackQuery(
{ id: "callback-1", data: "tgbtn:missing" },
{},
{
resolveAction: () => undefined,
answerCallbackQuery: async (id, text) => {
events.push({ id, text });
},
enqueueButtonPrompt: () => {
events.push("unexpected:enqueue");
},
},
),
true,
);
assert.deepEqual(events, [
{ id: "callback-1", text: "Button action expired." },
]);
});
test("Button prompt turns use Telegram prompt content", () => {
assert.deepEqual(
createTelegramButtonPromptTurn({
chatId: 7,
replyToMessageId: 22,
queueOrder: 3,
action: { text: "Continue", prompt: "Continue now" },
}),
{
kind: "prompt",
chatId: 7,
replyToMessageId: 22,
sourceMessageIds: [22],
queueOrder: 3,
queueLane: "priority",
laneOrder: 3,
queuedAttachments: [],
content: [{ type: "text", text: "[telegram] Continue now" }],
historyText: "Continue now",
statusSummary: "Continue",
},
);
assert.deepEqual(testReplyMarkup, {
inline_keyboard: [[{ text: "Continue", callback_data: "btn:1" }]],
});
});
test("Voice reply sender includes thread target fields and scoped reply parameters", async () => {
const events: unknown[] = [];
const dispose = registerTelegramVoiceSynthesisProvider(
async () => "/tmp/voice.opus",
);
const sendVoiceReply = createTelegramVoiceReplySender({
execCommand: async () => ({
stdout: "",
stderr: "",
code: 0,
killed: false,
}),
sendMultipart: async (method, fields, fileField, filePath, fileName) => {
events.push({ method, fields, fileField, filePath, fileName });
},
});
await sendVoiceReply(
{ chatId: 10, replyToMessageId: 20, target: { chatId: 10, threadId: 42 } },
"hello",
{ replyToPrompt: true },
);
assert.deepEqual(events, [
{
method: "sendVoice",
fields: {
chat_id: "10",
message_thread_id: "42",
reply_parameters:
'{"message_id":20,"allow_sending_without_reply":true}',
},
fileField: "voice",
filePath: "/tmp/voice.opus",
fileName: "voice.opus",
},
]);
dispose();
});
test("Voice reply sender can suppress prompt reply metadata for secondary voice", async () => {
const events: unknown[] = [];
const dispose = registerTelegramVoiceSynthesisProvider(
async () => "/tmp/voice.opus",
);
const sendVoiceReply = createTelegramVoiceReplySender({
execCommand: async () => ({
stdout: "",
stderr: "",
code: 0,
killed: false,
}),
sendMultipart: async (method, fields, fileField, filePath, fileName) => {
events.push({ method, fields, fileField, filePath, fileName });
},
});
await sendVoiceReply({ chatId: 10, replyToMessageId: 20 }, "hello", {
replyToPrompt: false,
});
assert.deepEqual(events, [
{
method: "sendVoice",
fields: { chat_id: "10" },
fileField: "voice",
filePath: "/tmp/voice.opus",
fileName: "voice.opus",
},
]);
dispose();
});
test("Outbound artifact sender sends multiple voice replies independently", async () => {
const events: unknown[] = [];
const dispose = registerTelegramVoiceSynthesisProvider(async (text) => {
return `/tmp/${text}.opus`;
});
const sendOutboundReplyArtifacts = createTelegramOutboundReplyArtifactSender({
execCommand: async () => ({
stdout: "",
stderr: "",
code: 0,
killed: false,
}),
sendMultipart: async (method, fields, fileField, filePath, fileName) => {
events.push({ method, fields, fileField, filePath, fileName });
},
});
await sendOutboundReplyArtifacts(
{ chatId: 10, replyToMessageId: 20 },
{
voiceReplies: [
{ text: "one", lang: "ru" },
{ text: "two", lang: "en" },
],
},
{ replyToPrompt: true },
);
assert.deepEqual(events, [
{
method: "sendVoice",
fields: {
chat_id: "10",
reply_parameters:
'{"message_id":20,"allow_sending_without_reply":true}',
},
fileField: "voice",
filePath: "/tmp/one.opus",
fileName: "one.opus",
},
{
method: "sendVoice",
fields: { chat_id: "10" },
fileField: "voice",
filePath: "/tmp/two.opus",
fileName: "two.opus",
},
]);
dispose();
});
test("Voice reply sender prefers configured outbound voice handlers over registered synthesis providers", async () => {
const events: unknown[] = [];
const dispose = registerTelegramVoiceSynthesisProvider(async () => {
events.push("provider-called");
return "/tmp/provider.opus";
});
const sendVoiceReply = createTelegramVoiceReplySender({
tempDir: "/tmp/pi-telegram-voice-handler-test",
getHandlers: () => [
{
type: "voice",
template: "tts --write-media {ogg}",
output: "ogg",
},
],
execCommand: async () => ({
stdout: "ok",
stderr: "",
code: 0,
killed: false,
}),
sendMultipart: async (_method, _fields, _fileField, filePath) => {
events.push(filePath);
},
});
await sendVoiceReply({ chatId: 10, replyToMessageId: 20 }, "hello");
assert.equal(events.length, 1);
assert.match(
events[0] as string,
/^\/tmp\/pi-telegram-voice-handler-test\/.+-voice\.ogg$/,
);
dispose();
});
test("Voice reply sender falls back from configured handlers to registered synthesis providers", async () => {
const events: unknown[] = [];
const dispose = registerTelegramVoiceSynthesisProvider(
async () => "/tmp/provider.opus",
);
const sendVoiceReply = createTelegramVoiceReplySender({
getHandlers: () => [{ type: "voice", template: "/missing/tts" }],
execCommand: async () => {
throw new Error("configured handler failed");
},
sendMultipart: async (_method, _fields, _fileField, filePath) => {
events.push(filePath);
},
recordRuntimeEvent: (_category, error) => {
events.push((error as Error).message);
},
});
await sendVoiceReply({ chatId: 10, replyToMessageId: 20 }, "hello");
assert.ok(events.includes("configured handler failed"));
assert.equal(events.at(-1), "/tmp/provider.opus");
dispose();
});
test("Voice reply sender uses configured outbound voice handlers when no provider is registered", async () => {
const execCalls: unknown[] = [];
const uploads: unknown[] = [];
const sendVoiceReply = createTelegramVoiceReplySender({
tempDir: "/tmp/pi-telegram-voice-handler-test",
getHandlers: () => [
{
type: "voice",
template: ["tts --write-media {mp3}", "ffmpeg -i {mp3} {ogg}"],
output: "ogg",
},
],
execCommand: async (command, args, options) => {
execCalls.push({ command, args, stdin: options?.stdin });
return {
stdout: command === "tts" ? "mp3-ready" : "ogg-ready",
stderr: "",
code: 0,
killed: false,
};
},
sendMultipart: async (method, fields, fileField, filePath, fileName) => {
uploads.push({ method, fields, fileField, filePath, fileName });
},
});
await sendVoiceReply({ chatId: 10, replyToMessageId: 20 }, "hello", {
replyToPrompt: true,
});
assert.equal(execCalls.length, 2);
const firstExec = execCalls[0] as {
command: string;
args: string[];
stdin: string;
};
const secondExec = execCalls[1] as {
command: string;
args: string[];
stdin: string;
};
assert.equal(firstExec.command, "tts");
assert.equal(firstExec.stdin, "hello");
assert.match(
firstExec.args[1],
/^\/tmp\/pi-telegram-voice-handler-test\/.+-voice\.mp3$/,
);
assert.equal(secondExec.command, "ffmpeg");
assert.equal(secondExec.stdin, "mp3-ready");
assert.match(
secondExec.args[1],
/^\/tmp\/pi-telegram-voice-handler-test\/.+-voice\.mp3$/,
);
assert.match(
secondExec.args[2],
/^\/tmp\/pi-telegram-voice-handler-test\/.+-voice\.ogg$/,
);
assert.equal(uploads.length, 1);
const upload = uploads[0] as {
method: string;
fields: Record<string, string>;
fileField: string;
filePath: string;
fileName: string;
};
assert.equal(upload.method, "sendVoice");
assert.deepEqual(upload.fields, {
chat_id: "10",
reply_parameters: '{"message_id":20,"allow_sending_without_reply":true}',
});
assert.equal(upload.fileField, "voice");
assert.match(
upload.filePath,
/^\/tmp\/pi-telegram-voice-handler-test\/.+-voice\.ogg$/,
);
assert.match(upload.fileName, /^.+-voice\.ogg$/);
});
test("Voice reply sender falls back to the next voice synthesis provider", async () => {
const events: unknown[] = [];
const dispose1 = registerTelegramVoiceSynthesisProvider(async () => {
throw new Error("provider 1 failed");
});
const dispose2 = registerTelegramVoiceSynthesisProvider(async () => {
return "/tmp/good.opus";
});
const sendVoiceReply = createTelegramVoiceReplySender({
execCommand: async () => ({
stdout: "",
stderr: "",
code: 0,
killed: false,
}),
sendMultipart: async (method, fields, fileField, filePath, fileName) => {
events.push({ method, fields, fileField, filePath, fileName });
},
});
await sendVoiceReply({ chatId: 10, replyToMessageId: 20 }, "hello");
assert.deepEqual(events, [
{
method: "sendVoice",
fields: {
chat_id: "10",
reply_parameters:
'{"message_id":20,"allow_sending_without_reply":true}',
},
fileField: "voice",
filePath: "/tmp/good.opus",
fileName: "good.opus",
},
]);
dispose1();
dispose2();
});
test("Voice reply sender uploads generated ogg via sendVoice", async () => {
const events: unknown[] = [];
const dispose = registerTelegramVoiceSynthesisProvider(
async () => "/tmp/voice.opus",
);
const sendVoiceReply = createTelegramVoiceReplySender({
execCommand: async () => ({
stdout: "",
stderr: "",
code: 0,
killed: false,
}),
sendMultipart: async (method, fields, fileField, filePath, fileName) => {
events.push({ method, fields, fileField, filePath, fileName });
},
});
await sendVoiceReply({ chatId: 10, replyToMessageId: 20 }, "hello");
dispose();
assert.deepEqual(events, [
{
method: "sendVoice",
fields: {
chat_id: "10",
reply_parameters:
'{"message_id":20,"allow_sending_without_reply":true}',
},
fileField: "voice",
filePath: "/tmp/voice.opus",
fileName: "voice.opus",
},
]);
});
// --- Critical-step composition tests ---
// --- Combined profile: CI-like pipeline (composition + retry + critical) ---
test("Voice synthesis provider registration and invocation", async () => {
clearTelegramVoiceSynthesisProviders();
const calls: string[] = [];
assert.equal(hasTelegramVoiceSynthesisProvider(), false);
const dispose = registerTelegramVoiceSynthesisProvider(async (text) => {
calls.push(text);
return `/tmp/voice-${text.length}.opus`;
});
assert.equal(hasTelegramVoiceSynthesisProvider(), true);
const providers = getTelegramVoiceSynthesisProviders();
assert.equal(providers.length, 1);
const result = await providers[0]("hello", {});
assert.equal(result, "/tmp/voice-5.opus");
assert.deepEqual(calls, ["hello"]);
dispose();
assert.equal(hasTelegramVoiceSynthesisProvider(), false);
});
test("Voice synthesis provider registry supports multiple providers", async () => {
clearTelegramVoiceSynthesisProviders();
assert.equal(getTelegramVoiceSynthesisProviders().length, 0);
const dispose1 = registerTelegramVoiceSynthesisProvider(
async () => "/tmp/voice1.opus",
);
const dispose2 = registerTelegramVoiceSynthesisProvider(
async () => "/tmp/voice2.opus",
);
assert.equal(getTelegramVoiceSynthesisProviders().length, 2);
assert.equal(hasTelegramVoiceSynthesisProvider(), true);
// returned array should be a copy
const providers = getTelegramVoiceSynthesisProviders();
providers.pop();
assert.equal(getTelegramVoiceSynthesisProviders().length, 2);
dispose1();
assert.equal(getTelegramVoiceSynthesisProviders().length, 1);
dispose2();
assert.equal(getTelegramVoiceSynthesisProviders().length, 0);
});
test("Voice reply sender passes replyMarkup to sendMultipart", async () => {
const fields: Record<string, unknown>[] = [];
const dispose = registerTelegramVoiceSynthesisProvider(
async () => "/tmp/voice.opus",
);
const sendVoiceReply = createTelegramVoiceReplySender({
execCommand: async () => ({
stdout: "",
stderr: "",
code: 0,
killed: false,
}),
sendMultipart: async (_method, f) => {
fields.push(f);
},
recordRuntimeEvent: () => {},
});
await sendVoiceReply({ chatId: 10, replyToMessageId: 20 }, "hello", {
replyMarkup: {
inline_keyboard: [[{ text: "OK", callback_data: "btn:1" }]],
},
});
assert.equal(fields.length, 1);
assert.ok(
typeof (fields[0] as Record<string, unknown>).reply_markup === "string",
);
dispose();
});
test("Voice reply sender only passes replyMarkup to first successful voice reply", async () => {
const fields: Record<string, unknown>[] = [];
const disposeFail = registerTelegramVoiceSynthesisProvider(async () => {
throw new Error("Provider 1 failed");
});
const disposeSuccess = registerTelegramVoiceSynthesisProvider(
async () => "/tmp/voice.opus",
);
const sendVoiceReply = createTelegramVoiceReplySender({
execCommand: async () => ({
stdout: "",
stderr: "",
code: 0,
killed: false,
}),
sendMultipart: async (_method, f) => {
fields.push(f);
},
recordRuntimeEvent: () => {},
});
await sendVoiceReply({ chatId: 10, replyToMessageId: 20 }, "hello", {
replyMarkup: {
inline_keyboard: [[{ text: "OK", callback_data: "btn:1" }]],
},
});
assert.equal(fields.length, 1);
assert.ok(
typeof (fields[0] as Record<string, unknown>).reply_markup === "string",
);
disposeFail();
disposeSuccess();
});
test("Voice reply sender throws when every handler fails", async () => {
clearTelegramVoiceSynthesisProviders();
const events: unknown[] = [];
const dispose = registerTelegramVoiceSynthesisProvider(async () => {
throw new Error("handler 1 failed");
});
const sendVoiceReply = createTelegramVoiceReplySender({
execCommand: async () => ({
stdout: "",
stderr: "",
code: 1,
killed: false,
}),
sendMultipart: async () => {
events.push("sendMultipart");
},
recordRuntimeEvent: (category, error) => {
events.push(`error:${category}:${(error as Error).message}`);
},
});
await assert.rejects(
async () =>
await sendVoiceReply({ chatId: 1, replyToMessageId: 2 }, "hello", {
replyToPrompt: false,
}),
/Failed to send voice reply: every voice synthesis provider and outbound voice handler failed/,
);
dispose();
assert.equal(hasTelegramVoiceSynthesisProvider(), false);
assert.ok(events.length >= 2);
assert.ok(events.some((e) => (e as string).includes("handler 1 failed")));
assert.ok(
events.some((e) =>
(e as string).includes(
"every voice synthesis provider and outbound voice handler failed",
),
),
);
});
test("Voice reply sender skips provider that returns undefined and tries next", async () => {
const events: unknown[] = [];
const dispose1 = registerTelegramVoiceSynthesisProvider(
async () => undefined,
);
const dispose2 = registerTelegramVoiceSynthesisProvider(
async () => "/tmp/voice.opus",
);
const sendVoiceReply = createTelegramVoiceReplySender({
execCommand: async () => ({
stdout: "",
stderr: "",
code: 0,
killed: false,
}),
sendMultipart: async (method, fields, fileField, filePath, fileName) => {
events.push({ method, fields, fileField, filePath, fileName });
},
recordRuntimeEvent: () => {},
});
await sendVoiceReply({ chatId: 10, replyToMessageId: 20 }, "hello");
assert.equal(events.length, 1);
assert.equal(
(events[0] as Record<string, unknown>).filePath,
"/tmp/voice.opus",
);
dispose1();
dispose2();
});
test("Voice reply sender records event when provider returns empty string", async () => {
const events: unknown[] = [];
const dispose1 = registerTelegramVoiceSynthesisProvider(async () => "");
const dispose2 = registerTelegramVoiceSynthesisProvider(
async () => "/tmp/voice.opus",
);
const sendVoiceReply = createTelegramVoiceReplySender({
execCommand: async () => ({
stdout: "",
stderr: "",
code: 0,
killed: false,
}),
sendMultipart: async () => {},
recordRuntimeEvent: (category, error, details) => {
events.push(
`error:${category}:${(error as Error).message}:${details?.phase}`,
);
},
});
await sendVoiceReply({ chatId: 10, replyToMessageId: 20 }, "hello");
assert.ok(events.some((e) => (e as string).includes("voice-provider-skip")));
dispose1();
dispose2();
});
test("Voice reply sender accepts Opus files", async () => {
const events: unknown[] = [];
const dispose = registerTelegramVoiceSynthesisProvider(
async () => "/tmp/response.opus",
);
const sendVoiceReply = createTelegramVoiceReplySender({
execCommand: async () => ({
stdout: "",
stderr: "",
code: 0,
killed: false,
}),
sendMultipart: async (_method, _fields, _fileField, filePath, fileName) => {
events.push({ filePath, fileName });
},
recordRuntimeEvent: () => {},
});
await sendVoiceReply({ chatId: 10, replyToMessageId: 20 }, "hello");
const fileEvent = events.find(
(e) => (e as Record<string, unknown>).filePath !== undefined,
) as Record<string, unknown>;
assert.equal(fileEvent.filePath, "/tmp/response.opus");
assert.equal(fileEvent.fileName, "response.opus");
dispose();
});
test("Voice reply sender accepts OGG files", async () => {
const events: unknown[] = [];
const dispose = registerTelegramVoiceSynthesisProvider(
async () => "/tmp/response.ogg",
);
const sendVoiceReply = createTelegramVoiceReplySender({
execCommand: async () => ({
stdout: "",
stderr: "",
code: 0,
killed: false,
}),
sendMultipart: async (_method, _fields, _fileField, filePath, fileName) => {
events.push({ filePath, fileName });
},
recordRuntimeEvent: () => {},
});
await sendVoiceReply({ chatId: 10, replyToMessageId: 20 }, "hello");
const fileEvent = events.find(
(e) => (e as Record<string, unknown>).filePath !== undefined,
) as Record<string, unknown>;
assert.equal(fileEvent.filePath, "/tmp/response.ogg");
assert.equal(fileEvent.fileName, "response.ogg");
dispose();
});
test("Voice reply sender throws for non-ogg files", async () => {
const dispose = registerTelegramVoiceSynthesisProvider(
async () => "/tmp/response.mp3",
);
const sendVoiceReply = createTelegramVoiceReplySender({
execCommand: async () => ({
stdout: "",
stderr: "",
code: 0,
killed: false,
}),
sendMultipart: async () => {
throw new Error("should not be called");
},
recordRuntimeEvent: () => {},
});
await assert.rejects(
sendVoiceReply({ chatId: 10, replyToMessageId: 20 }, "hello"),
/Failed to send voice reply: every voice synthesis provider and outbound voice handler failed./,
);
dispose();
});
test("Voice reply sender falls back to next handler when sendMultipart throws", async () => {
const fields: Record<string, unknown>[] = [];
let callCount = 0;
const dispose1 = registerTelegramVoiceSynthesisProvider(
async () => "/tmp/voice1.opus",
);
const dispose2 = registerTelegramVoiceSynthesisProvider(
async () => "/tmp/voice2.opus",
);
const sendVoiceReply = createTelegramVoiceReplySender({
execCommand: async () => ({
stdout: "",
stderr: "",
code: 0,
killed: false,
}),
sendMultipart: async (_method, f) => {
callCount++;
if (callCount === 1) throw new Error("network error");
fields.push(f);
},
recordRuntimeEvent: () => {},
});
await sendVoiceReply({ chatId: 10, replyToMessageId: 20 }, "hello");
assert.equal(callCount, 2);
assert.equal(fields.length, 1);
assert.equal((fields[0] as Record<string, unknown>).chat_id, "10");
dispose1();
dispose2();
});
test("Voice reply sender accepts provider returning { audioPath }", async () => {
const events: unknown[] = [];
const dispose = registerTelegramVoiceSynthesisProvider(async () => ({
audioPath: "/tmp/response.ogg",
}));
const sendVoiceReply = createTelegramVoiceReplySender({
execCommand: async () => ({
stdout: "",
stderr: "",
code: 0,
killed: false,
}),
sendMultipart: async (_method, _fields, _fileField, filePath, fileName) => {
events.push({ filePath, fileName });
},
recordRuntimeEvent: () => {},
});
await sendVoiceReply({ chatId: 10, replyToMessageId: 20 }, "hello");
const fileEvent = events.find(
(e) => (e as Record<string, unknown>).filePath !== undefined,
) as Record<string, unknown>;
assert.equal(fileEvent.filePath, "/tmp/response.ogg");
assert.equal(fileEvent.fileName, "response.ogg");
dispose();
});
test("Voice reply sender passes transcriptText as caption", async () => {
const fields: Record<string, unknown>[] = [];
const dispose = registerTelegramVoiceSynthesisProvider(async () => ({
audioPath: "/tmp/response.ogg",
transcriptText: "Clean text without speech tags",
}));
const sendVoiceReply = createTelegramVoiceReplySender({
execCommand: async () => ({
stdout: "",
stderr: "",
code: 0,
killed: false,
}),
sendMultipart: async (_method, f) => {
fields.push(f);
},
recordRuntimeEvent: () => {},
});
await sendVoiceReply({ chatId: 10, replyToMessageId: 20 }, "hello");
assert.equal(fields.length, 1);
assert.equal((fields[0] as Record<string, unknown>).chat_id, "10");
assert.equal(
(fields[0] as Record<string, unknown>).caption,
"Clean text without speech tags",
);
dispose();
});