Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<b>weekly report</b>" --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
```
Expand Down
3 changes: 2 additions & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ tgcli auth
- `--spoiler` blurs media until tapped (`send photo`/`send file`)
- `--retries <n>` retries transient network/transport failures for `send photo`
- `--retry-backoff <ms|constant|linear|exponential>` 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 <id|username> --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 <n>` 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**`
Expand Down
85 changes: 85 additions & 0 deletions cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,29 @@ function buildProgram() {
.option('--force-document', 'Send as uncompressed document')
.option('--retries <n>', '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 <id|username>', 'Recipient id or username')
.addOption(new Option('--chat <id|username>', 'Alias for --to').hideHelp())
.option('--video <path>', 'Video path')
.addOption(new Option('--file <path>', 'Alias for --video').hideHelp())
.option('--caption <text>', 'Optional caption')
.option('--parse-mode <mode>', 'Parse mode for caption: markdown|html|none')
.option('--filename <name>', 'Override filename')
.option('--width <px>', 'Video width (auto-detected with ffprobe when omitted)')
.option('--height <px>', 'Video height (auto-detected with ffprobe when omitted)')
.option('--duration <seconds>', 'Video duration (auto-detected with ffprobe when omitted)')
.option('--no-streaming', 'Disable streaming playback (whole file must download first)')
.option('--topic <id>', 'Forum topic id')
.option('--reply-to <id>', '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 <iso>', 'Schedule message (ISO 8601 datetime)')
.option('--retries <n>', 'Max retries on failure', '0')
.action(withGlobalOptions((globalFlags, options) => runSendVideo(globalFlags, options)));

const media = program.command('media').description('Download media');
media
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ For owner requests, `--timeout` also cancels Telegram RPCs and file uploads. Can
- send text --to <id|username> --message "..." [--parse-mode markdown|html|none] [--topic] [--reply-to <messageId>] [--no-preview] [--silent] [--no-forwards] [--schedule <iso>]
- send photo --to <id|username> --photo PATH [--caption] [--parse-mode markdown|html|none] [--topic] [--reply-to <messageId>] [--silent] [--no-forwards] [--schedule <iso>] [--caption-above] [--spoiler] [--retries <n>] [--retry-backoff <ms|constant|linear|exponential>]
- send file --to <id|username> --file PATH [--caption] [--filename] [--parse-mode markdown|html|none] [--topic] [--reply-to <messageId>] [--silent] [--no-forwards] [--schedule <iso>] [--caption-above] [--spoiler] [--force-document]
- send video --to <id|username> --video PATH [--caption] [--filename] [--parse-mode markdown|html|none] [--width] [--height] [--duration] [--no-streaming] [--topic] [--reply-to <messageId>] [--silent] [--no-forwards] [--schedule <iso>] [--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`.
Expand All @@ -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 <n>` — 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|username> --id <msgId> [--output PATH]
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
93 changes: 92 additions & 1 deletion telegram-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.');
Expand Down Expand Up @@ -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);
Expand Down
66 changes: 66 additions & 0 deletions tests/send-messages.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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');
});
});
Loading