mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-28 23:02:02 +03:00
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:
@@ -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 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-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: 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 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.
|
- 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.
|
- Docs: clarify tmux send-keys for TUI by splitting text and Enter. (#7737) Thanks @Wangnov.
|
||||||
|
|||||||
@@ -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
|
## Stickers
|
||||||
|
|
||||||
OpenClaw supports receiving and sending Telegram stickers with intelligent caching.
|
OpenClaw supports receiving and sending Telegram stickers with intelligent caching.
|
||||||
|
|||||||
+47
-11
@@ -42,6 +42,8 @@ type TelegramSendOpts = {
|
|||||||
plainText?: string;
|
plainText?: string;
|
||||||
/** Send audio as voice message (voice bubble) instead of audio file. Defaults to false. */
|
/** Send audio as voice message (voice bubble) instead of audio file. Defaults to false. */
|
||||||
asVoice?: boolean;
|
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. */
|
/** Send message silently (no notification). Defaults to false. */
|
||||||
silent?: boolean;
|
silent?: boolean;
|
||||||
/** Message ID to reply to (for threading) */
|
/** Message ID to reply to (for threading) */
|
||||||
@@ -387,9 +389,20 @@ export async function sendMessageTelegram(
|
|||||||
contentType: media.contentType,
|
contentType: media.contentType,
|
||||||
fileName: media.fileName,
|
fileName: media.fileName,
|
||||||
});
|
});
|
||||||
|
const isVideoNote = kind === "video" && opts.asVideoNote === true;
|
||||||
const fileName = media.fileName ?? (isGif ? "animation.gif" : inferFilename(kind)) ?? "file";
|
const fileName = media.fileName ?? (isGif ? "animation.gif" : inferFilename(kind)) ?? "file";
|
||||||
const file = new InputFile(media.buffer, fileName);
|
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;
|
const htmlCaption = caption ? renderHtmlText(caption) : undefined;
|
||||||
// If text exceeds Telegram's caption limit, send media without caption
|
// If text exceeds Telegram's caption limit, send media without caption
|
||||||
// then send text as a separate follow-up message.
|
// then send text as a separate follow-up message.
|
||||||
@@ -401,14 +414,14 @@ export async function sendMessageTelegram(
|
|||||||
...(!needsSeparateText && replyMarkup ? { reply_markup: replyMarkup } : {}),
|
...(!needsSeparateText && replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||||
};
|
};
|
||||||
const mediaParams = {
|
const mediaParams = {
|
||||||
caption: htmlCaption,
|
...(htmlCaption ? { caption: htmlCaption, parse_mode: "HTML" as const } : {}),
|
||||||
...(htmlCaption ? { parse_mode: "HTML" as const } : {}),
|
|
||||||
...baseMediaParams,
|
...baseMediaParams,
|
||||||
...(opts.silent === true ? { disable_notification: true } : {}),
|
...(opts.silent === true ? { disable_notification: true } : {}),
|
||||||
};
|
};
|
||||||
let result:
|
let result:
|
||||||
| Awaited<ReturnType<typeof api.sendPhoto>>
|
| Awaited<ReturnType<typeof api.sendPhoto>>
|
||||||
| Awaited<ReturnType<typeof api.sendVideo>>
|
| Awaited<ReturnType<typeof api.sendVideo>>
|
||||||
|
| Awaited<ReturnType<typeof api.sendVideoNote>>
|
||||||
| Awaited<ReturnType<typeof api.sendAudio>>
|
| Awaited<ReturnType<typeof api.sendAudio>>
|
||||||
| Awaited<ReturnType<typeof api.sendVoice>>
|
| Awaited<ReturnType<typeof api.sendVoice>>
|
||||||
| Awaited<ReturnType<typeof api.sendAnimation>>
|
| Awaited<ReturnType<typeof api.sendAnimation>>
|
||||||
@@ -440,14 +453,37 @@ export async function sendMessageTelegram(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} else if (kind === "video") {
|
} else if (kind === "video") {
|
||||||
result = await sendWithThreadFallback(mediaParams, "video", async (effectiveParams, label) =>
|
if (isVideoNote) {
|
||||||
requestWithDiag(
|
result = await sendWithThreadFallback(
|
||||||
() => api.sendVideo(chatId, file, effectiveParams as Parameters<typeof api.sendVideo>[2]),
|
mediaParams,
|
||||||
label,
|
"video_note",
|
||||||
).catch((err) => {
|
async (effectiveParams, label) =>
|
||||||
throw wrapChatNotFound(err);
|
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") {
|
} else if (kind === "audio") {
|
||||||
const { useVoice } = resolveTelegramVoiceSend({
|
const { useVoice } = resolveTelegramVoiceSend({
|
||||||
wantsVoice: opts.asVoice === true, // default false (backward compatible)
|
wantsVoice: opts.asVoice === true, // default false (backward compatible)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user