diff --git a/CHANGELOG.md b/CHANGELOG.md index 31b5f65340..fe08e92831 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,9 @@ Docs: https://docs.openclaw.ai - Telegram: remove last `@ts-nocheck` from `bot-handlers.ts`, use Grammy types directly, deduplicate `StickerMetadata`. Zero `@ts-nocheck` remaining in `src/telegram/`. (#9206) - Telegram: remove `@ts-nocheck` from `bot-message.ts`, type deps via `Omit`, widen `allMedia` to `TelegramMediaRef[]`. (#9180) - Telegram: remove `@ts-nocheck` from `bot.ts`, fix duplicate `bot.catch` error handler (Grammy overrides), remove dead reaction `message_thread_id` routing, harden sticker cache guard. (#9077) +- Telegram: allow per-group and per-topic `groupPolicy` overrides under `channels.telegram.groups`. (#9775) Thanks @nicolasstanley. +- Telegram: add video note support (`asVideoNote: true`) for media sends, with docs + tests. (#7902) Thanks @thewulf7. +- Feishu: expand channel handling (posts with images, doc links, routing, reactions/typing, replies, native commands). (#8975) Thanks @jiulingyun. - Onboarding: add Cloudflare AI Gateway provider setup and docs. (#7914) Thanks @roerohan. - Onboarding: add Moonshot (.cn) auth choice and keep the China base URL when preserving defaults. (#7180) Thanks @waynelwz. - Docs: clarify tmux send-keys for TUI by splitting text and Enter. (#7737) Thanks @Wangnov. 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..141780d431 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) */ @@ -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.trim() ? text : undefined; + } 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. @@ -401,14 +414,14 @@ export async function sendMessageTelegram( ...(!needsSeparateText && replyMarkup ? { reply_markup: replyMarkup } : {}), }; const mediaParams = { - caption: htmlCaption, - ...(htmlCaption ? { parse_mode: "HTML" as const } : {}), + ...(htmlCaption ? { caption: htmlCaption, parse_mode: "HTML" as const } : {}), ...baseMediaParams, ...(opts.silent === true ? { disable_notification: true } : {}), }; let result: | Awaited> | Awaited> + | Awaited> | Awaited> | Awaited> | Awaited> @@ -440,14 +453,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) diff --git a/src/telegram/send.video-note.test.ts b/src/telegram/send.video-note.test.ts new file mode 100644 index 0000000000..6a42305f6a --- /dev/null +++ b/src/telegram/send.video-note.test.ts @@ -0,0 +1,219 @@ +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(), {}); + + // 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: expect.any(String), + 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(), {}); + + // Text message gets reply 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(), { + reply_to_message_id: 999, + }); + + // Text threaded + expect(sendMessage).toHaveBeenCalledWith(chatId, text, { + parse_mode: "HTML", + reply_to_message_id: 999, + }); + }); +});