diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff3c9e9e..3b378a163 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ Docs: https://docs.clawd.bot - Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338. - Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639) - Telegram: honor per-account proxy for outbound API calls. (#1774) Thanks @radek-paclt. +- Telegram: fall back to text when voice notes are blocked by privacy settings. (#1725) Thanks @foeken. - Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634) - Voice Call: serialize Twilio TTS playback and cancel on barge-in to prevent overlap. (#1713) Thanks @dguido. - Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy. diff --git a/src/telegram/bot/delivery.test.ts b/src/telegram/bot/delivery.test.ts index ca1b3f4cd..359ba12b3 100644 --- a/src/telegram/bot/delivery.test.ts +++ b/src/telegram/bot/delivery.test.ts @@ -164,4 +164,111 @@ describe("deliverReplies", () => { }), ); }); + + it("falls back to text when sendVoice fails with VOICE_MESSAGES_FORBIDDEN", async () => { + const runtime = { error: vi.fn(), log: vi.fn() }; + const sendVoice = vi + .fn() + .mockRejectedValue( + new Error( + "GrammyError: Call to 'sendVoice' failed! (400: Bad Request: VOICE_MESSAGES_FORBIDDEN)", + ), + ); + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 5, + chat: { id: "123" }, + }); + const bot = { api: { sendVoice, sendMessage } } as unknown as Bot; + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("voice"), + contentType: "audio/ogg", + fileName: "note.ogg", + }); + + await deliverReplies({ + replies: [ + { mediaUrl: "https://example.com/note.ogg", text: "Hello there", audioAsVoice: true }, + ], + chatId: "123", + token: "tok", + runtime, + bot, + replyToMode: "off", + textLimit: 4000, + }); + + // Voice was attempted but failed + expect(sendVoice).toHaveBeenCalledTimes(1); + // Fallback to text succeeded + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith( + "123", + expect.stringContaining("Hello there"), + expect.any(Object), + ); + }); + + it("rethrows non-VOICE_MESSAGES_FORBIDDEN errors from sendVoice", async () => { + const runtime = { error: vi.fn(), log: vi.fn() }; + const sendVoice = vi.fn().mockRejectedValue(new Error("Network error")); + const sendMessage = vi.fn(); + const bot = { api: { sendVoice, sendMessage } } as unknown as Bot; + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("voice"), + contentType: "audio/ogg", + fileName: "note.ogg", + }); + + await expect( + deliverReplies({ + replies: [{ mediaUrl: "https://example.com/note.ogg", text: "Hello", audioAsVoice: true }], + chatId: "123", + token: "tok", + runtime, + bot, + replyToMode: "off", + textLimit: 4000, + }), + ).rejects.toThrow("Network error"); + + expect(sendVoice).toHaveBeenCalledTimes(1); + // Text fallback should NOT be attempted for other errors + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it("rethrows VOICE_MESSAGES_FORBIDDEN when no text fallback is available", async () => { + const runtime = { error: vi.fn(), log: vi.fn() }; + const sendVoice = vi + .fn() + .mockRejectedValue( + new Error( + "GrammyError: Call to 'sendVoice' failed! (400: Bad Request: VOICE_MESSAGES_FORBIDDEN)", + ), + ); + const sendMessage = vi.fn(); + const bot = { api: { sendVoice, sendMessage } } as unknown as Bot; + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("voice"), + contentType: "audio/ogg", + fileName: "note.ogg", + }); + + await expect( + deliverReplies({ + replies: [{ mediaUrl: "https://example.com/note.ogg", audioAsVoice: true }], + chatId: "123", + token: "tok", + runtime, + bot, + replyToMode: "off", + textLimit: 4000, + }), + ).rejects.toThrow("VOICE_MESSAGES_FORBIDDEN"); + + expect(sendVoice).toHaveBeenCalledTimes(1); + expect(sendMessage).not.toHaveBeenCalled(); + }); }); diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 2d117d748..c2b1afcf3 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -155,9 +155,44 @@ export async function deliverReplies(params: { // Voice message - displays as round playable bubble (opt-in via [[audio_as_voice]]) // Switch typing indicator to record_voice before sending. await params.onVoiceRecording?.(); - await bot.api.sendVoice(chatId, file, { - ...mediaParams, - }); + try { + await bot.api.sendVoice(chatId, file, { + ...mediaParams, + }); + } catch (voiceErr) { + // Fall back to text if voice messages are forbidden in this chat. + // This happens when the recipient has Telegram Premium privacy settings + // that block voice messages (Settings > Privacy > Voice Messages). + const errMsg = formatErrorMessage(voiceErr); + if (errMsg.includes("VOICE_MESSAGES_FORBIDDEN")) { + if (!reply.text?.trim()) { + throw voiceErr; + } + logVerbose( + "telegram sendVoice forbidden (recipient has voice messages blocked in privacy settings); falling back to text", + ); + // Send the text content instead of the voice message. + if (reply.text) { + const chunks = chunkText(reply.text); + for (const chunk of chunks) { + await sendTelegramText(bot, chatId, chunk.html, runtime, { + replyToMessageId: + replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined, + messageThreadId, + textMode: "html", + plainText: chunk.text, + linkPreview, + }); + if (replyToId && !hasReplied) { + hasReplied = true; + } + } + } + // Skip this media item; continue with next. + continue; + } + throw voiceErr; + } } else { // Audio file - displays with metadata (title, duration) - DEFAULT await bot.api.sendAudio(chatId, file, {