openclaw/extensions/telegram/src/bot/delivery.test.ts

897 lines
26 KiB
TypeScript

import type { Bot } from "grammy";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../../../../src/runtime.js";
import { deliverReplies } from "./delivery.js";
const loadWebMedia = vi.fn();
const triggerInternalHook = vi.hoisted(() => vi.fn(async () => {}));
const messageHookRunner = vi.hoisted(() => ({
hasHooks: vi.fn<(name: string) => boolean>(() => false),
runMessageSending: vi.fn(),
runMessageSent: vi.fn(),
}));
const baseDeliveryParams = {
chatId: "123",
token: "tok",
replyToMode: "off",
textLimit: 4000,
} as const;
type DeliverRepliesParams = Parameters<typeof deliverReplies>[0];
type DeliverWithParams = Omit<
DeliverRepliesParams,
"chatId" | "token" | "replyToMode" | "textLimit"
> &
Partial<Pick<DeliverRepliesParams, "replyToMode" | "textLimit">>;
type RuntimeStub = Pick<RuntimeEnv, "error" | "log" | "exit">;
vi.mock("../../../whatsapp/src/media.js", () => ({
loadWebMedia: (...args: unknown[]) => loadWebMedia(...args),
}));
vi.mock("../../../../src/plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () => messageHookRunner,
}));
vi.mock("../../../../src/hooks/internal-hooks.js", async () => {
const actual = await vi.importActual<typeof import("../../../../src/hooks/internal-hooks.js")>(
"../../../../src/hooks/internal-hooks.js",
);
return {
...actual,
triggerInternalHook,
};
});
vi.mock("grammy", () => ({
InputFile: class {
constructor(
public buffer: Buffer,
public fileName?: string,
) {}
},
GrammyError: class GrammyError extends Error {
description = "";
},
}));
function createRuntime(withLog = true): RuntimeStub {
return {
error: vi.fn(),
log: withLog ? vi.fn() : vi.fn(),
exit: vi.fn(),
};
}
function createBot(api: Record<string, unknown> = {}): Bot {
return { api } as unknown as Bot;
}
async function deliverWith(params: DeliverWithParams) {
await deliverReplies({
...baseDeliveryParams,
...params,
});
}
function mockMediaLoad(fileName: string, contentType: string, data: string) {
loadWebMedia.mockResolvedValueOnce({
buffer: Buffer.from(data),
contentType,
fileName,
});
}
function createSendMessageHarness(messageId = 4) {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({
message_id: messageId,
chat: { id: "123" },
});
const bot = createBot({ sendMessage });
return { runtime, sendMessage, bot };
}
function createVoiceMessagesForbiddenError() {
return new Error(
"GrammyError: Call to 'sendVoice' failed! (400: Bad Request: VOICE_MESSAGES_FORBIDDEN)",
);
}
function createThreadNotFoundError(operation = "sendMessage") {
return new Error(
`GrammyError: Call to '${operation}' failed! (400: Bad Request: message thread not found)`,
);
}
function createVoiceFailureHarness(params: {
voiceError: Error;
sendMessageResult?: { message_id: number; chat: { id: string } };
}) {
const runtime = createRuntime();
const sendVoice = vi.fn().mockRejectedValue(params.voiceError);
const sendMessage = params.sendMessageResult
? vi.fn().mockResolvedValue(params.sendMessageResult)
: vi.fn();
const bot = createBot({ sendVoice, sendMessage });
return { runtime, sendVoice, sendMessage, bot };
}
describe("deliverReplies", () => {
beforeEach(() => {
loadWebMedia.mockClear();
triggerInternalHook.mockReset();
messageHookRunner.hasHooks.mockReset();
messageHookRunner.hasHooks.mockReturnValue(false);
messageHookRunner.runMessageSending.mockReset();
messageHookRunner.runMessageSent.mockReset();
});
it("skips audioAsVoice-only payloads without logging an error", async () => {
const runtime = createRuntime(false);
await deliverWith({
replies: [{ audioAsVoice: true }],
runtime,
bot: createBot(),
});
expect(runtime.error).not.toHaveBeenCalled();
});
it("skips malformed replies and continues with valid entries", async () => {
const runtime = createRuntime(false);
const sendMessage = vi.fn().mockResolvedValue({ message_id: 1, chat: { id: "123" } });
const bot = createBot({ sendMessage });
await deliverWith({
replies: [undefined, { text: "hello" }] as unknown as DeliverRepliesParams["replies"],
runtime,
bot,
});
expect(runtime.error).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage.mock.calls[0]?.[1]).toBe("hello");
});
it("reports message_sent success=false when hooks blank out a text-only reply", async () => {
messageHookRunner.hasHooks.mockImplementation(
(name: string) => name === "message_sending" || name === "message_sent",
);
messageHookRunner.runMessageSending.mockResolvedValue({ content: "" });
const runtime = createRuntime(false);
const sendMessage = vi.fn();
const bot = createBot({ sendMessage });
await deliverWith({
replies: [{ text: "hello" }],
runtime,
bot,
});
expect(sendMessage).not.toHaveBeenCalled();
expect(messageHookRunner.runMessageSent).toHaveBeenCalledWith(
expect.objectContaining({ success: false, content: "" }),
expect.objectContaining({ channelId: "telegram", conversationId: "123" }),
);
});
it("passes accountId into message hooks", async () => {
messageHookRunner.hasHooks.mockImplementation(
(name: string) => name === "message_sending" || name === "message_sent",
);
const runtime = createRuntime(false);
const sendMessage = vi.fn().mockResolvedValue({ message_id: 9, chat: { id: "123" } });
const bot = createBot({ sendMessage });
await deliverWith({
accountId: "work",
replies: [{ text: "hello" }],
runtime,
bot,
});
expect(messageHookRunner.runMessageSending).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
channelId: "telegram",
accountId: "work",
conversationId: "123",
}),
);
expect(messageHookRunner.runMessageSent).toHaveBeenCalledWith(
expect.objectContaining({ success: true }),
expect.objectContaining({
channelId: "telegram",
accountId: "work",
conversationId: "123",
}),
);
});
it("emits internal message:sent when session hook context is available", async () => {
const runtime = createRuntime(false);
const sendMessage = vi.fn().mockResolvedValue({ message_id: 9, chat: { id: "123" } });
const bot = createBot({ sendMessage });
await deliverWith({
sessionKeyForInternalHooks: "agent:test:telegram:123",
mirrorIsGroup: true,
mirrorGroupId: "123",
replies: [{ text: "hello" }],
runtime,
bot,
});
expect(triggerInternalHook).toHaveBeenCalledWith(
expect.objectContaining({
type: "message",
action: "sent",
sessionKey: "agent:test:telegram:123",
context: expect.objectContaining({
to: "123",
content: "hello",
success: true,
channelId: "telegram",
conversationId: "123",
messageId: "9",
isGroup: true,
groupId: "123",
}),
}),
);
});
it("does not emit internal message:sent without a session key", async () => {
const runtime = createRuntime(false);
const sendMessage = vi.fn().mockResolvedValue({ message_id: 11, chat: { id: "123" } });
const bot = createBot({ sendMessage });
await deliverWith({
replies: [{ text: "hello" }],
runtime,
bot,
});
expect(triggerInternalHook).not.toHaveBeenCalled();
});
it("emits internal message:sent with success=false on delivery failure", async () => {
const runtime = createRuntime(false);
const sendMessage = vi.fn().mockRejectedValue(new Error("network error"));
const bot = createBot({ sendMessage });
await expect(
deliverWith({
sessionKeyForInternalHooks: "agent:test:telegram:123",
replies: [{ text: "hello" }],
runtime,
bot,
}),
).rejects.toThrow("network error");
expect(triggerInternalHook).toHaveBeenCalledWith(
expect.objectContaining({
type: "message",
action: "sent",
sessionKey: "agent:test:telegram:123",
context: expect.objectContaining({
to: "123",
content: "hello",
success: false,
error: "network error",
channelId: "telegram",
conversationId: "123",
}),
}),
);
});
it("passes media metadata to message_sending hooks", async () => {
messageHookRunner.hasHooks.mockImplementation((name: string) => name === "message_sending");
const runtime = createRuntime(false);
const sendPhoto = vi.fn().mockResolvedValue({ message_id: 2, chat: { id: "123" } });
const bot = createBot({ sendPhoto });
mockMediaLoad("photo.jpg", "image/jpeg", "image");
await deliverWith({
replies: [{ text: "caption", mediaUrl: "https://example.com/photo.jpg" }],
runtime,
bot,
});
expect(messageHookRunner.runMessageSending).toHaveBeenCalledWith(
expect.objectContaining({
to: "123",
content: "caption",
metadata: expect.objectContaining({
channel: "telegram",
mediaUrls: ["https://example.com/photo.jpg"],
}),
}),
expect.objectContaining({ channelId: "telegram", conversationId: "123" }),
);
});
it("invokes onVoiceRecording before sending a voice note", async () => {
const events: string[] = [];
const runtime = createRuntime(false);
const sendVoice = vi.fn(async () => {
events.push("sendVoice");
return { message_id: 1, chat: { id: "123" } };
});
const bot = createBot({ sendVoice });
const onVoiceRecording = vi.fn(async () => {
events.push("recordVoice");
});
mockMediaLoad("note.ogg", "audio/ogg", "voice");
await deliverWith({
replies: [{ mediaUrl: "https://example.com/note.ogg", audioAsVoice: true }],
runtime,
bot,
onVoiceRecording,
});
expect(onVoiceRecording).toHaveBeenCalledTimes(1);
expect(sendVoice).toHaveBeenCalledTimes(1);
expect(events).toEqual(["recordVoice", "sendVoice"]);
});
it("renders markdown in media captions", async () => {
const runtime = createRuntime();
const sendPhoto = vi.fn().mockResolvedValue({
message_id: 2,
chat: { id: "123" },
});
const bot = createBot({ sendPhoto });
mockMediaLoad("photo.jpg", "image/jpeg", "image");
await deliverWith({
replies: [{ mediaUrl: "https://example.com/photo.jpg", text: "hi **boss**" }],
runtime,
bot,
});
expect(sendPhoto).toHaveBeenCalledWith(
"123",
expect.anything(),
expect.objectContaining({
caption: "hi <b>boss</b>",
parse_mode: "HTML",
}),
);
});
it("passes mediaLocalRoots to media loading", async () => {
const runtime = createRuntime();
const sendPhoto = vi.fn().mockResolvedValue({
message_id: 12,
chat: { id: "123" },
});
const bot = createBot({ sendPhoto });
const mediaLocalRoots = ["/tmp/workspace-work"];
mockMediaLoad("photo.jpg", "image/jpeg", "image");
await deliverWith({
replies: [{ mediaUrl: "/tmp/workspace-work/photo.jpg" }],
runtime,
bot,
mediaLocalRoots,
});
expect(loadWebMedia).toHaveBeenCalledWith("/tmp/workspace-work/photo.jpg", {
localRoots: mediaLocalRoots,
});
});
it("passes Telegram transport to remote media loading", async () => {
const runtime = createRuntime();
const sendPhoto = vi.fn().mockResolvedValue({
message_id: 13,
chat: { id: "123" },
});
const bot = createBot({ sendPhoto });
const sourceFetch = vi.fn() as unknown as typeof fetch;
const telegramTransport = {
fetch: sourceFetch,
sourceFetch,
pinnedDispatcherPolicy: {
mode: "explicit-proxy",
proxyUrl: "http://proxy.test:8080",
} as const,
fallbackPinnedDispatcherPolicy: { mode: "direct" } as const,
};
mockMediaLoad("photo.jpg", "image/jpeg", "image");
await deliverWith({
replies: [{ mediaUrl: "https://example.com/photo.jpg" }],
runtime,
bot,
telegramTransport,
});
expect(loadWebMedia).toHaveBeenCalledWith(
"https://example.com/photo.jpg",
expect.objectContaining({
fetchImpl: sourceFetch,
dispatcherPolicy: telegramTransport.pinnedDispatcherPolicy,
fallbackDispatcherPolicy: telegramTransport.fallbackPinnedDispatcherPolicy,
shouldRetryFetchError: expect.any(Function),
}),
);
});
it("includes link_preview_options when linkPreview is false", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({
message_id: 3,
chat: { id: "123" },
});
const bot = createBot({ sendMessage });
await deliverWith({
replies: [{ text: "Check https://example.com" }],
runtime,
bot,
linkPreview: false,
});
expect(sendMessage).toHaveBeenCalledWith(
"123",
expect.any(String),
expect.objectContaining({
link_preview_options: { is_disabled: true },
}),
);
});
it("includes message_thread_id for DM topics", async () => {
const { runtime, sendMessage, bot } = createSendMessageHarness();
await deliverWith({
replies: [{ text: "Hello" }],
runtime,
bot,
thread: { id: 42, scope: "dm" },
});
expect(sendMessage).toHaveBeenCalledWith(
"123",
expect.any(String),
expect.objectContaining({
message_thread_id: 42,
}),
);
});
it("retries DM topic sends without message_thread_id when thread is missing", async () => {
const runtime = createRuntime();
const sendMessage = vi
.fn()
.mockRejectedValueOnce(createThreadNotFoundError("sendMessage"))
.mockResolvedValueOnce({
message_id: 7,
chat: { id: "123" },
});
const bot = createBot({ sendMessage });
await deliverWith({
replies: [{ text: "hello" }],
runtime,
bot,
thread: { id: 42, scope: "dm" },
});
expect(sendMessage).toHaveBeenCalledTimes(2);
expect(sendMessage.mock.calls[0]?.[2]).toEqual(
expect.objectContaining({
message_thread_id: 42,
}),
);
expect(sendMessage.mock.calls[1]?.[2]).not.toHaveProperty("message_thread_id");
expect(runtime.error).not.toHaveBeenCalled();
});
it("does not retry forum sends without message_thread_id", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockRejectedValue(createThreadNotFoundError("sendMessage"));
const bot = createBot({ sendMessage });
await expect(
deliverWith({
replies: [{ text: "hello" }],
runtime,
bot,
thread: { id: 42, scope: "forum" },
}),
).rejects.toThrow("message thread not found");
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(runtime.error).toHaveBeenCalledTimes(1);
});
it("retries media sends without message_thread_id for DM topics", async () => {
const runtime = createRuntime();
const sendPhoto = vi
.fn()
.mockRejectedValueOnce(createThreadNotFoundError("sendPhoto"))
.mockResolvedValueOnce({
message_id: 8,
chat: { id: "123" },
});
const bot = createBot({ sendPhoto });
mockMediaLoad("photo.jpg", "image/jpeg", "image");
await deliverWith({
replies: [{ mediaUrl: "https://example.com/photo.jpg", text: "caption" }],
runtime,
bot,
thread: { id: 42, scope: "dm" },
});
expect(sendPhoto).toHaveBeenCalledTimes(2);
expect(sendPhoto.mock.calls[0]?.[2]).toEqual(
expect.objectContaining({
message_thread_id: 42,
}),
);
expect(sendPhoto.mock.calls[1]?.[2]).not.toHaveProperty("message_thread_id");
expect(runtime.error).not.toHaveBeenCalled();
});
it("does not include link_preview_options when linkPreview is true", async () => {
const { runtime, sendMessage, bot } = createSendMessageHarness();
await deliverWith({
replies: [{ text: "Check https://example.com" }],
runtime,
bot,
linkPreview: true,
});
expect(sendMessage).toHaveBeenCalledWith(
"123",
expect.any(String),
expect.not.objectContaining({
link_preview_options: expect.anything(),
}),
);
});
it("falls back to plain text when markdown renders to empty HTML in threaded mode", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn(async (_chatId: string, text: string) => {
if (text === "") {
throw new Error("400: Bad Request: message text is empty");
}
return {
message_id: 6,
chat: { id: "123" },
};
});
const bot = { api: { sendMessage } } as unknown as Bot;
await deliverReplies({
replies: [{ text: ">" }],
chatId: "123",
token: "tok",
runtime,
bot,
replyToMode: "off",
textLimit: 4000,
thread: { id: 42, scope: "forum" },
});
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
"123",
">",
expect.objectContaining({
message_thread_id: 42,
}),
);
});
it("throws when formatted and plain fallback text are both empty", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn();
const bot = { api: { sendMessage } } as unknown as Bot;
await expect(
deliverReplies({
replies: [{ text: " " }],
chatId: "123",
token: "tok",
runtime,
bot,
replyToMode: "off",
textLimit: 4000,
}),
).rejects.toThrow("empty formatted text and empty plain fallback");
expect(sendMessage).not.toHaveBeenCalled();
});
it("uses reply_to_message_id when quote text is provided", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({
message_id: 10,
chat: { id: "123" },
});
const bot = createBot({ sendMessage });
await deliverWith({
replies: [{ text: "Hello there", replyToId: "500" }],
runtime,
bot,
replyToMode: "all",
replyQuoteText: "quoted text",
});
expect(sendMessage).toHaveBeenCalledWith(
"123",
expect.any(String),
expect.objectContaining({
reply_to_message_id: 500,
}),
);
expect(sendMessage).toHaveBeenCalledWith(
"123",
expect.any(String),
expect.not.objectContaining({
reply_parameters: expect.anything(),
}),
);
});
it("falls back to text when sendVoice fails with VOICE_MESSAGES_FORBIDDEN", async () => {
const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({
voiceError: createVoiceMessagesForbiddenError(),
sendMessageResult: {
message_id: 5,
chat: { id: "123" },
},
});
mockMediaLoad("note.ogg", "audio/ogg", "voice");
await deliverWith({
replies: [
{ mediaUrl: "https://example.com/note.ogg", text: "Hello there", audioAsVoice: true },
],
runtime,
bot,
});
// 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("voice fallback applies reply-to only on first chunk when replyToMode is first", async () => {
const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({
voiceError: createVoiceMessagesForbiddenError(),
sendMessageResult: {
message_id: 6,
chat: { id: "123" },
},
});
mockMediaLoad("note.ogg", "audio/ogg", "voice");
await deliverWith({
replies: [
{
mediaUrl: "https://example.com/note.ogg",
text: "chunk-one\n\nchunk-two",
replyToId: "77",
audioAsVoice: true,
channelData: {
telegram: {
buttons: [[{ text: "Ack", callback_data: "ack" }]],
},
},
},
],
runtime,
bot,
replyToMode: "first",
replyQuoteText: "quoted context",
textLimit: 12,
});
expect(sendVoice).toHaveBeenCalledTimes(1);
expect(sendMessage.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(sendMessage.mock.calls[0][2]).toEqual(
expect.objectContaining({
reply_to_message_id: 77,
reply_markup: {
inline_keyboard: [[{ text: "Ack", callback_data: "ack" }]],
},
}),
);
expect(sendMessage.mock.calls[1][2]).not.toEqual(
expect.objectContaining({ reply_to_message_id: 77 }),
);
expect(sendMessage.mock.calls[1][2]).not.toHaveProperty("reply_parameters");
expect(sendMessage.mock.calls[1][2]).not.toHaveProperty("reply_markup");
});
it("rethrows non-VOICE_MESSAGES_FORBIDDEN errors from sendVoice", async () => {
const runtime = createRuntime();
const sendVoice = vi.fn().mockRejectedValue(new Error("Network error"));
const sendMessage = vi.fn();
const bot = createBot({ sendVoice, sendMessage });
mockMediaLoad("note.ogg", "audio/ogg", "voice");
await expect(
deliverWith({
replies: [{ mediaUrl: "https://example.com/note.ogg", text: "Hello", audioAsVoice: true }],
runtime,
bot,
}),
).rejects.toThrow("Network error");
expect(sendVoice).toHaveBeenCalledTimes(1);
// Text fallback should NOT be attempted for other errors
expect(sendMessage).not.toHaveBeenCalled();
});
it("replyToMode 'first' only applies reply-to to the first text chunk", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({
message_id: 20,
chat: { id: "123" },
});
const bot = createBot({ sendMessage });
// Use a small textLimit to force multiple chunks
await deliverReplies({
replies: [{ text: "chunk-one\n\nchunk-two", replyToId: "700" }],
chatId: "123",
token: "tok",
runtime,
bot,
replyToMode: "first",
textLimit: 12,
});
expect(sendMessage.mock.calls.length).toBeGreaterThanOrEqual(2);
// First chunk should have reply_to_message_id
expect(sendMessage.mock.calls[0][2]).toEqual(
expect.objectContaining({ reply_to_message_id: 700 }),
);
// Second chunk should NOT have reply_to_message_id
expect(sendMessage.mock.calls[1][2]).not.toHaveProperty("reply_to_message_id");
});
it("replyToMode 'all' applies reply-to to every text chunk", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({
message_id: 21,
chat: { id: "123" },
});
const bot = createBot({ sendMessage });
await deliverReplies({
replies: [{ text: "chunk-one\n\nchunk-two", replyToId: "800" }],
chatId: "123",
token: "tok",
runtime,
bot,
replyToMode: "all",
textLimit: 12,
});
expect(sendMessage.mock.calls.length).toBeGreaterThanOrEqual(2);
// Both chunks should have reply_to_message_id
for (const call of sendMessage.mock.calls) {
expect(call[2]).toEqual(expect.objectContaining({ reply_to_message_id: 800 }));
}
});
it("replyToMode 'first' only applies reply-to to first media item", async () => {
const runtime = createRuntime();
const sendPhoto = vi.fn().mockResolvedValue({
message_id: 30,
chat: { id: "123" },
});
const bot = createBot({ sendPhoto });
mockMediaLoad("a.jpg", "image/jpeg", "img1");
mockMediaLoad("b.jpg", "image/jpeg", "img2");
await deliverReplies({
replies: [{ mediaUrls: ["https://a.jpg", "https://b.jpg"], replyToId: "900" }],
chatId: "123",
token: "tok",
runtime,
bot,
replyToMode: "first",
textLimit: 4000,
});
expect(sendPhoto).toHaveBeenCalledTimes(2);
// First media should have reply_to_message_id
expect(sendPhoto.mock.calls[0][2]).toEqual(
expect.objectContaining({ reply_to_message_id: 900 }),
);
// Second media should NOT have reply_to_message_id
expect(sendPhoto.mock.calls[1][2]).not.toHaveProperty("reply_to_message_id");
});
it("pins the first delivered text message when telegram pin is requested", async () => {
const runtime = createRuntime();
const sendMessage = vi
.fn()
.mockResolvedValueOnce({ message_id: 101, chat: { id: "123" } })
.mockResolvedValueOnce({ message_id: 102, chat: { id: "123" } });
const pinChatMessage = vi.fn().mockResolvedValue(true);
const bot = createBot({ sendMessage, pinChatMessage });
await deliverReplies({
replies: [{ text: "chunk-one\n\nchunk-two", channelData: { telegram: { pin: true } } }],
chatId: "123",
token: "tok",
runtime,
bot,
replyToMode: "off",
textLimit: 12,
});
expect(pinChatMessage).toHaveBeenCalledTimes(1);
expect(pinChatMessage).toHaveBeenCalledWith("123", 101, { disable_notification: true });
});
it("continues when pinning fails", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({ message_id: 201, chat: { id: "123" } });
const pinChatMessage = vi.fn().mockRejectedValue(new Error("pin failed"));
const bot = createBot({ sendMessage, pinChatMessage });
await deliverWith({
replies: [{ text: "hello", channelData: { telegram: { pin: true } } }],
runtime,
bot,
});
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(pinChatMessage).toHaveBeenCalledTimes(1);
});
it("rethrows VOICE_MESSAGES_FORBIDDEN when no text fallback is available", async () => {
const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({
voiceError: createVoiceMessagesForbiddenError(),
});
mockMediaLoad("note.ogg", "audio/ogg", "voice");
await expect(
deliverWith({
replies: [{ mediaUrl: "https://example.com/note.ogg", audioAsVoice: true }],
runtime,
bot,
}),
).rejects.toThrow("VOICE_MESSAGES_FORBIDDEN");
expect(sendVoice).toHaveBeenCalledTimes(1);
expect(sendMessage).not.toHaveBeenCalled();
});
});