diff --git a/.gitignore b/.gitignore
index fad5ea9..6e6b8d0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -139,6 +139,9 @@ backlog.md
# pr-review-fix-loop local artifacts
.claude/*.local.md
+.claude/pr-review-loop-stats.local.json
+.codex-review.md
+.codex-review.stderr
# Skills installer artifacts (npx skills add)
.agents/
diff --git a/README.md b/README.md
index 36da280..d4799a7 100644
--- a/README.md
+++ b/README.md
@@ -88,6 +88,7 @@ tgcli messages search "course" --chat @channel --source archive
tgcli send text --to @username --message "hello"
tgcli send text --to @username --message "**hi**" --parse-mode markdown
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 server
@@ -103,7 +104,7 @@ tgcli server Run background sync service (MCP optional)
tgcli service Install/start/stop/status/logs for background service
tgcli channels List/search channels
tgcli messages List/search messages
-tgcli send Send text or files
+tgcli send Send text, photos, or files
tgcli media Download media
tgcli topics Forum topics
tgcli tags Channel tags
@@ -144,6 +145,39 @@ tgcli send text --to @username --message "Hello world" --parse-mode none
tgcli send text --to @username --message "Hello world"
```
+### send photo
+
+Send a local image as a Telegram photo preview with optional retries for transient transport failures.
+
+| Flag | Description |
+|-|-|
+| `--to` | Recipient: `@username`, phone number, or chat ID |
+| `--photo` | Local image path |
+| `--caption` | Optional caption |
+| `--parse-mode` | `markdown`, `html`, or `none` for caption text |
+| `--reply-to` | Message ID to reply to |
+| `--topic` | Forum topic ID |
+| `--silent` | Send without notification |
+| `--no-forwards` | Prevent forwarding |
+| `--caption-above` | Place caption above photo |
+| `--spoiler` | Mark photo as spoiler |
+| `--schedule` | Schedule send (e.g. `2025-01-01T12:00:00`) |
+| `--retries` | Retry count for transient network/transport failures (default: `2`) |
+| `--retry-backoff` | Backoff in milliseconds or strategy: `constant`, `linear`, `exponential` |
+
+```bash
+tgcli send photo --to @channel --photo ./table.png --caption "Comparison" --json --timeout 30s
+tgcli send photo --to @channel --photo ./screenshot.png --caption "**Build**" --parse-mode markdown --reply-to 123
+tgcli send photo --to @channel --photo ./chart.jpg --caption "Daily chart" --silent --no-forwards --spoiler
+tgcli send photo --to @channel --photo ./diff.png --retries 3 --retry-backoff exponential --json
+```
+
+`tgcli send photo` returns structured JSON on success/failure in `--json` mode, including `method`, `message_id`, attempt count, and best-effort `media.file_id`.
+
+### send file
+
+Use `send file` for generic uploads and document-style media. If you need Telegram photo preview rendering for local PNG/JPG, prefer `send photo`.
+
## MCP (optional)
Enable it via config:
diff --git a/SKILL.md b/SKILL.md
index 6329431..f917394 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -37,8 +37,7 @@ tgcli auth
| Use tgcli for | Use telegram-mcp for |
|-|-|
| Read/search/archive messages | edit/delete/forward |
-| Send text/files and topic posts | reactions |
-| | Send photo with preview (image as cover) via `send_file` |
+| Send text/photo/files and topic posts | reactions |
| Forum topics listing/search | inline bot buttons |
| Download media from messages | advanced interactive actions |
| Group admin (rename, members, invite, join/leave) | ban/kick/promote with granular permissions |
@@ -59,13 +58,15 @@ tgcli auth
- If command shape is uncertain, verify it first with `tgcli --help` instead of guessing flags.
- For sending format control:
- `--parse-mode markdown|html|none` (case-insensitive)
- - for `send file`, `--parse-mode` requires `--caption`
+ - for `send photo` and `send file`, `--parse-mode` requires `--caption`
- `--reply-to ` replies to a specific message; if both `--reply-to` and `--topic` are passed, `--reply-to` wins
- `--silent` sends without notification sound
- `--no-forwards` protects message from forwarding/saving
- `--schedule ` schedules message for future delivery (ISO 8601, must be in the future, within 365 days)
- - `--caption-above` shows caption above media (`send file` only, requires `--caption`)
- - `--spoiler` blurs media until tapped (`send file` only)
+ - `--caption-above` shows caption above media (`send photo`/`send file`, requires `--caption`)
+ - `--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)
- `--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`):
@@ -107,9 +108,9 @@ tgcli messages search "Release" --case-sensitive --chat --source
Both positional query and `--query` flag work. `--chat` accepts multiple values. Use `--regex` for pattern matching, `--tag`/`--tags` to filter by channel tags, `--after`/`--before` for date range, `--case-sensitive` to disable case-insensitive search.
-### Send Text/File
+### Send Text/Photo/File
-**⚠ `send` uses `--to` and `--message`, NOT `--chat`/`--text`.**
+**⚠ `send` uses `--to` for the destination; then `--message` for text, `--photo` for photo uploads, and `--file` for generic files.**
```bash
tgcli send text --to --message "Hello" --json --timeout 30s
@@ -121,6 +122,13 @@ tgcli send text --to --message "Confidential" --no-forwards --jso
tgcli send text --to --message "Good morning!" --schedule "2025-01-15T09:00:00+03:00" --json --timeout 30s
tgcli send text --to --message "Hello" --retries 3 --json --timeout 30s
+tgcli send photo --to --photo /path/to/image.png --caption "Report" --json --timeout 30s
+tgcli send photo --to --photo /path/to/image.png --caption "**Report**" --parse-mode markdown --json --timeout 30s
+tgcli send photo --to --photo /path/to/image.png --reply-to --json --timeout 30s
+tgcli send photo --to --photo /path/to/image.png --caption "Breaking news" --caption-above --json --timeout 30s
+tgcli send photo --to --photo /path/to/image.png --spoiler --json --timeout 30s
+tgcli send photo --to --photo /path/to/image.png --retries 3 --retry-backoff exponential --json --timeout 30s
+
tgcli send file --to --file /path/to/file --caption "Report" --json --timeout 30s
tgcli send file --to --file /path/to/file --caption "Report" --parse-mode html --json --timeout 30s
tgcli send file --to --file /path/to/file --filename custom-name.pdf --json --timeout 30s
@@ -130,22 +138,11 @@ tgcli send file --to --file /path/to/photo.jpg --spoiler --json -
tgcli send file --to --file /path/to/photo.jpg --force-document --json --timeout 30s
```
-### Post with Cover Image (Photo + Caption)
-
-tgcli `send file` sends images as **documents** (no preview). To post an image as a photo with caption (cover-style), use **telegram-mcp** `send_file`:
-
-Workflow:
-1. Download or prepare image locally
-2. Send via telegram-mcp:
- - `mcp__telegram-mcp__send_file(chat_id=, file_path="/tmp/image.jpg", caption="Post text")`
-3. Caption limit: 1024 characters. For longer posts — send photo first, then follow up with `send text`.
-
-For draft/approval flow:
-1. Send to Saved Messages first (use own user ID, not `me`)
-2. Review in Telegram
-3. If approved — resend to target channel
+### Photo Preview vs Document Upload
-Note: telegram-mcp `send_file` auto-detects .jpg/.png as photos with preview. tgcli `send file` sends as auto-detected media by default; use `--force-document` to send as document attachment without preview.
+- Use `tgcli send photo` for local PNG/JPG when Telegram should render a photo preview.
+- Use `tgcli send file` for generic uploads and explicit document-style attachments.
+- For draft/approval flow, send to Saved Messages first, review in Telegram, then resend to the target chat.
### Media Download
diff --git a/cli.js b/cli.js
index 3750392..66297ff 100755
--- a/cli.js
+++ b/cli.js
@@ -11,6 +11,15 @@ import { Command } from 'commander';
import { acquireStoreLock, acquireReadLock, readStoreLock } from './store-lock.js';
import { loadConfig, normalizeConfig, saveConfig, validateConfig } from './core/config.js';
import { createMessageSyncService, createServices, createTelegramClient } from './core/services.js';
+import {
+ buildSendErrorPayload,
+ buildSendSuccessPayload,
+ classifySendError,
+ executeSendWithRetries,
+ formatSendErrorMessage,
+ parseRetryBackoff,
+ SendCommandError,
+} from './core/send-utils.js';
import { resolveStoreDir } from './core/store.js';
import { formatErrorMessage, parseRequiredWaitSeconds, withSendRetry } from './core/retry.js';
@@ -19,6 +28,7 @@ const SERVICE_STATE_FILE = 'service-state.json';
const LAUNCHD_LABEL = 'com.dapi.tgcli';
const SYSTEMD_SERVICE_NAME = 'tgcli';
const AUTH_SYNC_HINT = 'Run `tgcli sync --once` or `tgcli sync --follow` when you need archive data.';
+const DEFAULT_SEND_PHOTO_RETRIES = 2;
const CONFIG_SPECS = [
{ key: 'apiId', path: ['apiId'], type: 'number' },
{ key: 'apiHash', path: ['apiHash'], type: 'string', secret: true },
@@ -219,7 +229,7 @@ function buildProgram() {
.option('--after ', 'Messages after')
.action(withGlobalOptions((globalFlags, options) => runMessagesContext(globalFlags, options)));
- const send = program.command('send').description('Send text or files');
+ const send = program.command('send').description('Send text, photos, or files');
send
.command('text')
.description('Send a text message')
@@ -234,6 +244,23 @@ function buildProgram() {
.option('--schedule ', 'Schedule message (ISO 8601 datetime)')
.option('--retries ', 'Max retries on failure', '0')
.action(withGlobalOptions((globalFlags, options) => runSendText(globalFlags, options)));
+ send
+ .command('photo')
+ .description('Send a photo with preview')
+ .option('--to ', 'Recipient id or username')
+ .option('--photo ', 'Photo path')
+ .option('--caption ', 'Optional caption')
+ .option('--parse-mode ', 'Parse mode for caption: markdown|html|none')
+ .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 ', 'Retry count for transient send failures')
+ .option('--retry-backoff ', 'Retry backoff in ms or strategy: constant|linear|exponential')
+ .action(withGlobalOptions((globalFlags, options) => runSendPhoto(globalFlags, options)));
send
.command('file')
.description('Send a file')
@@ -551,6 +578,15 @@ function writeJson(payload) {
}
function writeError(error, asJson) {
+ if (error instanceof SendCommandError) {
+ if (asJson) {
+ process.stderr.write(`${JSON.stringify(buildSendErrorPayload(error.details), null, 2)}\n`);
+ } else {
+ process.stderr.write(`${formatSendErrorMessage(error.details)}\n`);
+ }
+ return;
+ }
+
const message = error?.message ?? String(error);
if (asJson) {
const payload = { ok: false, error: message };
@@ -952,6 +988,30 @@ async function refreshDialogsWithRetry(messageSyncService, options = {}) {
}
}
+function normalizeSendCommandError(error, { method, retries, attempt = 1 } = {}) {
+ if (error instanceof SendCommandError) {
+ return error;
+ }
+ if (error instanceof TypeError || error instanceof ReferenceError || error instanceof SyntaxError || error instanceof RangeError) {
+ return error;
+ }
+ return new SendCommandError(classifySendError(error, { method, retries, attempt }));
+}
+
+function logSendRetry(details, globalFlags) {
+ if (globalFlags.json) {
+ process.stderr.write(`${JSON.stringify({ event: 'retry', type: details.type, method: details.method, message: details.message, attempt: details.attempt, retries: details.retries })}\n`);
+ return;
+ }
+ const totalAttempts = (details.retries ?? 0) + 1;
+ const codeSuffix = details.code !== undefined && details.code !== null && details.code !== ''
+ ? ` (${details.code})`
+ : '';
+ process.stderr.write(
+ `${details.method} transient ${details.type} error on attempt ${details.attempt}/${totalAttempts}${codeSuffix}; retrying...\n`,
+ );
+}
+
function readVersion() {
try {
const pkgPath = new URL('./package.json', import.meta.url);
@@ -1043,8 +1103,8 @@ function parseNonNegativeInt(value, label) {
return null;
}
const parsed = Number(value);
- if (!Number.isFinite(parsed) || parsed < 0) {
- throw new Error(`${label} must be a non-negative number`);
+ if (!Number.isInteger(parsed) || parsed < 0) {
+ throw new Error(`${label} must be a non-negative integer`);
}
return parsed;
}
@@ -1525,7 +1585,7 @@ async function runSync(globalFlags, options = {}) {
const follow = options.follow || !options.once;
try {
- if (!(await telegramClient.isAuthorized().catch(() => false))) {
+ if (!(await telegramClient.isAuthorized().catch((err) => { process.stderr.write(`Auth check failed: ${err.message}\n`); return false; }))) {
throw new Error('Not authenticated. Run `node cli.js auth` first.');
}
@@ -2851,6 +2911,88 @@ async function runSendText(globalFlags, options = {}) {
}, timeoutMs);
}
+async function runSendPhoto(globalFlags, options = {}) {
+ const timeoutMs = globalFlags.timeoutMs;
+ const method = 'sendPhoto';
+ let retries = DEFAULT_SEND_PHOTO_RETRIES;
+
+ try {
+ return await runWithTimeout(async () => {
+ if (!options.to) {
+ throw new Error('--to is required');
+ }
+ if (!options.photo) {
+ throw new Error('--photo is required');
+ }
+
+ const parseMode = parseSendParseMode(options.parseMode);
+ if (parseMode && !(typeof options.caption === 'string' && options.caption.trim())) {
+ throw new Error('--parse-mode requires --caption for send photo');
+ }
+
+ retries = parseNonNegativeInt(options.retries, '--retries') ?? DEFAULT_SEND_PHOTO_RETRIES;
+ const retryBackoff = parseRetryBackoff(options.retryBackoff);
+ const storeDir = resolveStoreDir();
+ const release = acquireStoreLock(storeDir);
+ const { telegramClient, messageSyncService } = createServices({ storeDir });
+ try {
+ if (!(await telegramClient.isAuthorized().catch((err) => { process.stderr.write(`Auth check failed: ${err.message}\n`); return 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 sendOptions = {
+ caption: options.caption,
+ topicId,
+ replyToMessageId,
+ parseMode,
+ silent: options.silent || false,
+ noforwards: options.forwards === false,
+ captionAbove: options.captionAbove || false,
+ spoiler: options.spoiler || false,
+ scheduleDate,
+ };
+ const prepared = await telegramClient.preparePhotoMessage(options.to, options.photo, sendOptions);
+ const { result, attempts } = await executeSendWithRetries(
+ () => telegramClient.sendPreparedPhotoMessage(prepared),
+ {
+ method,
+ retries,
+ retryBackoff,
+ timeoutMs,
+ sleep: (ms) => delay(ms),
+ onRetry: (details) => logSendRetry(details, globalFlags),
+ },
+ );
+
+ if (globalFlags.json) {
+ writeJson(buildSendPhotoSuccessPayload({ method, inputChatId: options.to, result, attempts }));
+ } else {
+ console.log(`Photo sent (${result.messageId}).`);
+ }
+ } finally {
+ await messageSyncService.shutdown();
+ await telegramClient.destroy();
+ release();
+ }
+ }, timeoutMs);
+ } catch (error) {
+ throw normalizeSendCommandError(error, { method, retries });
+ }
+}
+
+function buildSendPhotoSuccessPayload({ method, inputChatId, result, attempts }) {
+ return buildSendSuccessPayload({
+ method,
+ chatId: result?.chatId ?? inputChatId,
+ messageId: result?.messageId,
+ media: result?.media ?? { type: 'photo' },
+ attempts,
+ warning: result?.warning,
+ });
+}
+
async function runSendFile(globalFlags, options = {}) {
const timeoutMs = globalFlags.timeoutMs;
return runWithTimeout(async () => {
@@ -4013,10 +4155,36 @@ function isCliEntrypoint(argvPath = process.argv[1]) {
}
}
+function resolveEntrypointPath(filePath) {
+ if (!filePath) {
+ return null;
+ }
+ try {
+ return fs.realpathSync(filePath);
+ } catch (error) {
+ if (error?.code !== 'ENOENT') {
+ console.error(`[resolveEntrypointPath] realpathSync failed for ${filePath}: ${error?.message}`);
+ }
+ return path.resolve(filePath);
+ }
+}
+
+function shouldRunMain(entryPath = process.argv[1]) {
+ const resolvedEntryPath = resolveEntrypointPath(entryPath);
+ return resolvedEntryPath !== null && resolvedEntryPath === CLI_PATH;
+}
+
export {
buildProgram,
+ buildSendPhotoSuccessPayload,
isCliEntrypoint,
+ logSendRetry,
+ main,
+ normalizeSendCommandError,
+ parseNonNegativeInt,
runAuthLogin,
+ shouldRunMain,
+ writeError,
};
if (isCliEntrypoint()) {
diff --git a/core/send-utils.js b/core/send-utils.js
new file mode 100644
index 0000000..05b5cfb
--- /dev/null
+++ b/core/send-utils.js
@@ -0,0 +1,371 @@
+const DEFAULT_BACKOFF_MS = 1000;
+const RETRY_BACKOFF_STRATEGIES = new Set(['constant', 'linear', 'exponential']);
+const RETRYABLE_TIMEOUT_CODES = new Set(['ETIMEDOUT', 'ERR_OPERATION_TIMED_OUT']);
+const RETRYABLE_NETWORK_CODES = new Set([
+ 'ECONNABORTED',
+ 'ECONNRESET',
+ 'EHOSTUNREACH',
+ 'EPIPE',
+ 'ENETDOWN',
+ 'ENETRESET',
+ 'ENETUNREACH',
+ 'ERR_NETWORK',
+]);
+const VALIDATION_ERROR_CODES = new Set(['EACCES', 'EISDIR', 'ENOENT', 'EPERM']);
+const TELEGRAM_ERROR_MARKERS = [
+ 'AUTH_KEY',
+ 'BOT_METHOD_INVALID',
+ 'CHANNEL_INVALID',
+ 'CHANNEL_PRIVATE',
+ 'CHAT_ADMIN_REQUIRED',
+ 'CHAT_SEND_MEDIA_FORBIDDEN',
+ 'CHAT_SEND_PHOTOS_FORBIDDEN',
+ 'CHAT_WRITE_FORBIDDEN',
+ 'FLOOD_WAIT',
+ 'MESSAGE_ID_INVALID',
+ 'MESSAGE_TOO_LONG',
+ 'PEER_ID_INVALID',
+ 'PHOTO_INVALID',
+ 'RPC',
+ 'SCHEDULE',
+ 'USER_BANNED_IN_CHANNEL',
+];
+const VALIDATION_MESSAGE_PATTERNS = [
+ /^--/,
+ /^File not found:/,
+ /^Message text cannot be empty\./,
+ /^filePath must be a string\./,
+ /^Invalid parse mode\./,
+ /^Invalid schedule date:/,
+ /^Not authenticated\./,
+];
+
+function formatErrorMessage(error) {
+ if (error instanceof Error && error.message) {
+ return error.message;
+ }
+ return String(error ?? 'Unknown error');
+}
+
+function extractErrorCode(error) {
+ if (!error || typeof error !== 'object') {
+ return null;
+ }
+ const code = error.code ?? error.errorCode ?? error.rpcCode ?? error.cause?.code ?? null;
+ return code === undefined ? null : code;
+}
+
+function looksLikeValidationError(message, code) {
+ if (code && VALIDATION_ERROR_CODES.has(String(code).toUpperCase())) {
+ return true;
+ }
+ return VALIDATION_MESSAGE_PATTERNS.some((pattern) => pattern.test(message));
+}
+
+function looksLikeTimeoutError(message, code) {
+ const normalizedCode = code ? String(code).toUpperCase() : '';
+ if (normalizedCode && RETRYABLE_TIMEOUT_CODES.has(normalizedCode)) {
+ return true;
+ }
+ const lowered = message.toLowerCase();
+ return lowered === 'timeout' || lowered.includes('timed out') || lowered.includes('timeout');
+}
+
+function looksLikeRetryableNetworkError(message, code) {
+ const normalizedCode = code ? String(code).toUpperCase() : '';
+ if (normalizedCode && RETRYABLE_NETWORK_CODES.has(normalizedCode)) {
+ return true;
+ }
+ const lowered = message.toLowerCase();
+ return lowered.includes('connection reset')
+ || lowered.includes('connection aborted')
+ || lowered.includes('broken pipe')
+ || lowered.includes('temporary disconnect')
+ || lowered.includes('network');
+}
+
+function looksLikeTransportError(message, error) {
+ if (error?.name === 'TransportError') {
+ return true;
+ }
+ return message.toLowerCase().includes('transport error');
+}
+
+function looksLikeTelegramError(message, code, error) {
+ if (typeof code === 'number') {
+ return true;
+ }
+ if (error?.name === 'RpcError' || error?.name === 'MtRpcError') {
+ return true;
+ }
+ const upper = message.toUpperCase();
+ return TELEGRAM_ERROR_MARKERS.some((marker) => upper.includes(marker));
+}
+
+export class SendCommandError extends Error {
+ constructor(details) {
+ super(details?.message ?? 'Send failed');
+ this.name = 'SendCommandError';
+ this.details = details;
+ }
+}
+
+export function parseRetryBackoff(value) {
+ if (value && typeof value === 'object' && typeof value.kind === 'string') {
+ return {
+ kind: value.kind,
+ baseMs: Number.isFinite(value.baseMs) ? value.baseMs : DEFAULT_BACKOFF_MS,
+ raw: value.raw ?? String(value.kind),
+ };
+ }
+
+ if (value === undefined || value === null || value === '') {
+ return { kind: 'constant', baseMs: DEFAULT_BACKOFF_MS, raw: String(DEFAULT_BACKOFF_MS) };
+ }
+
+ const normalized = String(value).trim().toLowerCase();
+ if (/^\d+$/.test(normalized)) {
+ return { kind: 'constant', baseMs: Number(normalized), raw: normalized };
+ }
+ if (RETRY_BACKOFF_STRATEGIES.has(normalized)) {
+ return { kind: normalized, baseMs: DEFAULT_BACKOFF_MS, raw: normalized };
+ }
+
+ throw new Error('--retry-backoff must be a non-negative integer or one of: constant, linear, exponential');
+}
+
+export function getRetryDelayMs(backoff, attempt) {
+ const strategy = backoff?.kind ?? 'constant';
+ const baseMs = Number.isFinite(backoff?.baseMs) ? backoff.baseMs : DEFAULT_BACKOFF_MS;
+
+ if (strategy === 'linear') {
+ return baseMs * attempt;
+ }
+ if (strategy === 'exponential') {
+ return baseMs * (2 ** Math.max(0, attempt - 1));
+ }
+ return baseMs;
+}
+
+export function classifySendError(error, { method, attempt = 1, retries = 0 } = {}) {
+ if (error instanceof SendCommandError) {
+ return error.details;
+ }
+
+ const message = formatErrorMessage(error);
+ const code = extractErrorCode(error);
+
+ if (looksLikeValidationError(message, code)) {
+ return {
+ type: 'validation',
+ method,
+ message,
+ code,
+ attempt,
+ retries,
+ retryable: false,
+ };
+ }
+
+ if (looksLikeTimeoutError(message, code)) {
+ const normalizedCode = code ? String(code).toUpperCase() : '';
+ return {
+ type: 'timeout',
+ method,
+ message,
+ code,
+ attempt,
+ retries,
+ retryable: normalizedCode ? RETRYABLE_TIMEOUT_CODES.has(normalizedCode) : message.toLowerCase() !== 'timeout',
+ };
+ }
+
+ if (looksLikeRetryableNetworkError(message, code)) {
+ return {
+ type: 'network',
+ method,
+ message,
+ code,
+ attempt,
+ retries,
+ retryable: true,
+ };
+ }
+
+ if (looksLikeTransportError(message, error)) {
+ return {
+ type: 'network',
+ method,
+ message,
+ code,
+ attempt,
+ retries,
+ retryable: true,
+ };
+ }
+
+ if (looksLikeTelegramError(message, code, error)) {
+ return {
+ type: 'telegram',
+ method,
+ message,
+ code,
+ attempt,
+ retries,
+ retryable: false,
+ };
+ }
+
+ return {
+ type: 'unknown',
+ method,
+ message,
+ code,
+ attempt,
+ retries,
+ retryable: false,
+ };
+}
+
+function createTimeoutDetails({ method, attempt, retries }) {
+ return {
+ type: 'timeout',
+ method,
+ message: 'Timeout',
+ code: null,
+ attempt,
+ retries,
+ retryable: false,
+ };
+}
+
+function delay(ms) {
+ return new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+}
+
+function getRemainingTimeoutMs(deadlineAt, now) {
+ if (deadlineAt === null) {
+ return null;
+ }
+ return deadlineAt - now();
+}
+
+export async function executeSendWithRetries(sendFn, options = {}) {
+ const retries = Number.isInteger(options.retries) && options.retries >= 0 ? options.retries : 0;
+ const method = options.method ?? 'sendMedia';
+ const backoff = parseRetryBackoff(options.retryBackoff);
+ const sleep = options.sleep ?? delay;
+ const now = options.now ?? (() => Date.now());
+ const deadlineAt = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
+ ? now() + options.timeoutMs
+ : null;
+
+ for (let attempt = 1; attempt <= retries + 1; attempt += 1) {
+ const remainingBeforeAttemptMs = getRemainingTimeoutMs(deadlineAt, now);
+ if (remainingBeforeAttemptMs !== null && remainingBeforeAttemptMs <= 0) {
+ throw new SendCommandError(createTimeoutDetails({
+ method,
+ attempt: Math.max(1, attempt - 1),
+ retries,
+ }));
+ }
+
+ try {
+ const result = await sendFn({ attempt });
+ return { result, attempts: attempt };
+ } catch (error) {
+ const details = classifySendError(error, { method, attempt, retries });
+ const shouldRetry = details.retryable && attempt <= retries;
+ if (!shouldRetry) {
+ throw new SendCommandError(details);
+ }
+
+ if (typeof options.onRetry === 'function') {
+ try {
+ options.onRetry(details);
+ } catch (callbackError) {
+ if (callbackError instanceof TypeError || callbackError instanceof ReferenceError) {
+ throw callbackError;
+ }
+ console.error('[executeSendWithRetries] onRetry callback error:', callbackError);
+ }
+ }
+
+ const retryDelayMs = getRetryDelayMs(backoff, attempt);
+ if (deadlineAt !== null) {
+ const remainingMs = getRemainingTimeoutMs(deadlineAt, now);
+ if (remainingMs <= 0) {
+ throw new SendCommandError(createTimeoutDetails({ method, attempt, retries }));
+ }
+ if (retryDelayMs > 0) {
+ await sleep(Math.min(retryDelayMs, remainingMs));
+ if (getRemainingTimeoutMs(deadlineAt, now) <= 0) {
+ throw new SendCommandError(createTimeoutDetails({ method, attempt, retries }));
+ }
+ }
+ } else if (retryDelayMs > 0) {
+ await sleep(retryDelayMs);
+ }
+ }
+ }
+
+ // Unreachable: the loop always exits via return (success) or throw (in catch).
+ // Kept as a safety net in case future refactors break the loop invariant.
+ throw new SendCommandError(createTimeoutDetails({ method, attempt: retries + 1, retries }));
+}
+
+export function buildSendSuccessPayload({ method, chatId, messageId, media, attempts, warning }) {
+ const payload = {
+ ok: true,
+ method,
+ chat_id: chatId,
+ message_id: messageId,
+ attempts,
+ };
+
+ if (media && typeof media === 'object') {
+ const mediaPayload = {};
+ if (media.type) mediaPayload.type = media.type;
+ if (media.fileId) mediaPayload.file_id = media.fileId;
+ if (Object.keys(mediaPayload).length > 0) {
+ payload.media = mediaPayload;
+ }
+ }
+
+ if (warning) {
+ payload.warning = warning;
+ }
+
+ return payload;
+}
+
+export function buildSendErrorPayload(details = {}) {
+ const payload = {
+ ok: false,
+ error: {
+ type: details.type ?? 'unknown',
+ method: details.method ?? 'sendMedia',
+ message: details.message ?? 'Unknown error',
+ attempt: details.attempt ?? 1,
+ retries: details.retries ?? 0,
+ },
+ };
+
+ if (details.code !== undefined && details.code !== null && details.code !== '') {
+ payload.error.code = details.code;
+ }
+
+ return payload;
+}
+
+export function formatSendErrorMessage(details = {}) {
+ const attempt = details.attempt ?? 1;
+ const retries = details.retries ?? 0;
+ const totalAttempts = retries + 1;
+ const codeSuffix = details.code !== undefined && details.code !== null && details.code !== ''
+ ? `, code ${details.code}`
+ : '';
+ return `${details.method ?? 'sendMedia'} failed [${details.type ?? 'unknown'}]: ${details.message ?? 'Unknown error'} (attempt ${attempt}/${totalAttempts}${codeSuffix})`;
+}
diff --git a/docs/cli.md b/docs/cli.md
index c5a6f39..cbef34f 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -59,17 +59,20 @@ MCP: disabled by default (set `mcp.enabled` in config.json to true to serve MCP)
## send
- 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]
- `--parse-mode` is case-insensitive on input.
- Allowed values: `markdown`, `html`, `none`.
- - For `send file`, `--parse-mode` requires `--caption`.
+ - For `send photo` and `send file`, `--parse-mode` requires `--caption`.
- If both `--reply-to` and `--topic` are passed, `--reply-to` takes precedence.
- `--no-preview` disables automatic link preview (applies to `send text` only).
- `--silent` — send without notification sound.
- `--no-forwards` — protect message from forwarding/saving.
- `--schedule ` — schedule message for future delivery (ISO 8601). Must be in the future, within 365 days.
- - `--caption-above` — show caption above media (requires `--caption`). `send file` only.
- - `--spoiler` — blur media until tapped. `send file` only.
+ - `--caption-above` — show caption above media (requires `--caption`). Applies to `send photo` and `send file`.
+ - `--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.
## media
diff --git a/telegram-client.js b/telegram-client.js
index 363cb29..b3b6308 100644
--- a/telegram-client.js
+++ b/telegram-client.js
@@ -1,5 +1,6 @@
import { TelegramClient as MtCuteClient } from '@mtcute/node';
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 EventEmitter from 'events';
@@ -165,6 +166,135 @@ function resolveScheduleDate(options) {
return undefined;
}
+function resolveReplyTo(options = {}) {
+ if (Number.isFinite(options.replyToMessageId)) {
+ return options.replyToMessageId;
+ }
+ if (Number.isFinite(options.topicId)) {
+ return options.topicId;
+ }
+ return undefined;
+}
+
+function buildTextSendParams(options = {}) {
+ const params = {};
+ const replyTo = resolveReplyTo(options);
+ if (replyTo) params.replyTo = replyTo;
+ if (options.noPreview) params.disableWebPreview = true;
+ if (options.silent) params.silent = true;
+ if (options.noForwards || options.noforwards) params.forbidForwards = true;
+ const scheduleDate = resolveScheduleDate(options);
+ if (scheduleDate) params.scheduleDate = scheduleDate;
+ return Object.keys(params).length ? params : undefined;
+}
+
+function buildMediaSendParams(options = {}) {
+ const params = {};
+ const replyTo = resolveReplyTo(options);
+ if (replyTo) params.replyTo = replyTo;
+ if (options.silent) params.silent = true;
+ if (options.noForwards || options.noforwards) params.forbidForwards = true;
+ const scheduleDate = resolveScheduleDate(options);
+ if (scheduleDate) params.scheduleDate = scheduleDate;
+ if (options.captionAbove) params.invert = true;
+ return Object.keys(params).length ? params : undefined;
+}
+
+function resolveUploadPath(filePath) {
+ if (!filePath || typeof filePath !== 'string') {
+ throw new Error('filePath must be a string.');
+ }
+ const resolved = path.resolve(filePath);
+ if (!fs.existsSync(resolved)) {
+ throw new Error(`File not found: ${resolved}`);
+ }
+ return `file:${resolved}`;
+}
+
+function resolveOptionalCaption(caption) {
+ if (typeof caption !== 'string') {
+ return undefined;
+ }
+ const trimmed = caption.trim();
+ return trimmed ? trimmed : undefined;
+}
+
+function buildSendMessageResult(sent, { method, defaultMediaType } = {}) {
+ const result = {
+ messageId: sent?.id ?? null,
+ };
+ if (method) {
+ result.method = method;
+ }
+ const media = summarizeMedia(sent?.media) ?? (defaultMediaType ? { type: defaultMediaType } : null);
+ if (media) {
+ result.media = media;
+ }
+ return result;
+}
+
+function buildLowLevelReplyTo(options = {}) {
+ const replyTo = resolveReplyTo(options);
+ if (!replyTo) {
+ return undefined;
+ }
+ return {
+ _: 'inputReplyToMessage',
+ replyToMsgId: replyTo,
+ };
+}
+
+function splitInputText(value) {
+ if (!value) {
+ return { message: '', entities: undefined };
+ }
+ if (typeof value === 'string') {
+ return { message: value, entities: undefined };
+ }
+ if (typeof value === 'object' && typeof value.text === 'string') {
+ return {
+ message: value.text,
+ entities: Array.isArray(value.entities) && value.entities.length > 0 ? value.entities : undefined,
+ };
+ }
+ return { message: String(value), entities: undefined };
+}
+
+function randomIdsEqual(left, right) {
+ if (left?.eq && typeof left.eq === 'function') {
+ return left.eq(right);
+ }
+ return String(left) === String(right);
+}
+
+function extractMessageIdFromSendUpdates(response, randomId) {
+ const updates = Array.isArray(response?.updates) ? response.updates : [];
+ let messageId = null;
+ for (const update of updates) {
+ if (update?._ === 'updateMessageID' && randomIdsEqual(update.randomId, randomId)) {
+ messageId = update.id;
+ break;
+ }
+ }
+ if (messageId !== null) {
+ return messageId;
+ }
+
+ for (const update of updates) {
+ if (
+ update?._ === 'updateNewMessage'
+ || update?._ === 'updateNewChannelMessage'
+ || update?._ === 'updateNewScheduledMessage'
+ || update?._ === 'updateQuickReplyMessage'
+ || update?._ === 'updateBotNewBusinessMessage'
+ ) {
+ return update.message?.id ?? null;
+ }
+ }
+
+ return null;
+}
+
function coerceApiId(value) {
if (typeof value === 'number') {
return value;
@@ -941,35 +1071,16 @@ class TelegramClient {
}
const parseMode = normalizeParseMode(options.parseMode);
const inputText = applyParseMode(messageText, parseMode);
- const replyTo = Number.isFinite(options.replyToMessageId)
- ? options.replyToMessageId
- : (Number.isFinite(options.topicId) ? options.topicId : undefined);
- const params = {};
- if (replyTo) params.replyTo = replyTo;
- if (options.noPreview) params.noWebpage = true;
- if (options.silent) params.silent = true;
- if (options.noForwards || options.noforwards) params.noforwards = true;
- const scheduleDate = resolveScheduleDate(options);
- if (scheduleDate) params.scheduleDate = scheduleDate;
+ const params = buildTextSendParams(options);
const peerRef = normalizeChannelId(channelId);
- const finalParams = Object.keys(params).length ? params : undefined;
- const sent = await this.client.sendText(peerRef, inputText, finalParams);
+ const sent = await this.client.sendText(peerRef, inputText, params);
return { messageId: sent.id };
}
async sendFileMessage(channelId, filePath, options = {}) {
await this.ensureLogin();
- if (!filePath || typeof filePath !== 'string') {
- throw new Error('filePath must be a string.');
- }
- const resolved = path.resolve(filePath);
- if (!fs.existsSync(resolved)) {
- throw new Error(`File not found: ${resolved}`);
- }
- const uploadPath = `file:${resolved}`;
- const caption = typeof options.caption === 'string' && options.caption.trim()
- ? options.caption
- : undefined;
+ 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 file');
@@ -981,16 +1092,6 @@ class TelegramClient {
if (options.captionAbove && !caption) {
throw new Error('--caption-above requires --caption for send file');
}
- const replyTo = Number.isFinite(options.replyToMessageId)
- ? options.replyToMessageId
- : (Number.isFinite(options.topicId) ? options.topicId : undefined);
- const params = {};
- if (replyTo) params.replyTo = replyTo;
- if (options.silent) params.silent = true;
- if (options.noForwards || options.noforwards) params.noforwards = true;
- const scheduleDate = resolveScheduleDate(options);
- if (scheduleDate) params.scheduleDate = scheduleDate;
- if (options.captionAbove) params.invertMedia = true;
const mediaOptions = {
caption: parsedCaption,
fileName,
@@ -999,8 +1100,95 @@ class TelegramClient {
if (options.forceDocument) mediaOptions.forceDocument = true;
const media = InputMedia.auto(uploadPath, mediaOptions);
const peerRef = normalizeChannelId(channelId);
- const sent = await this.client.sendMedia(peerRef, media, Object.keys(params).length ? params : undefined);
- return { messageId: sent.id };
+ const sent = await this.client.sendMedia(peerRef, media, buildMediaSendParams(options));
+ return buildSendMessageResult(sent, { method: 'sendDocument', defaultMediaType: 'document' });
+ }
+
+ async preparePhotoMessage(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 photo');
+ }
+ if (options.captionAbove && !caption) {
+ throw new Error('--caption-above requires --caption for send photo');
+ }
+
+ const mediaOptions = {};
+ let parsedCaption;
+ if (caption) {
+ parsedCaption = applyParseMode(caption, parseMode);
+ mediaOptions.caption = parsedCaption;
+ }
+ if (options.spoiler) {
+ mediaOptions.spoiler = true;
+ }
+
+ const media = InputMedia.photo(uploadPath, mediaOptions);
+ const { message, entities } = splitInputText(parsedCaption);
+ return {
+ method: 'sendPhoto',
+ peerRef: normalizeChannelId(channelId),
+ media,
+ request: {
+ _: 'messages.sendMedia',
+ silent: options.silent ? true : undefined,
+ replyTo: buildLowLevelReplyTo(options),
+ // Fixed per prepared object — reused across retries for Telegram-level send idempotency.
+ randomId: options.randomId ?? randomLong(),
+ scheduleDate: resolveScheduleDate(options),
+ message,
+ entities,
+ noforwards: options.noForwards || options.noforwards ? true : undefined,
+ invertMedia: options.captionAbove ? true : undefined,
+ },
+ };
+ }
+
+ async sendPreparedPhotoMessage(prepared) {
+ const peer = await this.client.resolvePeer(prepared.peerRef);
+ const chatId = peer?._ === 'inputPeerSelf'
+ ? String(prepared.peerRef)
+ : this._extractPeerId(peer);
+ const normalizedMedia = await this.client._normalizeInputMedia(prepared.media, { uploadPeer: peer });
+ const request = {
+ ...prepared.request,
+ peer,
+ media: normalizedMedia,
+ };
+ const result = await this.client.call(request);
+ this.client.handleClientUpdate(result, true);
+ const messageId = extractMessageIdFromSendUpdates(result, prepared.request.randomId);
+ if (!messageId) {
+ throw new Error('Failed to resolve sent photo message id from Telegram updates.');
+ }
+ let sent;
+ try {
+ [sent] = await this.client.getMessages(prepared.peerRef, Number(messageId));
+ } catch (error) {
+ // file_id enrichment is best-effort and must not flip a successful send into failure
+ console.error(`[sendPhoto] getMessages enrichment failed for peer ${prepared.peerRef}, message ${messageId}: ${error.message}`);
+ }
+ if (sent) {
+ return {
+ chatId,
+ ...buildSendMessageResult(sent, { method: 'sendPhoto', defaultMediaType: 'photo' }),
+ };
+ }
+ return {
+ chatId,
+ messageId: Number(messageId),
+ method: 'sendPhoto',
+ media: { type: 'photo' },
+ warning: 'Media enrichment failed; file_id unavailable',
+ };
+ }
+
+ async sendPhotoMessage(channelId, filePath, options = {}) {
+ const prepared = await this.preparePhotoMessage(channelId, filePath, options);
+ return this.sendPreparedPhotoMessage(prepared);
}
async downloadMessageMedia(channelId, messageId, options = {}) {
diff --git a/tests/cli-send-photo.test.js b/tests/cli-send-photo.test.js
new file mode 100644
index 0000000..b680595
--- /dev/null
+++ b/tests/cli-send-photo.test.js
@@ -0,0 +1,204 @@
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { buildSendPhotoSuccessPayload, logSendRetry, normalizeSendCommandError, parseNonNegativeInt, shouldRunMain, writeError } from '../cli.js';
+import { SendCommandError, buildSendErrorPayload } from '../core/send-utils.js';
+
+describe('tgcli send photo CLI validation', () => {
+ const tempDirs = [];
+
+ afterEach(() => {
+ for (const dir of tempDirs) {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ tempDirs.length = 0;
+ });
+
+ it('rejects fractional --retries values', () => {
+ expect(() => parseNonNegativeInt('1.5', '--retries')).toThrow(
+ '--retries must be a non-negative integer',
+ );
+ });
+
+ it('treats symlinked bin paths as the CLI entrypoint', async () => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tgcli-cli-entry-'));
+ tempDirs.push(tempDir);
+ const symlinkPath = path.join(tempDir, 'tgcli');
+ fs.symlinkSync(path.resolve('cli.js'), symlinkPath);
+
+ expect(shouldRunMain(symlinkPath)).toBe(true);
+ expect(shouldRunMain(path.join(tempDir, 'not-cli'))).toBe(false);
+ });
+
+ it('uses the resolved peer id for photo JSON chat_id output', () => {
+ expect(buildSendPhotoSuccessPayload({
+ method: 'sendPhoto',
+ inputChatId: '@some-alias',
+ result: {
+ chatId: '999',
+ messageId: 123,
+ media: { type: 'photo', fileId: 'photo-file-id' },
+ },
+ attempts: 2,
+ })).toEqual({
+ ok: true,
+ method: 'sendPhoto',
+ chat_id: '999',
+ message_id: 123,
+ media: {
+ type: 'photo',
+ file_id: 'photo-file-id',
+ },
+ attempts: 2,
+ });
+ });
+
+ it('falls back to inputChatId when result.chatId is absent', () => {
+ expect(buildSendPhotoSuccessPayload({
+ method: 'sendPhoto',
+ inputChatId: '@fallback-alias',
+ result: {
+ messageId: 789,
+ media: { type: 'photo', fileId: 'some-id' },
+ },
+ attempts: 1,
+ })).toEqual({
+ ok: true,
+ method: 'sendPhoto',
+ chat_id: '@fallback-alias',
+ message_id: 789,
+ media: {
+ type: 'photo',
+ file_id: 'some-id',
+ },
+ attempts: 1,
+ });
+ });
+});
+
+describe('buildSendPhotoSuccessPayload with warning', () => {
+ it('propagates warning from result into JSON payload', () => {
+ const payload = buildSendPhotoSuccessPayload({
+ method: 'sendPhoto',
+ inputChatId: '@chat',
+ result: {
+ chatId: '999',
+ messageId: 505,
+ media: { type: 'photo' },
+ warning: 'Media enrichment failed; file_id unavailable',
+ },
+ attempts: 1,
+ });
+ expect(payload.warning).toBe('Media enrichment failed; file_id unavailable');
+ expect(payload.ok).toBe(true);
+ });
+
+ it('omits warning when result has no warning', () => {
+ const payload = buildSendPhotoSuccessPayload({
+ method: 'sendPhoto',
+ inputChatId: '@chat',
+ result: {
+ chatId: '999',
+ messageId: 123,
+ media: { type: 'photo', fileId: 'abc' },
+ },
+ attempts: 1,
+ });
+ expect(payload).not.toHaveProperty('warning');
+ });
+});
+
+describe('normalizeSendCommandError', () => {
+ it('passes through SendCommandError as-is', () => {
+ const details = { type: 'validation', method: 'sendPhoto', message: 'bad', attempt: 1, retries: 0 };
+ const err = new SendCommandError(details);
+ expect(normalizeSendCommandError(err, { method: 'sendPhoto' })).toBe(err);
+ });
+
+ it('does not wrap TypeError into SendCommandError', () => {
+ const err = new TypeError('x is not a function');
+ const result = normalizeSendCommandError(err, { method: 'sendPhoto' });
+ expect(result).toBe(err);
+ expect(result).toBeInstanceOf(TypeError);
+ });
+
+ it('wraps operational errors into SendCommandError', () => {
+ const err = new Error('ECONNRESET');
+ err.code = 'ECONNRESET';
+ const result = normalizeSendCommandError(err, { method: 'sendPhoto', retries: 2 });
+ expect(result).toBeInstanceOf(SendCommandError);
+ expect(result.details).toMatchObject({ type: 'network', method: 'sendPhoto' });
+ });
+});
+
+describe('writeError', () => {
+ let stderrSpy;
+
+ beforeEach(() => {
+ stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
+ });
+
+ afterEach(() => {
+ stderrSpy.mockRestore();
+ });
+
+ it('writes structured JSON to stderr for SendCommandError in JSON mode', () => {
+ const details = { type: 'network', method: 'sendPhoto', message: 'ECONNRESET', code: 'ECONNRESET', attempt: 2, retries: 3 };
+ const err = new SendCommandError(details);
+ writeError(err, true);
+ const written = JSON.parse(stderrSpy.mock.calls[0][0]);
+ expect(written).toEqual(buildSendErrorPayload(details));
+ });
+
+ it('writes human-readable message to stderr for SendCommandError in text mode', () => {
+ const details = { type: 'timeout', method: 'sendPhoto', message: 'Timeout', attempt: 1, retries: 0 };
+ const err = new SendCommandError(details);
+ writeError(err, false);
+ expect(stderrSpy.mock.calls[0][0]).toContain('sendPhoto failed [timeout]');
+ });
+
+ it('writes generic JSON error for non-SendCommandError in JSON mode', () => {
+ writeError(new Error('something broke'), true);
+ const written = JSON.parse(stderrSpy.mock.calls[0][0]);
+ expect(written).toEqual({ ok: false, error: 'something broke' });
+ });
+});
+
+describe('logSendRetry', () => {
+ let stderrSpy;
+
+ beforeEach(() => {
+ stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
+ });
+
+ afterEach(() => {
+ stderrSpy.mockRestore();
+ });
+
+ it('writes structured JSON event to stderr in JSON mode', () => {
+ const details = { type: 'network', method: 'sendPhoto', message: 'ECONNRESET', attempt: 1, retries: 3 };
+ logSendRetry(details, { json: true });
+ const written = JSON.parse(stderrSpy.mock.calls[0][0]);
+ expect(written).toEqual({ event: 'retry', type: 'network', method: 'sendPhoto', message: 'ECONNRESET', attempt: 1, retries: 3 });
+ });
+
+ it('writes human-readable retry message to stderr in text mode', () => {
+ const details = { type: 'network', method: 'sendPhoto', message: 'ECONNRESET', code: 'ECONNRESET', attempt: 1, retries: 3 };
+ logSendRetry(details, { json: false });
+ const output = stderrSpy.mock.calls[0][0];
+ expect(output).toContain('sendPhoto transient network error');
+ expect(output).toContain('attempt 1/4');
+ expect(output).toContain('(ECONNRESET)');
+ });
+
+ it('omits code suffix when code is absent', () => {
+ const details = { type: 'timeout', method: 'sendPhoto', message: 'timed out', attempt: 2, retries: 2 };
+ logSendRetry(details, { json: false });
+ const output = stderrSpy.mock.calls[0][0];
+ expect(output).not.toContain('(');
+ expect(output).toContain('attempt 2/3');
+ });
+});
diff --git a/tests/send-messages.test.js b/tests/send-messages.test.js
index ec6b644..e52ddcb 100644
--- a/tests/send-messages.test.js
+++ b/tests/send-messages.test.js
@@ -4,6 +4,7 @@ vi.mock('@mtcute/node', () => ({
vi.mock('@mtcute/core', () => ({
InputMedia: {
auto: vi.fn((path, opts) => ({ path, ...opts })),
+ photo: vi.fn((path, opts) => ({ path, ...opts })),
},
}));
vi.mock('@mtcute/markdown-parser', () => ({
@@ -28,13 +29,23 @@ function createMockClient() {
tc.client = {
sendText: vi.fn().mockResolvedValue({ id: 101 }),
sendMedia: vi.fn().mockResolvedValue({ id: 202 }),
+ resolvePeer: vi.fn().mockResolvedValue({ _: 'inputPeerChannel', channelId: 999 }),
+ _normalizeInputMedia: vi.fn(async (media) => ({ _: 'inputMediaUploadedPhoto', media })),
+ call: vi.fn().mockResolvedValue({
+ updates: [
+ { _: 'updateMessageID', id: 202, randomId: { eq: () => true } },
+ { _: 'updateNewChannelMessage', message: { id: 202 } },
+ ],
+ }),
+ handleClientUpdate: vi.fn(),
+ getMessages: vi.fn().mockResolvedValue([{ id: 202, media: { type: 'photo', fileId: 'photo-file-id' } }]),
};
return tc;
}
-function createTempFile() {
+function createTempFile(extension = '.txt') {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tgcli-send-test-'));
- const filePath = path.join(dir, 'sample.txt');
+ const filePath = path.join(dir, `sample${extension}`);
fs.writeFileSync(filePath, 'sample file');
return { dir, filePath };
}
@@ -161,7 +172,7 @@ describe('sendTextMessage new send parameters', () => {
it('--no-forwards passes noforwards: true in params', async () => {
await tc.sendTextMessage('@chat', 'hello', { noforwards: true });
- expect(tc.client.sendText).toHaveBeenCalledWith('@chat', 'hello', { noforwards: true });
+ expect(tc.client.sendText).toHaveBeenCalledWith('@chat', 'hello', { forbidForwards: true });
});
it('--schedule passes scheduleDate as unix timestamp in params', async () => {
@@ -190,14 +201,14 @@ describe('sendTextMessage new send parameters', () => {
});
expect(tc.client.sendText).toHaveBeenCalledWith('@chat', 'hello', {
silent: true,
- noforwards: true,
+ forbidForwards: true,
scheduleDate,
});
});
it('noForwards (camelCase) passes noforwards: true in params', async () => {
await tc.sendTextMessage('@chat', 'hello', { noForwards: true });
- expect(tc.client.sendText).toHaveBeenCalledWith('@chat', 'hello', { noforwards: true });
+ expect(tc.client.sendText).toHaveBeenCalledWith('@chat', 'hello', { forbidForwards: true });
});
it('schedule (ISO string) passes scheduleDate as unix timestamp', async () => {
@@ -233,7 +244,7 @@ describe('sendFileMessage new send parameters', () => {
it('--no-forwards passes noforwards: true in params', async () => {
await tc.sendFileMessage('@chat', temp.filePath, { noforwards: true });
- expect(tc.client.sendMedia).toHaveBeenCalledWith('@chat', expect.anything(), { noforwards: true });
+ expect(tc.client.sendMedia).toHaveBeenCalledWith('@chat', expect.anything(), { forbidForwards: true });
});
it('--schedule passes scheduleDate in params', async () => {
@@ -247,7 +258,7 @@ describe('sendFileMessage new send parameters', () => {
caption: 'my caption',
captionAbove: true,
});
- expect(tc.client.sendMedia).toHaveBeenCalledWith('@chat', expect.anything(), { invertMedia: true });
+ expect(tc.client.sendMedia).toHaveBeenCalledWith('@chat', expect.anything(), { invert: true });
});
it('--caption-above without caption throws error', async () => {
@@ -276,7 +287,7 @@ describe('sendFileMessage new send parameters', () => {
it('noForwards (camelCase) passes noforwards: true in params', async () => {
await tc.sendFileMessage('@chat', temp.filePath, { noForwards: true });
- expect(tc.client.sendMedia).toHaveBeenCalledWith('@chat', expect.anything(), { noforwards: true });
+ expect(tc.client.sendMedia).toHaveBeenCalledWith('@chat', expect.anything(), { forbidForwards: true });
});
it('schedule (ISO string) passes scheduleDate as unix timestamp', async () => {
@@ -294,7 +305,7 @@ describe('sendFileMessage new send parameters', () => {
});
expect(tc.client.sendMedia).toHaveBeenCalledWith('@chat', expect.anything(), {
silent: true,
- noforwards: true,
+ forbidForwards: true,
replyTo: 99,
});
});
@@ -305,6 +316,253 @@ describe('sendFileMessage new send parameters', () => {
});
});
+describe('sendPhotoMessage', () => {
+ let tc;
+ let png;
+ let jpg;
+
+ beforeEach(() => {
+ tc = createMockClient();
+ png = createTempFile('.png');
+ jpg = createTempFile('.jpg');
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ fs.rmSync(png.dir, { recursive: true, force: true });
+ fs.rmSync(jpg.dir, { recursive: true, force: true });
+ });
+
+ it('sends local png via InputMedia.photo', async () => {
+ await tc.sendPhotoMessage('@chat', png.filePath, {});
+ const { InputMedia } = await import('@mtcute/core');
+ expect(InputMedia.photo).toHaveBeenCalledWith(expect.stringContaining('file:'), {});
+ expect(tc.client.resolvePeer).toHaveBeenCalledWith('@chat');
+ expect(tc.client._normalizeInputMedia).toHaveBeenCalledTimes(1);
+ expect(tc.client.call).toHaveBeenCalledTimes(1);
+ });
+
+ it('sends local jpg via InputMedia.photo', async () => {
+ await tc.sendPhotoMessage('@chat', jpg.filePath, {});
+ const { InputMedia } = await import('@mtcute/core');
+ expect(InputMedia.photo).toHaveBeenCalledWith(expect.stringContaining('file:'), {});
+ });
+
+ it('applies markdown parse mode to photo caption', async () => {
+ await tc.sendPhotoMessage('@chat', png.filePath, {
+ caption: '**caption**',
+ parseMode: 'markdown',
+ });
+ const { InputMedia } = await import('@mtcute/core');
+ expect(md).toHaveBeenCalledWith('**caption**');
+ expect(InputMedia.photo).toHaveBeenCalledWith(
+ expect.stringContaining('file:'),
+ expect.objectContaining({
+ caption: { text: '**caption**', entities: [{ type: 'bold' }] },
+ }),
+ );
+ });
+
+ it('passes reply-to, topic fallback, silent, no-forwards, caption-above, spoiler, and schedule to low-level sendMedia request', async () => {
+ const scheduleDate = Math.floor(Date.now() / 1000) + 1800;
+ await tc.sendPhotoMessage('@chat', png.filePath, {
+ caption: 'preview',
+ topicId: 42,
+ replyToMessageId: 77,
+ silent: true,
+ noForwards: true,
+ captionAbove: true,
+ spoiler: true,
+ scheduleDate,
+ });
+
+ const { InputMedia } = await import('@mtcute/core');
+ expect(InputMedia.photo).toHaveBeenCalledWith(
+ expect.stringContaining('file:'),
+ expect.objectContaining({ spoiler: true }),
+ );
+ expect(tc.client.call).toHaveBeenCalledWith(expect.objectContaining({
+ _: 'messages.sendMedia',
+ silent: true,
+ scheduleDate,
+ noforwards: true,
+ invertMedia: true,
+ replyTo: {
+ _: 'inputReplyToMessage',
+ replyToMsgId: 77,
+ },
+ }));
+ });
+
+ it('rejects caption-above without caption for send photo', async () => {
+ await expect(
+ tc.sendPhotoMessage('@chat', png.filePath, { captionAbove: true }),
+ ).rejects.toThrow('--caption-above requires --caption for send photo');
+ });
+
+ it('returns best-effort media metadata for successful photo sends', async () => {
+ tc.client.call.mockResolvedValueOnce({
+ updates: [
+ { _: 'updateMessageID', id: 303, randomId: { eq: () => true } },
+ { _: 'updateNewChannelMessage', message: { id: 303 } },
+ ],
+ });
+ tc.client.getMessages.mockResolvedValueOnce([{ id: 303, media: { type: 'photo', fileId: 'photo-file-id' } }]);
+
+ const result = await tc.sendPhotoMessage('@chat', png.filePath, {});
+ expect(result).toMatchObject({
+ chatId: '999',
+ messageId: 303,
+ method: 'sendPhoto',
+ media: {
+ type: 'photo',
+ fileId: 'photo-file-id',
+ },
+ });
+ expect(tc.client.handleClientUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ updates: expect.any(Array),
+ }),
+ true,
+ );
+ });
+
+ it('supports Saved Messages self targets without requiring a numeric peer id', async () => {
+ tc.client.resolvePeer.mockResolvedValueOnce({ _: 'inputPeerSelf' });
+ tc.client.call.mockResolvedValueOnce({
+ updates: [
+ { _: 'updateMessageID', id: 606, randomId: { eq: () => true } },
+ { _: 'updateNewChannelMessage', message: { id: 606 } },
+ ],
+ });
+ tc.client.getMessages.mockResolvedValueOnce([{ id: 606, media: { type: 'photo', fileId: 'saved-photo-id' } }]);
+
+ const result = await tc.sendPhotoMessage('me', png.filePath, {});
+
+ expect(tc.client._normalizeInputMedia).toHaveBeenCalledWith(expect.anything(), {
+ uploadPeer: { _: 'inputPeerSelf' },
+ });
+ expect(result).toMatchObject({
+ chatId: 'me',
+ messageId: 606,
+ method: 'sendPhoto',
+ media: {
+ type: 'photo',
+ fileId: 'saved-photo-id',
+ },
+ });
+ });
+
+ it('does not fail the send when best-effort metadata lookup errors', async () => {
+ tc.client.call.mockResolvedValueOnce({
+ updates: [
+ { _: 'updateMessageID', id: 505, randomId: { eq: () => true } },
+ { _: 'updateNewChannelMessage', message: { id: 505 } },
+ ],
+ });
+ tc.client.getMessages.mockRejectedValueOnce(Object.assign(new Error('temporary lookup failure'), { code: 'ECONNRESET' }));
+
+ const result = await tc.sendPhotoMessage('@chat', png.filePath, {});
+ expect(result).toEqual({
+ chatId: '999',
+ messageId: 505,
+ method: 'sendPhoto',
+ media: { type: 'photo' },
+ warning: 'Media enrichment failed; file_id unavailable',
+ });
+ });
+
+ it('reuses the same prepared request and randomId across photo send retries', async () => {
+ const prepared = await tc.preparePhotoMessage('@chat', png.filePath, { caption: 'retry me' });
+ tc.client.call
+ .mockRejectedValueOnce(Object.assign(new Error('ECONNRESET'), { code: 'ECONNRESET' }))
+ .mockResolvedValueOnce({
+ updates: [
+ {
+ _: 'updateMessageID',
+ id: 404,
+ randomId: { eq: (value) => String(value) === String(prepared.request.randomId) },
+ },
+ { _: 'updateNewChannelMessage', message: { id: 404 } },
+ ],
+ });
+ tc.client.getMessages.mockResolvedValueOnce([{ id: 404, media: { type: 'photo' } }]);
+
+ await expect(tc.sendPreparedPhotoMessage(prepared)).rejects.toThrow('ECONNRESET');
+ await tc.sendPreparedPhotoMessage(prepared);
+
+ const [firstRequest] = tc.client.call.mock.calls[0];
+ const [secondRequest] = tc.client.call.mock.calls[1];
+ expect(firstRequest).not.toBe(secondRequest);
+ expect(String(firstRequest.randomId)).toBe(String(prepared.request.randomId));
+ expect(String(secondRequest.randomId)).toBe(String(prepared.request.randomId));
+ expect(tc.client._normalizeInputMedia).toHaveBeenCalledTimes(2);
+ });
+
+ it('throws when photo filePath does not exist', async () => {
+ await expect(
+ tc.sendPhotoMessage('@chat', '/tmp/nonexistent-photo-12345.png', {}),
+ ).rejects.toThrow('File not found:');
+ });
+
+ it('throws when photo filePath is empty', async () => {
+ await expect(
+ tc.sendPhotoMessage('@chat', '', {}),
+ ).rejects.toThrow('filePath must be a string.');
+ });
+
+ it('extracts userId from inputPeerUser for photo send chatId', async () => {
+ tc.client.resolvePeer.mockResolvedValueOnce({ _: 'inputPeerUser', userId: 42 });
+ tc.client.call.mockResolvedValueOnce({
+ updates: [
+ { _: 'updateMessageID', id: 707, randomId: { eq: () => true } },
+ { _: 'updateNewMessage', message: { id: 707 } },
+ ],
+ });
+ tc.client.getMessages.mockResolvedValueOnce([{ id: 707, media: { type: 'photo', fileId: 'user-photo-id' } }]);
+
+ const result = await tc.sendPhotoMessage('42', png.filePath, {});
+ expect(result).toMatchObject({
+ chatId: '42',
+ messageId: 707,
+ });
+ });
+
+ it('rejects --parse-mode without --caption for preparePhotoMessage', async () => {
+ await expect(
+ tc.preparePhotoMessage('@chat', png.filePath, { parseMode: 'markdown' }),
+ ).rejects.toThrow('--parse-mode requires --caption for send photo');
+ });
+
+ it('throws when extractMessageIdFromSendUpdates returns null (no matching update)', async () => {
+ tc.client.call.mockResolvedValueOnce({
+ updates: [],
+ });
+
+ await expect(
+ tc.sendPhotoMessage('@chat', png.filePath, {}),
+ ).rejects.toThrow('Failed to resolve sent photo message id from Telegram updates.');
+ });
+
+ it('extracts messageId from updateNewChannelMessage when updateMessageID is absent', async () => {
+ tc.client.call.mockResolvedValueOnce({
+ updates: [
+ { _: 'updateNewChannelMessage', message: { id: 808 } },
+ ],
+ });
+ tc.client.getMessages.mockResolvedValueOnce([{ id: 808, media: { type: 'photo', fileId: 'fallback-id' } }]);
+
+ const result = await tc.sendPhotoMessage('@chat', png.filePath, {});
+ expect(result).toMatchObject({ messageId: 808 });
+ });
+
+ it('does not upload during preparePhotoMessage, so upload failures can be retried later', async () => {
+ await tc.preparePhotoMessage('@chat', png.filePath, { caption: 'retry me' });
+ expect(tc.client.resolvePeer).not.toHaveBeenCalled();
+ expect(tc.client._normalizeInputMedia).not.toHaveBeenCalled();
+ });
+});
+
describe('resolveScheduleDate error handling', () => {
let tc;
diff --git a/tests/send-utils.test.js b/tests/send-utils.test.js
new file mode 100644
index 0000000..3e12d54
--- /dev/null
+++ b/tests/send-utils.test.js
@@ -0,0 +1,626 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import {
+ buildSendErrorPayload,
+ buildSendSuccessPayload,
+ classifySendError,
+ executeSendWithRetries,
+ formatSendErrorMessage,
+ getRetryDelayMs,
+ parseRetryBackoff,
+ SendCommandError,
+} from '../core/send-utils.js';
+
+describe('executeSendWithRetries', () => {
+ it('retries once and succeeds on transient network error', async () => {
+ const sendFn = vi.fn()
+ .mockRejectedValueOnce(Object.assign(new Error('ECONNRESET'), { code: 'ECONNRESET' }))
+ .mockResolvedValueOnce({ messageId: 456, media: { type: 'photo', fileId: 'file-123' } });
+ const sleep = vi.fn().mockResolvedValue(undefined);
+
+ const result = await executeSendWithRetries(sendFn, {
+ method: 'sendPhoto',
+ retries: 2,
+ retryBackoff: parseRetryBackoff('25'),
+ sleep,
+ });
+
+ expect(sendFn).toHaveBeenCalledTimes(2);
+ expect(sleep).toHaveBeenCalledWith(25);
+ expect(result).toEqual({
+ result: { messageId: 456, media: { type: 'photo', fileId: 'file-123' } },
+ attempts: 2,
+ });
+ });
+
+ it('does not retry validation errors', async () => {
+ const sendFn = vi.fn().mockRejectedValue(new Error('File not found: /tmp/missing.png'));
+
+ await expect(
+ executeSendWithRetries(sendFn, {
+ method: 'sendPhoto',
+ retries: 3,
+ retryBackoff: parseRetryBackoff('constant'),
+ }),
+ ).rejects.toMatchObject({
+ name: 'SendCommandError',
+ details: expect.objectContaining({
+ type: 'validation',
+ method: 'sendPhoto',
+ attempt: 1,
+ retries: 3,
+ }),
+ });
+
+ expect(sendFn).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not retry non-transient telegram errors', async () => {
+ const sendFn = vi.fn().mockRejectedValue(
+ Object.assign(new Error('CHAT_WRITE_FORBIDDEN'), { code: 403 }),
+ );
+
+ await expect(
+ executeSendWithRetries(sendFn, {
+ method: 'sendPhoto',
+ retries: 2,
+ retryBackoff: parseRetryBackoff('linear'),
+ }),
+ ).rejects.toMatchObject({
+ name: 'SendCommandError',
+ details: expect.objectContaining({
+ type: 'telegram',
+ method: 'sendPhoto',
+ attempt: 1,
+ retries: 2,
+ code: 403,
+ }),
+ });
+
+ expect(sendFn).toHaveBeenCalledTimes(1);
+ });
+
+ it('retries mtcute transport errors even when they use numeric codes', async () => {
+ const sendFn = vi.fn()
+ .mockRejectedValueOnce(Object.assign(new Error('Transport error: 404'), { name: 'TransportError', code: 404 }))
+ .mockResolvedValueOnce({ messageId: 789, media: { type: 'photo' } });
+ const sleep = vi.fn().mockResolvedValue(undefined);
+
+ const result = await executeSendWithRetries(sendFn, {
+ method: 'sendPhoto',
+ retries: 2,
+ retryBackoff: parseRetryBackoff('10'),
+ sleep,
+ });
+
+ expect(sendFn).toHaveBeenCalledTimes(2);
+ expect(sleep).toHaveBeenCalledWith(10);
+ expect(result).toEqual({
+ result: { messageId: 789, media: { type: 'photo' } },
+ attempts: 2,
+ });
+ });
+
+ it('continues retrying when onRetry callback throws', async () => {
+ const sendFn = vi.fn()
+ .mockRejectedValueOnce(Object.assign(new Error('ECONNRESET'), { code: 'ECONNRESET' }))
+ .mockResolvedValueOnce({ messageId: 100 });
+ const sleep = vi.fn().mockResolvedValue(undefined);
+ const onRetry = vi.fn(() => { throw new Error('callback crash'); });
+
+ const result = await executeSendWithRetries(sendFn, {
+ method: 'sendPhoto',
+ retries: 2,
+ retryBackoff: parseRetryBackoff('10'),
+ sleep,
+ onRetry,
+ });
+
+ expect(sendFn).toHaveBeenCalledTimes(2);
+ expect(onRetry).toHaveBeenCalledTimes(1);
+ expect(result).toEqual({ result: { messageId: 100 }, attempts: 2 });
+ });
+
+ it('logs onRetry callback errors to stderr instead of swallowing them', async () => {
+ const sendFn = vi.fn()
+ .mockRejectedValueOnce(Object.assign(new Error('ECONNRESET'), { code: 'ECONNRESET' }))
+ .mockResolvedValueOnce({ messageId: 100 });
+ const sleep = vi.fn().mockResolvedValue(undefined);
+ const callbackError = new Error('bad callback');
+ const onRetry = vi.fn(() => { throw callbackError; });
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ await executeSendWithRetries(sendFn, {
+ method: 'sendPhoto',
+ retries: 2,
+ retryBackoff: parseRetryBackoff('10'),
+ sleep,
+ onRetry,
+ });
+
+ expect(errorSpy).toHaveBeenCalledWith(
+ expect.stringContaining('onRetry'),
+ callbackError,
+ );
+ errorSpy.mockRestore();
+ });
+
+ it('re-throws TypeError from onRetry callback instead of swallowing it', async () => {
+ const sendFn = vi.fn()
+ .mockRejectedValueOnce(Object.assign(new Error('ECONNRESET'), { code: 'ECONNRESET' }))
+ .mockResolvedValueOnce({ messageId: 100 });
+ const sleep = vi.fn().mockResolvedValue(undefined);
+ const onRetry = vi.fn(() => { throw new TypeError('x is not a function'); });
+
+ await expect(
+ executeSendWithRetries(sendFn, {
+ method: 'sendPhoto',
+ retries: 2,
+ retryBackoff: parseRetryBackoff('10'),
+ sleep,
+ onRetry,
+ }),
+ ).rejects.toBeInstanceOf(TypeError);
+ });
+
+ it('throws timeout before first attempt when budget already expired', async () => {
+ let currentTime = 100;
+ const sendFn = vi.fn();
+ const sleep = vi.fn();
+
+ await expect(
+ executeSendWithRetries(sendFn, {
+ method: 'sendPhoto',
+ retries: 2,
+ timeoutMs: 1,
+ now: () => { currentTime += 10; return currentTime; },
+ sleep,
+ }),
+ ).rejects.toMatchObject({
+ name: 'SendCommandError',
+ details: expect.objectContaining({
+ type: 'timeout',
+ attempt: 1,
+ retries: 2,
+ }),
+ });
+
+ expect(sendFn).not.toHaveBeenCalled();
+ });
+
+ it('throws timeout when budget expires before second attempt', async () => {
+ let currentTime = 0;
+ const now = vi.fn(() => currentTime);
+ const sleep = vi.fn(async (ms) => { currentTime += ms; });
+ const sendFn = vi.fn(async () => {
+ currentTime += 200;
+ throw Object.assign(new Error('ECONNRESET'), { code: 'ECONNRESET' });
+ });
+
+ await expect(
+ executeSendWithRetries(sendFn, {
+ method: 'sendPhoto',
+ retries: 2,
+ retryBackoff: parseRetryBackoff('50'),
+ timeoutMs: 200,
+ sleep,
+ now,
+ }),
+ ).rejects.toMatchObject({
+ name: 'SendCommandError',
+ details: expect.objectContaining({
+ type: 'timeout',
+ attempt: 1,
+ retries: 2,
+ }),
+ });
+
+ expect(sendFn).toHaveBeenCalledTimes(1);
+ });
+
+ it('fails immediately with retries: 0 and does not sleep', async () => {
+ const sendFn = vi.fn().mockRejectedValue(Object.assign(new Error('ECONNRESET'), { code: 'ECONNRESET' }));
+ const sleep = vi.fn().mockResolvedValue(undefined);
+
+ await expect(
+ executeSendWithRetries(sendFn, {
+ method: 'sendPhoto',
+ retries: 0,
+ retryBackoff: parseRetryBackoff('10'),
+ sleep,
+ }),
+ ).rejects.toMatchObject({
+ name: 'SendCommandError',
+ details: expect.objectContaining({
+ type: 'network',
+ attempt: 1,
+ retries: 0,
+ }),
+ });
+
+ expect(sendFn).toHaveBeenCalledTimes(1);
+ expect(sleep).not.toHaveBeenCalled();
+ });
+
+ it('coerces invalid retries (negative, fractional) to 0', async () => {
+ const sendFn = vi.fn().mockRejectedValue(Object.assign(new Error('ECONNRESET'), { code: 'ECONNRESET' }));
+ const sleep = vi.fn().mockResolvedValue(undefined);
+
+ for (const retries of [-1, 1.5, NaN]) {
+ sendFn.mockClear();
+ sleep.mockClear();
+
+ await expect(
+ executeSendWithRetries(sendFn, { method: 'sendPhoto', retries, sleep }),
+ ).rejects.toMatchObject({ name: 'SendCommandError' });
+
+ expect(sendFn).toHaveBeenCalledTimes(1);
+ expect(sleep).not.toHaveBeenCalled();
+ }
+ });
+
+ it('throws SendCommandError after exhausting all retries', async () => {
+ const sendFn = vi.fn().mockRejectedValue(Object.assign(new Error('ECONNRESET'), { code: 'ECONNRESET' }));
+ const sleep = vi.fn().mockResolvedValue(undefined);
+
+ await expect(
+ executeSendWithRetries(sendFn, {
+ method: 'sendPhoto',
+ retries: 2,
+ retryBackoff: parseRetryBackoff('10'),
+ sleep,
+ }),
+ ).rejects.toMatchObject({
+ name: 'SendCommandError',
+ details: expect.objectContaining({
+ type: 'network',
+ method: 'sendPhoto',
+ attempt: 3,
+ retries: 2,
+ retryable: true,
+ }),
+ });
+
+ expect(sendFn).toHaveBeenCalledTimes(3);
+ expect(sleep).toHaveBeenCalledTimes(2);
+ });
+
+ it('stops retrying when the timeout budget is exhausted during backoff', async () => {
+ let currentTime = 0;
+ const now = vi.fn(() => currentTime);
+ const sleep = vi.fn(async (ms) => {
+ currentTime += ms;
+ });
+ const sendFn = vi.fn().mockRejectedValue(Object.assign(new Error('ECONNRESET'), { code: 'ECONNRESET' }));
+
+ await expect(
+ executeSendWithRetries(sendFn, {
+ method: 'sendPhoto',
+ retries: 2,
+ retryBackoff: parseRetryBackoff('100'),
+ timeoutMs: 100,
+ sleep,
+ now,
+ }),
+ ).rejects.toMatchObject({
+ name: 'SendCommandError',
+ details: expect.objectContaining({
+ type: 'timeout',
+ method: 'sendPhoto',
+ attempt: 1,
+ retries: 2,
+ }),
+ });
+
+ expect(sendFn).toHaveBeenCalledTimes(1);
+ expect(sleep).toHaveBeenCalledWith(100);
+ });
+
+ it('clamps sleep duration to remaining timeout budget', async () => {
+ let currentTime = 0;
+ const now = vi.fn(() => currentTime);
+ const sleep = vi.fn(async (ms) => { currentTime += ms - 1; });
+ const sendFn = vi.fn()
+ .mockImplementationOnce(async () => { currentTime += 80; throw Object.assign(new Error('ECONNRESET'), { code: 'ECONNRESET' }); })
+ .mockResolvedValueOnce({ messageId: 1 });
+
+ await executeSendWithRetries(sendFn, {
+ method: 'sendPhoto',
+ retries: 2,
+ retryBackoff: parseRetryBackoff('500'),
+ timeoutMs: 200,
+ sleep,
+ now,
+ });
+
+ expect(sleep).toHaveBeenCalledWith(120);
+ });
+});
+
+describe('send payload builders', () => {
+ it('creates structured JSON success payload', () => {
+ expect(
+ buildSendSuccessPayload({
+ method: 'sendPhoto',
+ chatId: 123,
+ messageId: 456,
+ media: { type: 'photo', fileId: 'file-123' },
+ attempts: 2,
+ }),
+ ).toEqual({
+ ok: true,
+ method: 'sendPhoto',
+ chat_id: 123,
+ message_id: 456,
+ media: {
+ type: 'photo',
+ file_id: 'file-123',
+ },
+ attempts: 2,
+ });
+ });
+
+ it('includes warning in success payload when provided', () => {
+ const result = buildSendSuccessPayload({
+ method: 'sendPhoto',
+ chatId: 123,
+ messageId: 456,
+ media: { type: 'photo' },
+ attempts: 1,
+ warning: 'Media enrichment failed; file_id unavailable',
+ });
+ expect(result.warning).toBe('Media enrichment failed; file_id unavailable');
+ });
+
+ it('omits warning from success payload when not provided', () => {
+ const result = buildSendSuccessPayload({
+ method: 'sendPhoto',
+ chatId: 123,
+ messageId: 456,
+ attempts: 1,
+ });
+ expect(result).not.toHaveProperty('warning');
+ });
+
+ it('includes media with only type when fileId is absent', () => {
+ const result = buildSendSuccessPayload({
+ method: 'sendPhoto',
+ chatId: 123,
+ messageId: 456,
+ media: { type: 'photo' },
+ attempts: 1,
+ });
+ expect(result.media).toEqual({ type: 'photo' });
+ expect(result.media).not.toHaveProperty('file_id');
+ });
+
+ it('omits media field when media is absent or empty', () => {
+ const withoutMedia = buildSendSuccessPayload({
+ method: 'sendPhoto',
+ chatId: 123,
+ messageId: 456,
+ attempts: 1,
+ });
+ expect(withoutMedia).not.toHaveProperty('media');
+
+ const withEmptyMedia = buildSendSuccessPayload({
+ method: 'sendPhoto',
+ chatId: 123,
+ messageId: 456,
+ media: {},
+ attempts: 1,
+ });
+ expect(withEmptyMedia).not.toHaveProperty('media');
+ });
+
+ it('includes code:0 in error payload (falsy but valid)', () => {
+ const result = buildSendErrorPayload({
+ type: 'telegram',
+ method: 'sendPhoto',
+ message: 'Some error',
+ code: 0,
+ attempt: 1,
+ retries: 0,
+ });
+ expect(result.error.code).toBe(0);
+ });
+
+ it('creates structured JSON error payload', () => {
+ const error = new SendCommandError({
+ type: 'network',
+ method: 'sendPhoto',
+ message: 'ECONNRESET',
+ code: 'ECONNRESET',
+ attempt: 2,
+ retries: 3,
+ });
+
+ expect(buildSendErrorPayload(error.details)).toEqual({
+ ok: false,
+ error: {
+ type: 'network',
+ method: 'sendPhoto',
+ message: 'ECONNRESET',
+ code: 'ECONNRESET',
+ attempt: 2,
+ retries: 3,
+ },
+ });
+ });
+});
+
+describe('classifySendError', () => {
+ it('returns existing details for SendCommandError', () => {
+ const details = { type: 'validation', method: 'sendPhoto', message: 'bad', attempt: 1, retries: 0 };
+ const result = classifySendError(new SendCommandError(details));
+ expect(result).toBe(details);
+ });
+
+ it('classifies ENOENT as validation error', () => {
+ const error = Object.assign(new Error('ENOENT: no such file'), { code: 'ENOENT' });
+ const result = classifySendError(error, { method: 'sendPhoto' });
+ expect(result).toMatchObject({ type: 'validation', retryable: false });
+ });
+
+ it('classifies "File not found:" message as validation error', () => {
+ const result = classifySendError(new Error('File not found: /tmp/x.png'), { method: 'sendPhoto' });
+ expect(result).toMatchObject({ type: 'validation', retryable: false });
+ });
+
+ it('classifies ETIMEDOUT as retryable timeout', () => {
+ const error = Object.assign(new Error('connect ETIMEDOUT'), { code: 'ETIMEDOUT' });
+ const result = classifySendError(error, { method: 'sendPhoto' });
+ expect(result).toMatchObject({ type: 'timeout', retryable: true, code: 'ETIMEDOUT' });
+ });
+
+ it('classifies ETIMEDOUT code with bare "Timeout" message as retryable (code takes precedence)', () => {
+ const error = Object.assign(new Error('Timeout'), { code: 'ETIMEDOUT' });
+ const result = classifySendError(error, { method: 'sendPhoto' });
+ expect(result).toMatchObject({ type: 'timeout', retryable: true, code: 'ETIMEDOUT' });
+ });
+
+ it('classifies bare "timeout" message as non-retryable timeout', () => {
+ const result = classifySendError(new Error('Timeout'), { method: 'sendPhoto' });
+ expect(result).toMatchObject({ type: 'timeout', retryable: false });
+ });
+
+ it('classifies "timed out" message without code as retryable timeout', () => {
+ const result = classifySendError(new Error('connection timed out'), { method: 'sendPhoto' });
+ expect(result).toMatchObject({ type: 'timeout', retryable: true });
+ });
+
+ it('classifies "Request timeout" substring as retryable timeout', () => {
+ const result = classifySendError(new Error('Request timeout exceeded'), { method: 'sendPhoto' });
+ expect(result).toMatchObject({ type: 'timeout', retryable: true });
+ });
+
+ it('classifies MtRpcError by name as telegram error', () => {
+ const error = Object.assign(new Error('PEER_ID_INVALID'), { name: 'MtRpcError' });
+ const result = classifySendError(error, { method: 'sendPhoto' });
+ expect(result).toMatchObject({ type: 'telegram', retryable: false });
+ });
+
+ it('classifies ECONNRESET as retryable network error', () => {
+ const error = Object.assign(new Error('ECONNRESET'), { code: 'ECONNRESET' });
+ const result = classifySendError(error, { method: 'sendPhoto' });
+ expect(result).toMatchObject({ type: 'network', retryable: true });
+ });
+
+ it('classifies TransportError with numeric code as retryable network', () => {
+ const error = Object.assign(new Error('Transport error: 404'), { name: 'TransportError', code: 404 });
+ const result = classifySendError(error, { method: 'sendPhoto' });
+ expect(result).toMatchObject({ type: 'network', retryable: true });
+ });
+
+ it('classifies numeric code (non-transport) as non-retryable telegram error', () => {
+ const error = Object.assign(new Error('CHAT_WRITE_FORBIDDEN'), { code: 403 });
+ const result = classifySendError(error, { method: 'sendPhoto' });
+ expect(result).toMatchObject({ type: 'telegram', retryable: false, code: 403 });
+ });
+
+ it('classifies FLOOD_WAIT as non-retryable telegram error (retry requires dynamic backoff not yet supported)', () => {
+ const result = classifySendError(new Error('FLOOD_WAIT_30'), { method: 'sendPhoto' });
+ expect(result).toMatchObject({ type: 'telegram', retryable: false });
+ });
+
+ it('classifies RpcError by name as telegram error', () => {
+ const error = Object.assign(new Error('PEER_ID_INVALID'), { name: 'RpcError' });
+ const result = classifySendError(error, { method: 'sendPhoto' });
+ expect(result).toMatchObject({ type: 'telegram', retryable: false });
+ });
+
+ it('classifies unknown errors as non-retryable unknown fallback', () => {
+ const result = classifySendError(new Error('something weird'), { method: 'sendPhoto' });
+ expect(result).toMatchObject({ type: 'unknown', retryable: false });
+ });
+
+ it('handles null and primitive error arguments', () => {
+ const nullResult = classifySendError(null, { method: 'sendPhoto' });
+ expect(nullResult).toMatchObject({ type: 'unknown', message: 'Unknown error' });
+
+ const stringResult = classifySendError('raw string error', { method: 'sendPhoto' });
+ expect(stringResult).toMatchObject({ type: 'unknown', message: 'raw string error' });
+ });
+
+ it('passes method, attempt, and retries through', () => {
+ const result = classifySendError(new Error('ECONNRESET'), { method: 'sendPhoto', attempt: 3, retries: 5 });
+ expect(result.method).toBe('sendPhoto');
+ expect(result.attempt).toBe(3);
+ expect(result.retries).toBe(5);
+ });
+});
+
+describe('getRetryDelayMs', () => {
+ it('returns constant baseMs regardless of attempt', () => {
+ const backoff = parseRetryBackoff('500');
+ expect(getRetryDelayMs(backoff, 1)).toBe(500);
+ expect(getRetryDelayMs(backoff, 3)).toBe(500);
+ });
+
+ it('returns linear baseMs * attempt', () => {
+ const backoff = parseRetryBackoff('linear');
+ expect(getRetryDelayMs(backoff, 1)).toBe(1000);
+ expect(getRetryDelayMs(backoff, 3)).toBe(3000);
+ });
+
+ it('returns exponential baseMs * 2^(attempt-1)', () => {
+ const backoff = parseRetryBackoff('exponential');
+ expect(getRetryDelayMs(backoff, 1)).toBe(1000);
+ expect(getRetryDelayMs(backoff, 2)).toBe(2000);
+ expect(getRetryDelayMs(backoff, 3)).toBe(4000);
+ });
+
+ it('falls back to constant for undefined backoff', () => {
+ expect(getRetryDelayMs(undefined, 2)).toBe(1000);
+ });
+
+ it('works with explicit object not produced by parseRetryBackoff', () => {
+ expect(getRetryDelayMs({ kind: 'exponential', baseMs: 500 }, 3)).toBe(2000);
+ expect(getRetryDelayMs({ kind: 'linear', baseMs: 200 }, 4)).toBe(800);
+ expect(getRetryDelayMs({ kind: 'constant', baseMs: 300 }, 5)).toBe(300);
+ });
+});
+
+describe('parseRetryBackoff validation', () => {
+ it('throws on invalid string input', () => {
+ expect(() => parseRetryBackoff('invalid')).toThrow(
+ '--retry-backoff must be a non-negative integer or one of: constant, linear, exponential',
+ );
+ });
+
+ it('throws on negative numeric string', () => {
+ expect(() => parseRetryBackoff('-5')).toThrow(
+ '--retry-backoff must be a non-negative integer or one of: constant, linear, exponential',
+ );
+ });
+});
+
+describe('formatSendErrorMessage', () => {
+ it('formats error details into human-readable string', () => {
+ const msg = formatSendErrorMessage({
+ type: 'network',
+ method: 'sendPhoto',
+ message: 'ECONNRESET',
+ code: 'ECONNRESET',
+ attempt: 2,
+ retries: 3,
+ });
+ expect(msg).toBe('sendPhoto failed [network]: ECONNRESET (attempt 2/4, code ECONNRESET)');
+ });
+
+ it('omits code suffix when code is absent', () => {
+ const msg = formatSendErrorMessage({
+ type: 'timeout',
+ method: 'sendPhoto',
+ message: 'Timeout',
+ attempt: 1,
+ retries: 0,
+ });
+ expect(msg).toBe('sendPhoto failed [timeout]: Timeout (attempt 1/1)');
+ });
+
+ it('uses defaults for missing fields', () => {
+ const msg = formatSendErrorMessage({});
+ expect(msg).toBe('sendMedia failed [unknown]: Unknown error (attempt 1/1)');
+ });
+});