diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 31a61fc042..04a0102a30 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -463,6 +463,25 @@ For message tool sends, set `asVoice: true` with a voice-compatible audio `media } ``` +## Video messages (video vs video note) + +Telegram distinguishes **video notes** (round bubble) from **video files** (rectangular). +OpenClaw defaults to video files. + +For message tool sends, set `asVideoNote: true` with a video `media` URL: + +```json5 +{ + action: "send", + channel: "telegram", + to: "123456789", + media: "https://example.com/video.mp4", + asVideoNote: true, +} +``` + +(Note: Video notes do not support captions. If you provide a message text, it will be sent as a separate message.) + ## Stickers OpenClaw supports receiving and sending Telegram stickers with intelligent caching. diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 3d1d32bb82..22c9522dcc 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -42,6 +42,8 @@ type TelegramSendOpts = { plainText?: string; /** Send audio as voice message (voice bubble) instead of audio file. Defaults to false. */ asVoice?: boolean; + /** Send video as video note (voice bubble) instead of regular video. Defaults to false. */ + asVideoNote?: boolean; /** Send message silently (no notification). Defaults to false. */ silent?: boolean; /** Message ID to reply to (for threading) */ @@ -75,7 +77,7 @@ const diagLogger = createSubsystemLogger("telegram/diagnostic"); function createTelegramHttpLogger(cfg: ReturnType) { const enabled = isDiagnosticFlagEnabled("telegram.http", cfg); if (!enabled) { - return () => {}; + return () => { }; } return (label: string, err: unknown) => { if (!(err instanceof HttpError)) { @@ -96,14 +98,14 @@ function resolveTelegramClientOptions( }); const timeoutSeconds = typeof account.config.timeoutSeconds === "number" && - Number.isFinite(account.config.timeoutSeconds) + Number.isFinite(account.config.timeoutSeconds) ? Math.max(1, Math.floor(account.config.timeoutSeconds)) : undefined; return fetchImpl || timeoutSeconds ? { - ...(fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : {}), - ...(timeoutSeconds ? { timeoutSeconds } : {}), - } + ...(fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : {}), + ...(timeoutSeconds ? { timeoutSeconds } : {}), + } : undefined; } @@ -387,9 +389,20 @@ export async function sendMessageTelegram( contentType: media.contentType, fileName: media.fileName, }); + const isVideoNote = kind === "video" && opts.asVideoNote === true; const fileName = media.fileName ?? (isGif ? "animation.gif" : inferFilename(kind)) ?? "file"; const file = new InputFile(media.buffer, fileName); - const { caption, followUpText } = splitTelegramCaption(text); + let caption: string | undefined; + let followUpText: string | undefined; + + if (isVideoNote) { + caption = undefined; + followUpText = text; + } else { + const split = splitTelegramCaption(text); + caption = split.caption; + followUpText = split.followUpText; + } const htmlCaption = caption ? renderHtmlText(caption) : undefined; // If text exceeds Telegram's caption limit, send media without caption // then send text as a separate follow-up message. @@ -409,6 +422,7 @@ export async function sendMessageTelegram( let result: | Awaited> | Awaited> + | Awaited> | Awaited> | Awaited> | Awaited> @@ -440,14 +454,37 @@ export async function sendMessageTelegram( }), ); } else if (kind === "video") { - result = await sendWithThreadFallback(mediaParams, "video", async (effectiveParams, label) => - requestWithDiag( - () => api.sendVideo(chatId, file, effectiveParams as Parameters[2]), - label, - ).catch((err) => { - throw wrapChatNotFound(err); - }), - ); + if (isVideoNote) { + result = await sendWithThreadFallback( + mediaParams, + "video_note", + async (effectiveParams, label) => + requestWithDiag( + () => + api.sendVideoNote( + chatId, + file, + effectiveParams as Parameters[2], + ), + label, + ).catch((err) => { + throw wrapChatNotFound(err); + }), + ); + } else { + result = await sendWithThreadFallback( + mediaParams, + "video", + async (effectiveParams, label) => + requestWithDiag( + () => + api.sendVideo(chatId, file, effectiveParams as Parameters[2]), + label, + ).catch((err) => { + throw wrapChatNotFound(err); + }), + ); + } } else if (kind === "audio") { const { useVoice } = resolveTelegramVoiceSend({ wantsVoice: opts.asVoice === true, // default false (backward compatible) @@ -517,9 +554,9 @@ export async function sendMessageTelegram( const textParams = hasThreadParams || replyMarkup ? { - ...threadParams, - ...(replyMarkup ? { reply_markup: replyMarkup } : {}), - } + ...threadParams, + ...(replyMarkup ? { reply_markup: replyMarkup } : {}), + } : undefined; const textRes = await sendTelegramText(followUpText, textParams); // Return the text message ID as the "main" message (it's the actual content). @@ -538,9 +575,9 @@ export async function sendMessageTelegram( const textParams = hasThreadParams || replyMarkup ? { - ...threadParams, - ...(replyMarkup ? { reply_markup: replyMarkup } : {}), - } + ...threadParams, + ...(replyMarkup ? { reply_markup: replyMarkup } : {}), + } : undefined; const res = await sendTelegramText(text, textParams, opts.plainText); const messageId = String(res?.message_id ?? "unknown"); diff --git a/src/telegram/send.video-note.test.ts b/src/telegram/send.video-note.test.ts new file mode 100644 index 0000000000..495891d916 --- /dev/null +++ b/src/telegram/send.video-note.test.ts @@ -0,0 +1,224 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { botApi, botCtorSpy } = vi.hoisted(() => ({ + botApi: { + sendMessage: vi.fn(), + sendVideo: vi.fn(), + sendVideoNote: vi.fn(), + }, + botCtorSpy: vi.fn(), +})); + +const { loadWebMedia } = vi.hoisted(() => ({ + loadWebMedia: vi.fn(), +})); + +vi.mock("../web/media.js", () => ({ + loadWebMedia, +})); + +vi.mock("grammy", () => ({ + Bot: class { + api = botApi; + catch = vi.fn(); + constructor( + public token: string, + public options?: { + client?: { fetch?: typeof fetch; timeoutSeconds?: number }; + }, + ) { + botCtorSpy(token, options); + } + }, + InputFile: class { }, +})); + +const { loadConfig } = vi.hoisted(() => ({ + loadConfig: vi.fn(() => ({})), +})); +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig, + }; +}); + +import { sendMessageTelegram } from "./send.js"; + +describe("sendMessageTelegram video notes", () => { + beforeEach(() => { + loadConfig.mockReturnValue({}); + loadWebMedia.mockReset(); + botApi.sendMessage.mockReset(); + botApi.sendVideo.mockReset(); + botApi.sendVideoNote.mockReset(); + botCtorSpy.mockReset(); + }); + + it("sends video as video note when asVideoNote is true", async () => { + const chatId = "123"; + const text = "ignored caption context"; // Should be sent separately + + const sendVideoNote = vi.fn().mockResolvedValue({ + message_id: 101, + chat: { id: chatId }, + }); + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 102, + chat: { id: chatId }, + }); + const api = { sendVideoNote, sendMessage } as unknown as { + sendVideoNote: typeof sendVideoNote; + sendMessage: typeof sendMessage; + }; + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("fake-video"), + contentType: "video/mp4", + fileName: "video.mp4", + }); + + const res = await sendMessageTelegram(chatId, text, { + token: "tok", + api, + mediaUrl: "https://example.com/video.mp4", + asVideoNote: true, + }); + + // Video note sent WITHOUT caption (video notes cannot have captions) + expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), { + caption: undefined, + }); + + // Text sent as separate message + expect(sendMessage).toHaveBeenCalledWith(chatId, text, { + parse_mode: "HTML", + }); + + // Returns the text message ID as it is the "main" content with text + expect(res.messageId).toBe("102"); + }); + + it("sends regular video when asVideoNote is false", async () => { + const chatId = "123"; + const text = "my caption"; + + const sendVideo = vi.fn().mockResolvedValue({ + message_id: 201, + chat: { id: chatId }, + }); + const api = { sendVideo } as unknown as { + sendVideo: typeof sendVideo; + }; + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("fake-video"), + contentType: "video/mp4", + fileName: "video.mp4", + }); + + const res = await sendMessageTelegram(chatId, text, { + token: "tok", + api, + mediaUrl: "https://example.com/video.mp4", + asVideoNote: false, + }); + + // Regular video sent WITH caption + expect(sendVideo).toHaveBeenCalledWith(chatId, expect.anything(), { + caption: text, + parse_mode: "HTML", + }); + expect(res.messageId).toBe("201"); + }); + + it("adds reply_markup to separate text message for video notes", async () => { + const chatId = "123"; + const text = "Check this out"; + + const sendVideoNote = vi.fn().mockResolvedValue({ + message_id: 301, + chat: { id: chatId }, + }); + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 302, + chat: { id: chatId }, + }); + const api = { sendVideoNote, sendMessage } as unknown as { + sendVideoNote: typeof sendVideoNote; + sendMessage: typeof sendMessage; + }; + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("fake-video"), + contentType: "video/mp4", + fileName: "video.mp4", + }); + + await sendMessageTelegram(chatId, text, { + token: "tok", + api, + mediaUrl: "https://example.com/video.mp4", + asVideoNote: true, + buttons: [[{ text: "Btn", callback_data: "dat" }]], + }); + + // Video note sent WITHOUT reply_markup (it goes to text) + expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), { + caption: undefined, + }); + + // Text message gets reliability markup + expect(sendMessage).toHaveBeenCalledWith(chatId, text, { + parse_mode: "HTML", + reply_markup: { + inline_keyboard: [[{ text: "Btn", callback_data: "dat" }]], + }, + }); + }); + + it("threads video note and text message correctly", async () => { + const chatId = "123"; + const text = "Threaded reply"; + + const sendVideoNote = vi.fn().mockResolvedValue({ + message_id: 401, + chat: { id: chatId }, + }); + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 402, + chat: { id: chatId }, + }); + const api = { sendVideoNote, sendMessage } as unknown as { + sendVideoNote: typeof sendVideoNote; + sendMessage: typeof sendMessage; + }; + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("fake-video"), + contentType: "video/mp4", + fileName: "video.mp4", + }); + + await sendMessageTelegram(chatId, text, { + token: "tok", + api, + mediaUrl: "https://example.com/video.mp4", + asVideoNote: true, + replyToMessageId: 999, + }); + + // Video note threaded + expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), { + caption: undefined, + reply_to_message_id: 999, + }); + + // Text threaded + expect(sendMessage).toHaveBeenCalledWith(chatId, text, { + parse_mode: "HTML", + reply_to_message_id: 999, + }); + }); +});