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'); + }); +});