feat: Implement Telegram video note support with tests and docs (#12408)

* feat: Implement Telegram video note support with tests and docs

* fixing lint

* feat: add doctor-state-integrity command, Telegram messaging, and PowerShell Docker setup scripts.

* Update src/telegram/send.video-note.test.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix: Set video note follow-up text to undefined for empty input and adjust caption test expectation.

* test: add assertion for `sendMessage` with reply markup and HTML parse mode in `send.video-note` test.

* docs: add changelog entry for Telegram video notes

---------

Co-authored-by: Evgenii Utkin <thewulf7@gmail.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: CLAWDINATOR Bot <clawdinator[bot]@users.noreply.github.com>
This commit is contained in:
clawdinator[bot]
2026-02-09 07:00:57 +00:00
committed by GitHub
parent 6ed255319f
commit fb8e4489a3
4 changed files with 288 additions and 11 deletions

View File

@@ -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<BuildTelegramMessageContextParams>`, 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.

View File

@@ -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.

View File

@@ -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<ReturnType<typeof api.sendPhoto>>
| Awaited<ReturnType<typeof api.sendVideo>>
| Awaited<ReturnType<typeof api.sendVideoNote>>
| Awaited<ReturnType<typeof api.sendAudio>>
| Awaited<ReturnType<typeof api.sendVoice>>
| Awaited<ReturnType<typeof api.sendAnimation>>
@@ -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<typeof api.sendVideo>[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<typeof api.sendVideoNote>[2],
),
label,
).catch((err) => {
throw wrapChatNotFound(err);
}),
);
} else {
result = await sendWithThreadFallback(
mediaParams,
"video",
async (effectiveParams, label) =>
requestWithDiag(
() =>
api.sendVideo(chatId, file, effectiveParams as Parameters<typeof api.sendVideo>[2]),
label,
).catch((err) => {
throw wrapChatNotFound(err);
}),
);
}
} else if (kind === "audio") {
const { useVoice } = resolveTelegramVoiceSend({
wantsVoice: opts.asVoice === true, // default false (backward compatible)

View File

@@ -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<typeof import("../config/config.js")>();
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,
});
});
});