feat: Implement Telegram video note support with tests and docs

This commit is contained in:
Evgenii Utkin
2026-02-02 22:23:01 +01:00
committed by CLAWDINATOR Bot
parent 6ed255319f
commit 231de30ef6
3 changed files with 300 additions and 20 deletions

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) */
@@ -75,7 +77,7 @@ const diagLogger = createSubsystemLogger("telegram/diagnostic");
function createTelegramHttpLogger(cfg: ReturnType<typeof loadConfig>) {
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<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 +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<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)
@@ -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");

View File

@@ -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<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(), {
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,
});
});
});