fix(telegram): fall back to text when voice messages forbidden (#1725)

* fix(telegram): fall back to text when voice messages forbidden

When TTS auto mode is enabled, slash commands like /status would fail
silently because sendVoice was rejected with VOICE_MESSAGES_FORBIDDEN.
The entire reply would fail without any text being sent.

This adds error handling to catch VOICE_MESSAGES_FORBIDDEN specifically
and fall back to sending the text content as a regular message instead
of failing completely.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: handle telegram voice fallback errors (#1725) (thanks @foeken)

---------

Co-authored-by: Echo <andre.foeken@Donut.local>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Andre Foeken
2026-01-25 14:18:41 +01:00
committed by GitHub
parent 8257ec6a1f
commit 9bd5def32c
3 changed files with 146 additions and 3 deletions

View File

@@ -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.

View File

@@ -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();
});
});

View File

@@ -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, {