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:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user