diff --git a/README.md b/README.md
index e193709..f0807a0 100644
--- a/README.md
+++ b/README.md
@@ -126,6 +126,7 @@ tgcli send text --to @username --message "done" --reply-to 123
tgcli send photo --to @channel --photo ./screenshot.png --caption "UI diff" --json --timeout 30s
tgcli send file --to @channel --file ./report.pdf --caption "weekly report" --parse-mode html
tgcli send file --to @channel --file ./report.pdf --reply-to 123
+tgcli send video --to @channel --video ./demo.mp4 --caption "Demo" --parse-mode markdown
tgcli groups requests list --chat @group --json --timeout 30s
tgcli server
```
diff --git a/SKILL.md b/SKILL.md
index dbaf490..670dc7a 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -79,7 +79,8 @@ tgcli auth
- `--spoiler` blurs media until tapped (`send photo`/`send file`)
- `--retries ` retries transient network/transport failures for `send photo`
- `--retry-backoff ` controls retry delay for `send photo`
- - `--force-document` sends photo/video as uncompressed document (`send file` only)
+ - `--force-document` sends photo/video as uncompressed document (`send file` only); otherwise video files go out as streamable videos
+- `send video --to --video PATH [--caption] [--parse-mode markdown|html|none] [--width] [--height] [--duration] [--no-streaming]` — video with a player; metadata is auto-read with `ffprobe` when available
- `--retries ` retry on failure with exponential backoff (default 0); JSON output includes `retry_log` and `attempts` when retries occurred
- Telegram markdown formatting (when `--parse-mode markdown`):
- Bold: `**text**`
diff --git a/cli.js b/cli.js
index 1d0b10f..0aa633e 100755
--- a/cli.js
+++ b/cli.js
@@ -409,6 +409,29 @@ function buildProgram() {
.option('--force-document', 'Send as uncompressed document')
.option('--retries ', 'Max retries on failure', '0')
.action(withGlobalOptions((globalFlags, options) => runSendFile(globalFlags, options)));
+ send
+ .command('video')
+ .description('Send a video with a player and streaming (mp4/mov/webm)')
+ .option('--to ', 'Recipient id or username')
+ .addOption(new Option('--chat ', 'Alias for --to').hideHelp())
+ .option('--video ', 'Video path')
+ .addOption(new Option('--file ', 'Alias for --video').hideHelp())
+ .option('--caption ', 'Optional caption')
+ .option('--parse-mode ', 'Parse mode for caption: markdown|html|none')
+ .option('--filename ', 'Override filename')
+ .option('--width ', 'Video width (auto-detected with ffprobe when omitted)')
+ .option('--height ', 'Video height (auto-detected with ffprobe when omitted)')
+ .option('--duration ', 'Video duration (auto-detected with ffprobe when omitted)')
+ .option('--no-streaming', 'Disable streaming playback (whole file must download first)')
+ .option('--topic ', 'Forum topic id')
+ .option('--reply-to ', 'Reply to message id')
+ .option('--silent', 'Send without notification sound')
+ .option('--no-forwards', 'Protect message from forwarding')
+ .option('--caption-above', 'Show caption above media')
+ .option('--spoiler', 'Blur media until tapped')
+ .option('--schedule ', 'Schedule message (ISO 8601 datetime)')
+ .option('--retries ', 'Max retries on failure', '0')
+ .action(withGlobalOptions((globalFlags, options) => runSendVideo(globalFlags, options)));
const media = program.command('media').description('Download media');
media
@@ -3490,6 +3513,67 @@ async function runSendFile(globalFlags, options = {}) {
}, timeoutMs);
}
+async function runSendVideo(globalFlags, options = {}) {
+ resolveSendAliases(options);
+ const timeoutMs = globalFlags.timeoutMs;
+ return runWithTimeout(async () => {
+ if (!options.to) {
+ throw new Error('--to is required');
+ }
+ if (!options.video && options.file) options.video = options.file;
+ if (!options.video) {
+ throw new Error('--video is required');
+ }
+ const parseMode = validateSendParseMode('send video', options);
+ const storeDir = resolveStoreDir();
+ const release = acquireStoreLock(storeDir);
+ const { telegramClient, messageSyncService } = createServices({ storeDir });
+ try {
+ if (!(await telegramClient.isAuthorized().catch(() => false))) {
+ throw new Error('Not authenticated. Run `node cli.js auth` first.');
+ }
+ const topicId = parsePositiveInt(options.topic, '--topic');
+ const replyToMessageId = parsePositiveInt(options.replyTo, '--reply-to');
+ const scheduleDate = parseScheduleDate(options.schedule);
+ const retries = Math.max(0, parseInt(options.retries, 10) || 0);
+ const { result, retryLog, attempts } = await withSendRetry(
+ () => telegramClient.sendVideoMessage(options.to, options.video, {
+ caption: options.caption,
+ filename: options.filename,
+ topicId,
+ replyToMessageId,
+ parseMode,
+ silent: options.silent || false,
+ noforwards: options.forwards === false,
+ captionAbove: options.captionAbove || false,
+ spoiler: options.spoiler || false,
+ scheduleDate,
+ width: options.width,
+ height: options.height,
+ duration: options.duration,
+ streaming: options.streaming !== false,
+ }),
+ { retries, json: globalFlags.json }
+ );
+ const payload = { channelId: options.to, ...result };
+ if (retryLog.length > 0) {
+ payload.attempts = attempts;
+ payload.retry_log = retryLog;
+ }
+
+ if (globalFlags.json) {
+ writeJson(payload);
+ } else {
+ console.log(`Video sent (${result.messageId}).`);
+ }
+ } finally {
+ await messageSyncService.shutdown();
+ await telegramClient.destroy();
+ release();
+ }
+ }, timeoutMs);
+}
+
async function runMediaDownload(globalFlags, options = {}) {
const timeoutMs = globalFlags.timeoutMs;
return runWithTimeout(async () => {
@@ -4741,6 +4825,7 @@ const ownerCliHandlers = {
'send text': runSendText,
'send photo': runSendPhoto,
'send file': runSendFile,
+ 'send video': runSendVideo,
'media download': runMediaDownload,
'topics list': runTopicsList,
'topics search': runTopicsSearch,
diff --git a/docs/cli.md b/docs/cli.md
index b75e189..be77f4c 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -93,6 +93,7 @@ For owner requests, `--timeout` also cancels Telegram RPCs and file uploads. Can
- send text --to --message "..." [--parse-mode markdown|html|none] [--topic] [--reply-to ] [--no-preview] [--silent] [--no-forwards] [--schedule ]
- send photo --to --photo PATH [--caption] [--parse-mode markdown|html|none] [--topic] [--reply-to ] [--silent] [--no-forwards] [--schedule ] [--caption-above] [--spoiler] [--retries ] [--retry-backoff ]
- send file --to --file PATH [--caption] [--filename] [--parse-mode markdown|html|none] [--topic] [--reply-to ] [--silent] [--no-forwards] [--schedule ] [--caption-above] [--spoiler] [--force-document]
+- send video --to --video PATH [--caption] [--filename] [--parse-mode markdown|html|none] [--width] [--height] [--duration] [--no-streaming] [--topic] [--reply-to ] [--silent] [--no-forwards] [--schedule ] [--caption-above] [--spoiler]
- `--parse-mode` is case-insensitive on input.
- Allowed values: `markdown`, `html`, `none`.
- For `send photo` and `send file`, `--parse-mode` requires `--caption`.
@@ -105,7 +106,8 @@ For owner requests, `--timeout` also cancels Telegram RPCs and file uploads. Can
- `--spoiler` — blur media until tapped. Applies to `send photo` and `send file`.
- `--retries ` — retry transient network/transport failures for `send photo` (default: `2`).
- `--retry-backoff` accepts either a millisecond value or `constant|linear|exponential`.
- - `--force-document` — send photo/video as uncompressed document. `send file` only.
+ - `--force-document` — send photo/video as uncompressed document. `send file` only. Without it `send file` sends `.mp4/.mov/.m4v/.webm/.mkv/.avi/.3gp` as a streamable video.
+ - `send video` always sends a streamable video; `--width/--height/--duration` override metadata, otherwise it is read with `ffprobe` when installed. `--no-streaming` disables progressive playback.
## media
- media download --chat --id [--output PATH]
diff --git a/package-lock.json b/package-lock.json
index 50638cb..201634d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@dapi/tgcli",
- "version": "2.11.1",
+ "version": "2.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@dapi/tgcli",
- "version": "2.11.1",
+ "version": "2.12.0",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
diff --git a/package.json b/package.json
index b8ebc65..cf5a2ec 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@dapi/tgcli",
- "version": "2.11.1",
+ "version": "2.12.0",
"type": "module",
"description": "Telegram CLI + MCP server powered by MTProto and the official MCP SDK",
"main": "mcp-server.js",
diff --git a/telegram-client.js b/telegram-client.js
index fc718bb..c113548 100644
--- a/telegram-client.js
+++ b/telegram-client.js
@@ -9,6 +9,7 @@ import { InputMedia } from '@mtcute/core';
import { randomLong } from '@mtcute/core/utils.js';
import { html } from '@mtcute/html-parser';
import { md } from '@mtcute/markdown-parser';
+import { spawnSync } from 'child_process';
import EventEmitter from 'events';
import fs from 'fs';
import { stat } from 'fs/promises';
@@ -298,6 +299,66 @@ function buildMediaSendParams(options = {}) {
return Object.keys(params).length ? params : undefined;
}
+const VIDEO_EXTENSIONS = new Set(['.mp4', '.m4v', '.mov', '.webm', '.mkv', '.avi', '.3gp']);
+
+function isVideoPath(filePath) {
+ return typeof filePath === 'string' && VIDEO_EXTENSIONS.has(path.extname(filePath).toLowerCase());
+}
+
+function parseVideoDimension(value, name) {
+ if (value === undefined || value === null || value === '') return undefined;
+ const parsed = Number(value);
+ if (!Number.isFinite(parsed) || parsed <= 0) {
+ throw new Error(`${name} must be a positive number.`);
+ }
+ return Math.round(parsed);
+}
+
+// Reads width/height/duration with ffprobe when it is installed; silently returns {} otherwise.
+// Telegram renders a video without these fields, but with them the preview keeps its aspect
+// ratio and shows the duration before playback.
+function probeVideoMetadata(filePath) {
+ if (process.env.TGCLI_DISABLE_FFPROBE === '1') return {};
+ try {
+ const probe = spawnSync('ffprobe', [
+ '-v', 'error',
+ '-select_streams', 'v:0',
+ '-show_entries', 'stream=width,height:format=duration',
+ '-of', 'json',
+ filePath,
+ ], { encoding: 'utf8', timeout: 10_000 });
+ if (probe.status !== 0 || !probe.stdout) return {};
+ const parsed = JSON.parse(probe.stdout);
+ const stream = parsed.streams?.[0] ?? {};
+ const duration = Number(parsed.format?.duration);
+ const result = {};
+ if (Number.isFinite(stream.width) && stream.width > 0) result.width = stream.width;
+ if (Number.isFinite(stream.height) && stream.height > 0) result.height = stream.height;
+ if (Number.isFinite(duration) && duration > 0) result.duration = Math.round(duration);
+ return result;
+ } catch {
+ return {};
+ }
+}
+
+function buildVideoMediaOptions(filePath, mediaOptions, options = {}) {
+ const explicit = {
+ width: parseVideoDimension(options.width, '--width'),
+ height: parseVideoDimension(options.height, '--height'),
+ duration: parseVideoDimension(options.duration, '--duration'),
+ };
+ const probed = explicit.width && explicit.height && explicit.duration ? {} : probeVideoMetadata(filePath);
+ const videoOptions = {
+ ...mediaOptions,
+ supportsStreaming: options.streaming !== false && options.supportsStreaming !== false,
+ };
+ for (const key of ['width', 'height', 'duration']) {
+ const value = explicit[key] ?? probed[key];
+ if (value) videoOptions[key] = value;
+ }
+ return videoOptions;
+}
+
function resolveUploadPath(filePath) {
if (!filePath || typeof filePath !== 'string') {
throw new Error('filePath must be a string.');
@@ -1459,13 +1520,43 @@ class TelegramClient {
fileName,
};
if (options.spoiler) mediaOptions.spoiler = true;
+ const peerRef = normalizeChannelId(channelId);
+ // Video files are sent as streamable videos unless the caller forces a document:
+ // InputMedia.auto only distinguishes photos from documents.
+ if (!options.forceDocument && isVideoPath(filePath)) {
+ const media = InputMedia.video(uploadPath, buildVideoMediaOptions(path.resolve(filePath), mediaOptions, options));
+ const sent = await this.client.sendMedia(peerRef, media, buildMediaSendParams(options));
+ return buildSendMessageResult(sent, { method: 'sendVideo', defaultMediaType: 'video' });
+ }
if (options.forceDocument) mediaOptions.forceDocument = true;
const media = InputMedia.auto(uploadPath, mediaOptions);
- const peerRef = normalizeChannelId(channelId);
const sent = await this.client.sendMedia(peerRef, media, buildMediaSendParams(options));
return buildSendMessageResult(sent, { method: 'sendDocument', defaultMediaType: 'document' });
}
+ async sendVideoMessage(channelId, filePath, options = {}) {
+ await this.ensureLogin();
+ const uploadPath = resolveUploadPath(filePath);
+ const caption = resolveOptionalCaption(options.caption);
+ const parseMode = normalizeParseMode(options.parseMode);
+ if (parseMode && !caption) {
+ throw new Error('--parse-mode requires --caption for send video');
+ }
+ if (options.captionAbove && !caption) {
+ throw new Error('--caption-above requires --caption for send video');
+ }
+ const parsedCaption = caption ? applyParseMode(caption, parseMode) : undefined;
+ const fileName = typeof options.filename === 'string' && options.filename.trim()
+ ? options.filename.trim()
+ : undefined;
+ const mediaOptions = { caption: parsedCaption, fileName };
+ if (options.spoiler) mediaOptions.spoiler = true;
+ const media = InputMedia.video(uploadPath, buildVideoMediaOptions(path.resolve(filePath), mediaOptions, options));
+ const peerRef = normalizeChannelId(channelId);
+ const sent = await this.client.sendMedia(peerRef, media, buildMediaSendParams(options));
+ return buildSendMessageResult(sent, { method: 'sendVideo', defaultMediaType: 'video' });
+ }
+
async preparePhotoMessage(channelId, filePath, options = {}) {
await this.ensureLogin();
const uploadPath = resolveUploadPath(filePath);
diff --git a/tests/send-messages.test.js b/tests/send-messages.test.js
index 60bb391..c113932 100644
--- a/tests/send-messages.test.js
+++ b/tests/send-messages.test.js
@@ -5,6 +5,7 @@ vi.mock('@mtcute/core', () => ({
InputMedia: {
auto: vi.fn((path, opts) => ({ path, ...opts })),
photo: vi.fn((path, opts) => ({ path, ...opts })),
+ video: vi.fn((path, opts) => ({ path, ...opts })),
},
}));
vi.mock('@mtcute/markdown-parser', () => ({
@@ -633,3 +634,68 @@ describe('resolveScheduleDate error handling', () => {
expect(tc.client.sendText).toHaveBeenCalledWith('@chat', 'hello', undefined);
});
});
+
+describe('video sending', () => {
+ let tc;
+ let temp;
+
+ beforeEach(() => {
+ tc = createMockClient();
+ temp = createTempFile('.mp4');
+ process.env.TGCLI_DISABLE_FFPROBE = '1';
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ delete process.env.TGCLI_DISABLE_FFPROBE;
+ fs.rmSync(temp.dir, { recursive: true, force: true });
+ vi.clearAllMocks();
+ });
+
+ it('sendFileMessage sends .mp4 as a streamable video', async () => {
+ const result = await tc.sendFileMessage('@chat', temp.filePath, { caption: 'demo' });
+ const { InputMedia } = await import('@mtcute/core');
+ expect(InputMedia.video).toHaveBeenCalledWith(
+ expect.stringContaining('file:'),
+ expect.objectContaining({ supportsStreaming: true }),
+ );
+ expect(InputMedia.auto).not.toHaveBeenCalled();
+ expect(result.method).toBe('sendVideo');
+ });
+
+ it('sendFileMessage keeps .mp4 as a document with forceDocument', async () => {
+ await tc.sendFileMessage('@chat', temp.filePath, { forceDocument: true });
+ const { InputMedia } = await import('@mtcute/core');
+ expect(InputMedia.video).not.toHaveBeenCalled();
+ expect(InputMedia.auto).toHaveBeenCalledWith(
+ expect.stringContaining('file:'),
+ expect.objectContaining({ forceDocument: true }),
+ );
+ });
+
+ it('sendVideoMessage passes explicit dimensions, duration and streaming flag', async () => {
+ await tc.sendVideoMessage('@chat', temp.filePath, {
+ caption: '**demo**',
+ parseMode: 'markdown',
+ width: '780',
+ height: '1688',
+ duration: '62',
+ streaming: false,
+ spoiler: true,
+ });
+ const { InputMedia } = await import('@mtcute/core');
+ expect(InputMedia.video).toHaveBeenCalledWith(
+ expect.stringContaining('file:'),
+ expect.objectContaining({ width: 780, height: 1688, duration: 62, supportsStreaming: false, spoiler: true }),
+ );
+ expect(md).toHaveBeenCalledWith('**demo**');
+ });
+
+ it('sendVideoMessage rejects a non-positive dimension', async () => {
+ await expect(tc.sendVideoMessage('@chat', temp.filePath, { width: '-1' })).rejects.toThrow('--width must be a positive number.');
+ });
+
+ it('sendVideoMessage requires --caption with --parse-mode', async () => {
+ await expect(tc.sendVideoMessage('@chat', temp.filePath, { parseMode: 'markdown' })).rejects.toThrow('--parse-mode requires --caption for send video');
+ });
+});