/** * 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.", "", ""].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.", "", "", ].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.", "", "", "Первая строка.", "Вторая строка.", "", ].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", ); 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', ); 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.", "", "", "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.", "", "", "", "", ].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.", "", "", "", "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.", "", "", "", "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\n\nVisible tail.", ), "Visible text.\n\nVisible tail.", ); assert.equal( stripTelegramCommentMarkupForPreview( [ "Visible text.", "", '', "Hidden prompt.", "-->", ].join("\n"), ), "Visible text.", ); assert.equal( stripTelegramCommentMarkupForPreview("Visible text.\n\n<"), "Visible text.", ); assert.equal( stripTelegramCommentMarkupForPreview("Visible text.\n\n { const markdown = [ "Example:", "", "```md", "", "", '", "```", ].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", "", " ````", "", "", ].join("\n"); const actions: unknown[] = []; const plan = planTelegramButtonReply(markdown, { registerAction: (action) => { actions.push(action); return `btn:${actions.length}`; }, }); assert.equal( plan.markdown, [ "Example:", "", "````md", "", " ````", ].join("\n"), ); assert.deepEqual(actions, [{ text: "OK", prompt: "OK" }]); }); test("Outbound action comments require top-level column-zero markers", () => { const markdown = [ "Visible answer.", "", " ", "", '> ", ].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.", "", '", "", "", ].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.", "", "", "", '', ].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.", "", ""].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.", "", '', ].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.", "", '', "Where does JAM end and DEOS begin?", "-->", "", '', "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.", "", '', "", "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.", "", '', "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; 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[] = []; 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).reply_markup === "string", ); dispose(); }); test("Voice reply sender only passes replyMarkup to first successful voice reply", async () => { const fields: Record[] = []; 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).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).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).filePath !== undefined, ) as Record; 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).filePath !== undefined, ) as Record; 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[] = []; 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).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).filePath !== undefined, ) as Record; 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[] = []; 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).chat_id, "10"); assert.equal( (fields[0] as Record).caption, "Clean text without speech tags", ); dispose(); });