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: 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: 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: 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: 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.
|
- 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.
|
- 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]])
|
// Voice message - displays as round playable bubble (opt-in via [[audio_as_voice]])
|
||||||
// Switch typing indicator to record_voice before sending.
|
// Switch typing indicator to record_voice before sending.
|
||||||
await params.onVoiceRecording?.();
|
await params.onVoiceRecording?.();
|
||||||
await bot.api.sendVoice(chatId, file, {
|
try {
|
||||||
...mediaParams,
|
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 {
|
} else {
|
||||||
// Audio file - displays with metadata (title, duration) - DEFAULT
|
// Audio file - displays with metadata (title, duration) - DEFAULT
|
||||||
await bot.api.sendAudio(chatId, file, {
|
await bot.api.sendAudio(chatId, file, {
|
||||||
|
|||||||
Reference in New Issue
Block a user