mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
feat: Implement Telegram video note support with tests and docs
This commit is contained in:
committed by
CLAWDINATOR Bot
parent
6ed255319f
commit
231de30ef6
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
224
src/telegram/send.video-note.test.ts
Normal file
224
src/telegram/send.video-note.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user