From 0c93485adb618e1fa2275db3f1162895912e0494 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Thu, 12 Mar 2026 16:54:35 +0300 Subject: [PATCH 01/20] feat: add send photo command with retries Closes #16 --- README.md | 30 +++- SKILL.md | 41 +++-- cli.js | 142 +++++++++++++++- core/send-utils.js | 322 ++++++++++++++++++++++++++++++++++++ docs/cli.md | 9 +- telegram-client.js | 123 ++++++++++---- tests/send-messages.test.js | 101 ++++++++++- tests/send-utils.test.js | 126 ++++++++++++++ 8 files changed, 830 insertions(+), 64 deletions(-) create mode 100644 core/send-utils.js create mode 100644 tests/send-utils.test.js diff --git a/README.md b/README.md index 36da280..32b2c40 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,33 @@ tgcli send text --to @username --message "Hello world" --parse-mode none tgcli send text --to @username --message "Hello world" ``` +### send photo + +Send a local PNG/JPG as a Telegram photo preview with optional retries for transient transport failures. + +| Flag | Description | +|-|-| +| `--to` | Recipient: `@username`, phone number, or chat ID | +| `--photo` | Local PNG/JPG path | +| `--caption` | Optional caption | +| `--parse-mode` | `markdown`, `html`, or `none` for caption text | +| `--reply-to` | Message ID to reply to | +| `--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..9d66efe 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,32 @@ async function refreshDialogsWithRetry(messageSyncService, options = {}) { } } +function normalizeSendCommandError(error, { method, retries, attempt = 1 } = {}) { + if (error instanceof SendCommandError) { + return error; + } + return new SendCommandError(classifySendError(error, { method, retries, attempt })); +} + +function logSendRetry(details, globalFlags) { + if (globalFlags.json) { + 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 formatErrorMessage(error) { + if (error instanceof Error && error.message) { + return error.message; + } +} + function readVersion() { try { const pkgPath = new URL('./package.json', import.meta.url); @@ -2851,6 +2913,84 @@ 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(() => 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 { result, attempts } = await executeSendWithRetries( + () => telegramClient.sendPhotoMessage(options.to, options.photo, sendOptions), + { + method, + retries, + retryBackoff, + timeoutMs, + sleep: (ms) => delay(ms), + onRetry: (details) => logSendRetry(details, globalFlags), + }, + ); + + if (globalFlags.json) { + writeJson( + buildSendSuccessPayload({ + method, + chatId: options.to, + messageId: result.messageId, + media: result.media ?? { type: 'photo' }, + attempts, + }), + ); + } else { + console.log(`Photo sent (${result.messageId}).`); + } + } finally { + await messageSyncService.shutdown(); + await telegramClient.destroy(); + release(); + } + }, timeoutMs); + } catch (error) { + throw normalizeSendCommandError(error, { method, retries }); + } +} + async function runSendFile(globalFlags, options = {}) { const timeoutMs = globalFlags.timeoutMs; return runWithTimeout(async () => { diff --git a/core/send-utils.js b/core/send-utils.js new file mode 100644 index 0000000..0f999e7 --- /dev/null +++ b/core/send-utils.js @@ -0,0 +1,322 @@ +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 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 (looksLikeTelegramError(message, code, error)) { + return { + type: 'telegram', + method, + message, + code, + attempt, + retries, + retryable: false, + }; + } + + return { + type: 'network', + 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); + }); +} + +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; + + let attempt = 0; + while (attempt <= retries) { + attempt += 1; + 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') { + options.onRetry(details); + } + + const retryDelayMs = getRetryDelayMs(backoff, attempt); + if (deadlineAt !== null) { + const remainingMs = deadlineAt - now(); + if (remainingMs <= 0) { + throw new SendCommandError(createTimeoutDetails({ method, attempt, retries })); + } + if (retryDelayMs > 0) { + await sleep(Math.min(retryDelayMs, remainingMs)); + } + } else if (retryDelayMs > 0) { + await sleep(retryDelayMs); + } + } + } + + throw new SendCommandError(createTimeoutDetails({ method, attempt, retries })); +} + +export function buildSendSuccessPayload({ method, chatId, messageId, media, attempts }) { + 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; + } + } + + return payload; +} + +export function buildSendErrorPayload(details = {}) { + const payload = { + ok: false, + error: { + type: details.type ?? 'network', + 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 ?? 'network'}]: ${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..62d4054 100644 --- a/telegram-client.js +++ b/telegram-client.js @@ -165,6 +165,62 @@ 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 buildSendParams(options = {}) { + const params = {}; + const replyTo = resolveReplyTo(options); + 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; + if (options.captionAbove) params.invertMedia = 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 coerceApiId(value) { if (typeof value === 'number') { return value; @@ -941,35 +997,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 = buildSendParams(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 +1018,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 +1026,34 @@ 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, buildSendParams(options)); + return buildSendMessageResult(sent, { method: 'sendDocument', defaultMediaType: 'document' }); + } + + async sendPhotoMessage(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 = {}; + if (caption) { + mediaOptions.caption = applyParseMode(caption, parseMode); + } + if (options.spoiler) { + mediaOptions.spoiler = true; + } + + const media = InputMedia.photo(uploadPath, mediaOptions); + const peerRef = normalizeChannelId(channelId); + const sent = await this.client.sendMedia(peerRef, media, buildSendParams(options)); + return buildSendMessageResult(sent, { method: 'sendPhoto', defaultMediaType: 'photo' }); } async downloadMessageMedia(channelId, messageId, options = {}) { diff --git a/tests/send-messages.test.js b/tests/send-messages.test.js index ec6b644..a7eb62b 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', () => ({ @@ -32,9 +33,9 @@ function createMockClient() { 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 }; } @@ -305,6 +306,102 @@ 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.sendMedia).toHaveBeenCalledWith('@chat', expect.anything(), undefined); + }); + + 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 sendMedia', 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.sendMedia).toHaveBeenCalledWith('@chat', expect.anything(), { + replyTo: 77, + silent: true, + noforwards: true, + scheduleDate, + invertMedia: true, + }); + }); + + 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.sendMedia.mockResolvedValueOnce({ + id: 303, + media: { type: 'photo', fileId: 'photo-file-id' }, + }); + + const result = await tc.sendPhotoMessage('@chat', png.filePath, {}); + expect(result).toMatchObject({ + messageId: 303, + method: 'sendPhoto', + media: { + type: 'photo', + fileId: 'photo-file-id', + }, + }); + }); +}); + 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..ec8ff5c --- /dev/null +++ b/tests/send-utils.test.js @@ -0,0 +1,126 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + buildSendErrorPayload, + buildSendSuccessPayload, + executeSendWithRetries, + 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); + }); +}); + +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('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, + }, + }); + }); +}); From 2855301fd8db9594054ba0a1973fa3d561d11811 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Thu, 12 Mar 2026 17:43:10 +0300 Subject: [PATCH 02/20] fix: make send photo retries idempotent Closes #16 --- cli.js | 3 +- telegram-client.js | 134 +++++++++++++++++++++++++++++++++--- tests/send-messages.test.js | 75 +++++++++++++++----- 3 files changed, 186 insertions(+), 26 deletions(-) diff --git a/cli.js b/cli.js index 9d66efe..f4abd0e 100755 --- a/cli.js +++ b/cli.js @@ -2955,8 +2955,9 @@ async function runSendPhoto(globalFlags, options = {}) { spoiler: options.spoiler || false, scheduleDate, }; + const prepared = await telegramClient.preparePhotoMessage(options.to, options.photo, sendOptions); const { result, attempts } = await executeSendWithRetries( - () => telegramClient.sendPhotoMessage(options.to, options.photo, sendOptions), + () => telegramClient.sendPreparedPhotoMessage(prepared), { method, retries, diff --git a/telegram-client.js b/telegram-client.js index 62d4054..ea498e4 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'; @@ -175,16 +176,27 @@ function resolveReplyTo(options = {}) { return undefined; } -function buildSendParams(options = {}) { +function buildTextSendParams(options = {}) { const params = {}; const replyTo = resolveReplyTo(options); if (replyTo) params.replyTo = replyTo; - if (options.noPreview) params.noWebpage = true; + if (options.noPreview) params.disableWebPreview = true; if (options.silent) params.silent = true; - if (options.noForwards || options.noforwards) params.noforwards = true; + if (options.noForwards || options.noforwards) params.forbidForwards = true; const scheduleDate = resolveScheduleDate(options); if (scheduleDate) params.scheduleDate = scheduleDate; - if (options.captionAbove) params.invertMedia = true; + 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; } @@ -221,6 +233,68 @@ function buildSendMessageResult(sent, { method, defaultMediaType } = {}) { 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; @@ -997,7 +1071,7 @@ class TelegramClient { } const parseMode = normalizeParseMode(options.parseMode); const inputText = applyParseMode(messageText, parseMode); - const params = buildSendParams(options); + const params = buildTextSendParams(options); const peerRef = normalizeChannelId(channelId); const sent = await this.client.sendText(peerRef, inputText, params); return { messageId: sent.id }; @@ -1026,11 +1100,11 @@ 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, buildSendParams(options)); + const sent = await this.client.sendMedia(peerRef, media, buildMediaSendParams(options)); return buildSendMessageResult(sent, { method: 'sendDocument', defaultMediaType: 'document' }); } - async sendPhotoMessage(channelId, filePath, options = {}) { + async preparePhotoMessage(channelId, filePath, options = {}) { await this.ensureLogin(); const uploadPath = resolveUploadPath(filePath); const caption = resolveOptionalCaption(options.caption); @@ -1043,8 +1117,10 @@ class TelegramClient { } const mediaOptions = {}; + let parsedCaption; if (caption) { - mediaOptions.caption = applyParseMode(caption, parseMode); + parsedCaption = applyParseMode(caption, parseMode); + mediaOptions.caption = parsedCaption; } if (options.spoiler) { mediaOptions.spoiler = true; @@ -1052,10 +1128,50 @@ class TelegramClient { const media = InputMedia.photo(uploadPath, mediaOptions); const peerRef = normalizeChannelId(channelId); - const sent = await this.client.sendMedia(peerRef, media, buildSendParams(options)); + const peer = await this.client.resolvePeer(peerRef); + const normalizedMedia = await this.client._normalizeInputMedia(media, { uploadPeer: peer }); + const { message, entities } = splitInputText(parsedCaption); + return { + method: 'sendPhoto', + peerRef, + request: { + _: 'messages.sendMedia', + peer, + media: normalizedMedia, + silent: options.silent ? true : undefined, + replyTo: buildLowLevelReplyTo(options), + 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 result = await this.client.call(prepared.request); + const messageId = extractMessageIdFromSendUpdates(result, prepared.request.randomId); + if (!messageId) { + throw new Error('Failed to resolve sent photo message id from Telegram updates.'); + } + const [sent] = await this.client.getMessages(prepared.peerRef, Number(messageId)); + if (!sent) { + return { + messageId: Number(messageId), + method: 'sendPhoto', + media: { type: 'photo' }, + }; + } return buildSendMessageResult(sent, { method: 'sendPhoto', defaultMediaType: 'photo' }); } + async sendPhotoMessage(channelId, filePath, options = {}) { + const prepared = await this.preparePhotoMessage(channelId, filePath, options); + return this.sendPreparedPhotoMessage(prepared); + } + async downloadMessageMedia(channelId, messageId, options = {}) { await this.ensureLogin(); const peerRef = normalizeChannelId(channelId); diff --git a/tests/send-messages.test.js b/tests/send-messages.test.js index a7eb62b..18c1ac3 100644 --- a/tests/send-messages.test.js +++ b/tests/send-messages.test.js @@ -29,6 +29,15 @@ 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 } }, + ], + }), + getMessages: vi.fn().mockResolvedValue([{ id: 202, media: { type: 'photo', fileId: 'photo-file-id' } }]), }; return tc; } @@ -162,7 +171,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 () => { @@ -191,14 +200,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 () => { @@ -234,7 +243,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 () => { @@ -248,7 +257,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 () => { @@ -277,7 +286,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 () => { @@ -295,7 +304,7 @@ describe('sendFileMessage new send parameters', () => { }); expect(tc.client.sendMedia).toHaveBeenCalledWith('@chat', expect.anything(), { silent: true, - noforwards: true, + forbidForwards: true, replyTo: 99, }); }); @@ -327,7 +336,9 @@ describe('sendPhotoMessage', () => { await tc.sendPhotoMessage('@chat', png.filePath, {}); const { InputMedia } = await import('@mtcute/core'); expect(InputMedia.photo).toHaveBeenCalledWith(expect.stringContaining('file:'), {}); - expect(tc.client.sendMedia).toHaveBeenCalledWith('@chat', expect.anything(), undefined); + 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 () => { @@ -351,7 +362,7 @@ describe('sendPhotoMessage', () => { ); }); - it('passes reply-to, topic fallback, silent, no-forwards, caption-above, spoiler, and schedule to sendMedia', async () => { + 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', @@ -369,13 +380,17 @@ describe('sendPhotoMessage', () => { expect.stringContaining('file:'), expect.objectContaining({ spoiler: true }), ); - expect(tc.client.sendMedia).toHaveBeenCalledWith('@chat', expect.anything(), { - replyTo: 77, + expect(tc.client.call).toHaveBeenCalledWith(expect.objectContaining({ + _: 'messages.sendMedia', silent: true, - noforwards: true, scheduleDate, + noforwards: true, invertMedia: true, - }); + replyTo: { + _: 'inputReplyToMessage', + replyToMsgId: 77, + }, + })); }); it('rejects caption-above without caption for send photo', async () => { @@ -385,10 +400,13 @@ describe('sendPhotoMessage', () => { }); it('returns best-effort media metadata for successful photo sends', async () => { - tc.client.sendMedia.mockResolvedValueOnce({ - id: 303, - media: { type: 'photo', fileId: 'photo-file-id' }, + 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({ @@ -400,6 +418,31 @@ describe('sendPhotoMessage', () => { }, }); }); + + 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(secondRequest).toBe(firstRequest); + expect(String(secondRequest.randomId)).toBe(String(prepared.request.randomId)); + }); }); describe('resolveScheduleDate error handling', () => { From 9e9980b2ffe1320e85645b3dfb1906fc71c44545 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Thu, 12 Mar 2026 18:01:58 +0300 Subject: [PATCH 03/20] fix: retry photo uploads during normalization Closes #16 --- telegram-client.js | 17 ++++++++++------- tests/send-messages.test.js | 10 +++++++++- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/telegram-client.js b/telegram-client.js index ea498e4..1819e92 100644 --- a/telegram-client.js +++ b/telegram-client.js @@ -1127,17 +1127,13 @@ class TelegramClient { } const media = InputMedia.photo(uploadPath, mediaOptions); - const peerRef = normalizeChannelId(channelId); - const peer = await this.client.resolvePeer(peerRef); - const normalizedMedia = await this.client._normalizeInputMedia(media, { uploadPeer: peer }); const { message, entities } = splitInputText(parsedCaption); return { method: 'sendPhoto', - peerRef, + peerRef: normalizeChannelId(channelId), + media, request: { _: 'messages.sendMedia', - peer, - media: normalizedMedia, silent: options.silent ? true : undefined, replyTo: buildLowLevelReplyTo(options), randomId: options.randomId ?? randomLong(), @@ -1151,7 +1147,14 @@ class TelegramClient { } async sendPreparedPhotoMessage(prepared) { - const result = await this.client.call(prepared.request); + const peer = await this.client.resolvePeer(prepared.peerRef); + const normalizedMedia = await this.client._normalizeInputMedia(prepared.media, { uploadPeer: peer }); + const request = { + ...prepared.request, + peer, + media: normalizedMedia, + }; + const result = await this.client.call(request); const messageId = extractMessageIdFromSendUpdates(result, prepared.request.randomId); if (!messageId) { throw new Error('Failed to resolve sent photo message id from Telegram updates.'); diff --git a/tests/send-messages.test.js b/tests/send-messages.test.js index 18c1ac3..5a350ff 100644 --- a/tests/send-messages.test.js +++ b/tests/send-messages.test.js @@ -440,8 +440,16 @@ describe('sendPhotoMessage', () => { const [firstRequest] = tc.client.call.mock.calls[0]; const [secondRequest] = tc.client.call.mock.calls[1]; - expect(secondRequest).toBe(firstRequest); + 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('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(); }); }); From 273c63302fc8b7c6843b93cd22bd707326e62018 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Thu, 12 Mar 2026 18:18:23 +0300 Subject: [PATCH 04/20] fix: retry transport errors for send photo Closes #16 --- core/send-utils.js | 19 +++++++++++++++++++ telegram-client.js | 20 ++++++++++++-------- tests/send-messages.test.js | 17 +++++++++++++++++ tests/send-utils.test.js | 21 +++++++++++++++++++++ 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/core/send-utils.js b/core/send-utils.js index 0f999e7..baa666d 100644 --- a/core/send-utils.js +++ b/core/send-utils.js @@ -84,6 +84,13 @@ function looksLikeRetryableNetworkError(message, code) { || 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; @@ -185,6 +192,18 @@ export function classifySendError(error, { method, attempt = 1, retries = 0 } = }; } + if (looksLikeTransportError(message, error)) { + return { + type: 'network', + method, + message, + code, + attempt, + retries, + retryable: true, + }; + } + if (looksLikeTelegramError(message, code, error)) { return { type: 'telegram', diff --git a/telegram-client.js b/telegram-client.js index 1819e92..7f7c0ed 100644 --- a/telegram-client.js +++ b/telegram-client.js @@ -1159,15 +1159,19 @@ class TelegramClient { if (!messageId) { throw new Error('Failed to resolve sent photo message id from Telegram updates.'); } - const [sent] = await this.client.getMessages(prepared.peerRef, Number(messageId)); - if (!sent) { - return { - messageId: Number(messageId), - method: 'sendPhoto', - media: { type: 'photo' }, - }; + try { + const [sent] = await this.client.getMessages(prepared.peerRef, Number(messageId)); + if (sent) { + return buildSendMessageResult(sent, { method: 'sendPhoto', defaultMediaType: 'photo' }); + } + } catch (error) { + // file_id enrichment is best-effort and must not flip a successful send into failure } - return buildSendMessageResult(sent, { method: 'sendPhoto', defaultMediaType: 'photo' }); + return { + messageId: Number(messageId), + method: 'sendPhoto', + media: { type: 'photo' }, + }; } async sendPhotoMessage(channelId, filePath, options = {}) { diff --git a/tests/send-messages.test.js b/tests/send-messages.test.js index 5a350ff..e55746d 100644 --- a/tests/send-messages.test.js +++ b/tests/send-messages.test.js @@ -419,6 +419,23 @@ describe('sendPhotoMessage', () => { }); }); + 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({ + messageId: 505, + method: 'sendPhoto', + media: { type: 'photo' }, + }); + }); + 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 diff --git a/tests/send-utils.test.js b/tests/send-utils.test.js index ec8ff5c..c0d80b4 100644 --- a/tests/send-utils.test.js +++ b/tests/send-utils.test.js @@ -76,6 +76,27 @@ describe('executeSendWithRetries', () => { 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, + }); + }); }); describe('send payload builders', () => { From 5e4d07d89e7ff8cd4465a2ed5f859af37c5651d7 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Thu, 12 Mar 2026 18:53:16 +0300 Subject: [PATCH 05/20] fix: tighten send photo retry timeout handling --- cli.js | 6 ++++-- core/send-utils.js | 27 ++++++++++++++++++++++----- tests/cli-send-photo.test.js | 11 +++++++++++ tests/send-utils.test.js | 31 +++++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 7 deletions(-) create mode 100644 tests/cli-send-photo.test.js diff --git a/cli.js b/cli.js index f4abd0e..8d3f322 100755 --- a/cli.js +++ b/cli.js @@ -1105,8 +1105,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; } @@ -4157,6 +4157,8 @@ function isCliEntrypoint(argvPath = process.argv[1]) { export { buildProgram, isCliEntrypoint, + main, + parseNonNegativeInt, runAuthLogin, }; diff --git a/core/send-utils.js b/core/send-utils.js index baa666d..dcb23e7 100644 --- a/core/send-utils.js +++ b/core/send-utils.js @@ -245,6 +245,13 @@ function delay(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'; @@ -255,9 +262,16 @@ export async function executeSendWithRetries(sendFn, options = {}) { ? now() + options.timeoutMs : null; - let attempt = 0; - while (attempt <= retries) { - attempt += 1; + 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 }; @@ -274,12 +288,15 @@ export async function executeSendWithRetries(sendFn, options = {}) { const retryDelayMs = getRetryDelayMs(backoff, attempt); if (deadlineAt !== null) { - const remainingMs = deadlineAt - now(); + 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); @@ -287,7 +304,7 @@ export async function executeSendWithRetries(sendFn, options = {}) { } } - throw new SendCommandError(createTimeoutDetails({ method, attempt, retries })); + throw new SendCommandError(createTimeoutDetails({ method, attempt: retries + 1, retries })); } export function buildSendSuccessPayload({ method, chatId, messageId, media, attempts }) { diff --git a/tests/cli-send-photo.test.js b/tests/cli-send-photo.test.js new file mode 100644 index 0000000..f634117 --- /dev/null +++ b/tests/cli-send-photo.test.js @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; + +import { parseNonNegativeInt } from '../cli.js'; + +describe('tgcli send photo CLI validation', () => { + it('rejects fractional --retries values', () => { + expect(() => parseNonNegativeInt('1.5', '--retries')).toThrow( + '--retries must be a non-negative integer', + ); + }); +}); diff --git a/tests/send-utils.test.js b/tests/send-utils.test.js index c0d80b4..d1d48da 100644 --- a/tests/send-utils.test.js +++ b/tests/send-utils.test.js @@ -97,6 +97,37 @@ describe('executeSendWithRetries', () => { attempts: 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); + }); }); describe('send payload builders', () => { From 0ee14a98f13d1e9fae39650c9b0cee8ec034d45c Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Thu, 12 Mar 2026 19:05:56 +0300 Subject: [PATCH 06/20] fix: support symlinked tgcli entrypoint --- cli.js | 17 +++++++++++++++++ tests/cli-send-photo.test.js | 27 +++++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/cli.js b/cli.js index 8d3f322..fd6ff54 100755 --- a/cli.js +++ b/cli.js @@ -4154,12 +4154,29 @@ function isCliEntrypoint(argvPath = process.argv[1]) { } } +function resolveEntrypointPath(filePath) { + if (!filePath) { + return null; + } + try { + return fs.realpathSync(filePath); + } catch { + return path.resolve(filePath); + } +} + +function shouldRunMain(entryPath = process.argv[1]) { + const resolvedEntryPath = resolveEntrypointPath(entryPath); + return resolvedEntryPath !== null && resolvedEntryPath === CLI_PATH; +} + export { buildProgram, isCliEntrypoint, main, parseNonNegativeInt, runAuthLogin, + shouldRunMain, }; if (isCliEntrypoint()) { diff --git a/tests/cli-send-photo.test.js b/tests/cli-send-photo.test.js index f634117..a4856bc 100644 --- a/tests/cli-send-photo.test.js +++ b/tests/cli-send-photo.test.js @@ -1,11 +1,34 @@ -import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; -import { parseNonNegativeInt } from '../cli.js'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { parseNonNegativeInt, shouldRunMain } from '../cli.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); + }); }); From 326ef950560bc553a0ecf7a03aa40a70ad2d7b3e Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Thu, 12 Mar 2026 20:04:39 +0300 Subject: [PATCH 07/20] fix: return resolved chat id for send photo --- cli.js | 21 ++++++++++++--------- telegram-client.js | 10 +++++++++- tests/cli-send-photo.test.js | 25 ++++++++++++++++++++++++- tests/send-messages.test.js | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 11 deletions(-) diff --git a/cli.js b/cli.js index fd6ff54..18030f5 100755 --- a/cli.js +++ b/cli.js @@ -2969,15 +2969,7 @@ async function runSendPhoto(globalFlags, options = {}) { ); if (globalFlags.json) { - writeJson( - buildSendSuccessPayload({ - method, - chatId: options.to, - messageId: result.messageId, - media: result.media ?? { type: 'photo' }, - attempts, - }), - ); + writeJson(buildSendPhotoSuccessPayload({ method, inputChatId: options.to, result, attempts })); } else { console.log(`Photo sent (${result.messageId}).`); } @@ -2992,6 +2984,16 @@ async function runSendPhoto(globalFlags, options = {}) { } } +function buildSendPhotoSuccessPayload({ method, inputChatId, result, attempts }) { + return buildSendSuccessPayload({ + method, + chatId: result?.chatId ?? inputChatId, + messageId: result?.messageId, + media: result?.media ?? { type: 'photo' }, + attempts, + }); +} + async function runSendFile(globalFlags, options = {}) { const timeoutMs = globalFlags.timeoutMs; return runWithTimeout(async () => { @@ -4172,6 +4174,7 @@ function shouldRunMain(entryPath = process.argv[1]) { export { buildProgram, + buildSendPhotoSuccessPayload, isCliEntrypoint, main, parseNonNegativeInt, diff --git a/telegram-client.js b/telegram-client.js index 7f7c0ed..3f65c17 100644 --- a/telegram-client.js +++ b/telegram-client.js @@ -1148,6 +1148,9 @@ class TelegramClient { 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, @@ -1155,6 +1158,7 @@ class TelegramClient { 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.'); @@ -1162,12 +1166,16 @@ class TelegramClient { try { const [sent] = await this.client.getMessages(prepared.peerRef, Number(messageId)); if (sent) { - return buildSendMessageResult(sent, { method: 'sendPhoto', defaultMediaType: 'photo' }); + return { + chatId, + ...buildSendMessageResult(sent, { method: 'sendPhoto', defaultMediaType: 'photo' }), + }; } } catch (error) { // file_id enrichment is best-effort and must not flip a successful send into failure } return { + chatId, messageId: Number(messageId), method: 'sendPhoto', media: { type: 'photo' }, diff --git a/tests/cli-send-photo.test.js b/tests/cli-send-photo.test.js index a4856bc..19f68cb 100644 --- a/tests/cli-send-photo.test.js +++ b/tests/cli-send-photo.test.js @@ -4,7 +4,7 @@ import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { parseNonNegativeInt, shouldRunMain } from '../cli.js'; +import { buildSendPhotoSuccessPayload, parseNonNegativeInt, shouldRunMain } from '../cli.js'; describe('tgcli send photo CLI validation', () => { const tempDirs = []; @@ -31,4 +31,27 @@ describe('tgcli send photo CLI validation', () => { 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, + }); + }); }); diff --git a/tests/send-messages.test.js b/tests/send-messages.test.js index e55746d..72ab322 100644 --- a/tests/send-messages.test.js +++ b/tests/send-messages.test.js @@ -37,6 +37,7 @@ function createMockClient() { { _: 'updateNewChannelMessage', message: { id: 202 } }, ], }), + handleClientUpdate: vi.fn(), getMessages: vi.fn().mockResolvedValue([{ id: 202, media: { type: 'photo', fileId: 'photo-file-id' } }]), }; return tc; @@ -410,6 +411,7 @@ describe('sendPhotoMessage', () => { const result = await tc.sendPhotoMessage('@chat', png.filePath, {}); expect(result).toMatchObject({ + chatId: '999', messageId: 303, method: 'sendPhoto', media: { @@ -417,6 +419,38 @@ describe('sendPhotoMessage', () => { 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 () => { @@ -430,6 +464,7 @@ describe('sendPhotoMessage', () => { const result = await tc.sendPhotoMessage('@chat', png.filePath, {}); expect(result).toEqual({ + chatId: '999', messageId: 505, method: 'sendPhoto', media: { type: 'photo' }, From b60e4729fc147ba56cc6976fdff90c97760f8c62 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Fri, 13 Mar 2026 11:02:06 +0300 Subject: [PATCH 08/20] fix: address PR review findings for send photo - Log getMessages enrichment errors instead of silently swallowing them - Log auth check failures to stderr before returning false - Wrap onRetry callback in try-catch to protect retry loop - Complete README send photo flags table (topic, silent, no-forwards, etc.) - Add unit tests for classifySendError, getRetryDelayMs, formatSendErrorMessage - Add test for onRetry callback crash resilience Co-Authored-By: Claude Opus 4.6 --- README.md | 10 ++- cli.js | 4 +- core/send-utils.js | 6 +- telegram-client.js | 1 + tests/send-utils.test.js | 149 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 165 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 32b2c40..d4799a7 100644 --- a/README.md +++ b/README.md @@ -147,15 +147,21 @@ tgcli send text --to @username --message "Hello world" ### send photo -Send a local PNG/JPG as a Telegram photo preview with optional retries for transient transport failures. +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 PNG/JPG path | +| `--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` | diff --git a/cli.js b/cli.js index 18030f5..28fb64d 100755 --- a/cli.js +++ b/cli.js @@ -1587,7 +1587,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.'); } @@ -2938,7 +2938,7 @@ async function runSendPhoto(globalFlags, options = {}) { const release = acquireStoreLock(storeDir); const { telegramClient, messageSyncService } = createServices({ storeDir }); 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.'); } const topicId = parsePositiveInt(options.topic, '--topic'); diff --git a/core/send-utils.js b/core/send-utils.js index dcb23e7..481c70d 100644 --- a/core/send-utils.js +++ b/core/send-utils.js @@ -283,7 +283,11 @@ export async function executeSendWithRetries(sendFn, options = {}) { } if (typeof options.onRetry === 'function') { - options.onRetry(details); + try { + options.onRetry(details); + } catch { + // onRetry callback failure must not break the retry loop + } } const retryDelayMs = getRetryDelayMs(backoff, attempt); diff --git a/telegram-client.js b/telegram-client.js index 3f65c17..8101047 100644 --- a/telegram-client.js +++ b/telegram-client.js @@ -1173,6 +1173,7 @@ class TelegramClient { } } 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}`); } return { chatId, diff --git a/tests/send-utils.test.js b/tests/send-utils.test.js index d1d48da..9b4aa64 100644 --- a/tests/send-utils.test.js +++ b/tests/send-utils.test.js @@ -3,7 +3,10 @@ import { describe, expect, it, vi } from 'vitest'; import { buildSendErrorPayload, buildSendSuccessPayload, + classifySendError, executeSendWithRetries, + formatSendErrorMessage, + getRetryDelayMs, parseRetryBackoff, SendCommandError, } from '../core/send-utils.js'; @@ -98,6 +101,26 @@ describe('executeSendWithRetries', () => { }); }); + 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('stops retrying when the timeout budget is exhausted during backoff', async () => { let currentTime = 0; const now = vi.fn(() => currentTime); @@ -176,3 +199,129 @@ describe('send payload builders', () => { }); }); }); + +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 bare "timeout" message as non-retryable timeout', () => { + const result = classifySendError(new Error('Timeout'), { method: 'sendPhoto' }); + expect(result).toMatchObject({ type: 'timeout', 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 message as non-retryable telegram error', () => { + 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 network fallback', () => { + const result = classifySendError(new Error('something weird'), { method: 'sendPhoto' }); + expect(result).toMatchObject({ type: 'network', retryable: false }); + }); + + 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); + }); +}); + +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 [network]: Unknown error (attempt 1/1)'); + }); +}); From 296668d14c47a83c55e21bcd399fc55df235dfba Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Fri, 13 Mar 2026 11:12:21 +0300 Subject: [PATCH 09/20] fix: address PR review issues (iteration 1) - Log onRetry callback errors instead of silently swallowing them - Log non-ENOENT errors in resolveEntrypointPath - Add tests for: retry exhaustion, extractMessageIdFromSendUpdates null, preparePhotoMessage parse-mode without caption, retries:0 edge case, parseRetryBackoff invalid input, buildSendSuccessPayload without media, buildSendPhotoSuccessPayload inputChatId fallback Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 + cli.js | 5 +- core/send-utils.js | 4 +- tests/cli-send-photo.test.js | 22 +++++++ tests/send-messages.test.js | 16 ++++++ tests/send-utils.test.js | 107 +++++++++++++++++++++++++++++++++++ 6 files changed, 154 insertions(+), 3 deletions(-) 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/cli.js b/cli.js index 28fb64d..7a6da37 100755 --- a/cli.js +++ b/cli.js @@ -4162,7 +4162,10 @@ function resolveEntrypointPath(filePath) { } try { return fs.realpathSync(filePath); - } catch { + } catch (error) { + if (error?.code !== 'ENOENT') { + console.error(`[resolveEntrypointPath] realpathSync failed for ${filePath}: ${error?.message}`); + } return path.resolve(filePath); } } diff --git a/core/send-utils.js b/core/send-utils.js index 481c70d..2cc27b8 100644 --- a/core/send-utils.js +++ b/core/send-utils.js @@ -285,8 +285,8 @@ export async function executeSendWithRetries(sendFn, options = {}) { if (typeof options.onRetry === 'function') { try { options.onRetry(details); - } catch { - // onRetry callback failure must not break the retry loop + } catch (callbackError) { + console.error('[executeSendWithRetries] onRetry callback error:', callbackError); } } diff --git a/tests/cli-send-photo.test.js b/tests/cli-send-photo.test.js index 19f68cb..8846661 100644 --- a/tests/cli-send-photo.test.js +++ b/tests/cli-send-photo.test.js @@ -54,4 +54,26 @@ describe('tgcli send photo CLI validation', () => { 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, + }); + }); }); diff --git a/tests/send-messages.test.js b/tests/send-messages.test.js index 72ab322..ed3a9f2 100644 --- a/tests/send-messages.test.js +++ b/tests/send-messages.test.js @@ -498,6 +498,22 @@ describe('sendPhotoMessage', () => { expect(tc.client._normalizeInputMedia).toHaveBeenCalledTimes(2); }); + 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('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(); diff --git a/tests/send-utils.test.js b/tests/send-utils.test.js index 9b4aa64..e26f1f9 100644 --- a/tests/send-utils.test.js +++ b/tests/send-utils.test.js @@ -121,6 +121,80 @@ describe('executeSendWithRetries', () => { 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 TypeError('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'), + expect.any(TypeError), + ); + errorSpy.mockRestore(); + }); + + 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('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); @@ -176,6 +250,25 @@ describe('send payload builders', () => { }); }); + 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('creates structured JSON error payload', () => { const error = new SendCommandError({ type: 'network', @@ -296,6 +389,20 @@ describe('getRetryDelayMs', () => { }); }); +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({ From fca9d218e646ffd87fdb1fa327eb73c7022d4212 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Fri, 13 Mar 2026 11:16:56 +0300 Subject: [PATCH 10/20] fix: address PR review issues (iteration 2) - Narrow catch scope in sendPreparedPhotoMessage to only getMessages - Emit structured JSON retry events to stderr in JSON mode - Add tests for: timeout before second attempt, retryable "timed out" message, inputPeerUser peer extraction, invalid photo filePath Co-Authored-By: Claude Opus 4.6 --- cli.js | 1 + telegram-client.js | 15 ++++++++------- tests/send-messages.test.js | 29 +++++++++++++++++++++++++++++ tests/send-utils.test.js | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 7 deletions(-) diff --git a/cli.js b/cli.js index 7a6da37..337b386 100755 --- a/cli.js +++ b/cli.js @@ -997,6 +997,7 @@ function normalizeSendCommandError(error, { method, retries, attempt = 1 } = {}) 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; diff --git a/telegram-client.js b/telegram-client.js index 8101047..0fcb0da 100644 --- a/telegram-client.js +++ b/telegram-client.js @@ -1163,18 +1163,19 @@ class TelegramClient { if (!messageId) { throw new Error('Failed to resolve sent photo message id from Telegram updates.'); } + let sent; try { - const [sent] = await this.client.getMessages(prepared.peerRef, Number(messageId)); - if (sent) { - return { - chatId, - ...buildSendMessageResult(sent, { method: 'sendPhoto', defaultMediaType: 'photo' }), - }; - } + [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), diff --git a/tests/send-messages.test.js b/tests/send-messages.test.js index ed3a9f2..e484bf1 100644 --- a/tests/send-messages.test.js +++ b/tests/send-messages.test.js @@ -498,6 +498,35 @@ describe('sendPhotoMessage', () => { 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' }), diff --git a/tests/send-utils.test.js b/tests/send-utils.test.js index e26f1f9..861ec4c 100644 --- a/tests/send-utils.test.js +++ b/tests/send-utils.test.js @@ -145,6 +145,36 @@ describe('executeSendWithRetries', () => { errorSpy.mockRestore(); }); + 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); @@ -322,6 +352,11 @@ describe('classifySendError', () => { 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 ECONNRESET as retryable network error', () => { const error = Object.assign(new Error('ECONNRESET'), { code: 'ECONNRESET' }); const result = classifySendError(error, { method: 'sendPhoto' }); From 8b31364574a2d4f7bf2e30940cfa92a55ed9c531 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Fri, 13 Mar 2026 11:22:37 +0300 Subject: [PATCH 11/20] fix: address PR review issues (iteration 3) - Change fallback error type from 'network' to 'unknown' for unrecognized errors - Skip wrapping TypeError/ReferenceError/SyntaxError/RangeError in normalizeSendCommandError - Add tests for: timeout before first attempt, media without fileId, normalizeSendCommandError behavior, FLOOD_WAIT design decision Co-Authored-By: Claude Opus 4.6 --- cli.js | 4 ++++ core/send-utils.js | 6 ++--- tests/cli-send-photo.test.js | 26 +++++++++++++++++++- tests/send-utils.test.js | 46 ++++++++++++++++++++++++++++++++---- 4 files changed, 74 insertions(+), 8 deletions(-) diff --git a/cli.js b/cli.js index 337b386..694932d 100755 --- a/cli.js +++ b/cli.js @@ -992,6 +992,9 @@ 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 })); } @@ -4181,6 +4184,7 @@ export { buildSendPhotoSuccessPayload, isCliEntrypoint, main, + normalizeSendCommandError, parseNonNegativeInt, runAuthLogin, shouldRunMain, diff --git a/core/send-utils.js b/core/send-utils.js index 2cc27b8..2a25fec 100644 --- a/core/send-utils.js +++ b/core/send-utils.js @@ -217,7 +217,7 @@ export function classifySendError(error, { method, attempt = 1, retries = 0 } = } return { - type: 'network', + type: 'unknown', method, message, code, @@ -336,7 +336,7 @@ export function buildSendErrorPayload(details = {}) { const payload = { ok: false, error: { - type: details.type ?? 'network', + type: details.type ?? 'unknown', method: details.method ?? 'sendMedia', message: details.message ?? 'Unknown error', attempt: details.attempt ?? 1, @@ -358,5 +358,5 @@ export function formatSendErrorMessage(details = {}) { const codeSuffix = details.code !== undefined && details.code !== null && details.code !== '' ? `, code ${details.code}` : ''; - return `${details.method ?? 'sendMedia'} failed [${details.type ?? 'network'}]: ${details.message ?? 'Unknown error'} (attempt ${attempt}/${totalAttempts}${codeSuffix})`; + return `${details.method ?? 'sendMedia'} failed [${details.type ?? 'unknown'}]: ${details.message ?? 'Unknown error'} (attempt ${attempt}/${totalAttempts}${codeSuffix})`; } diff --git a/tests/cli-send-photo.test.js b/tests/cli-send-photo.test.js index 8846661..f32ce0b 100644 --- a/tests/cli-send-photo.test.js +++ b/tests/cli-send-photo.test.js @@ -4,7 +4,8 @@ import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { buildSendPhotoSuccessPayload, parseNonNegativeInt, shouldRunMain } from '../cli.js'; +import { buildSendPhotoSuccessPayload, normalizeSendCommandError, parseNonNegativeInt, shouldRunMain } from '../cli.js'; +import { SendCommandError } from '../core/send-utils.js'; describe('tgcli send photo CLI validation', () => { const tempDirs = []; @@ -77,3 +78,26 @@ describe('tgcli send photo CLI validation', () => { }); }); }); + +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' }); + }); +}); diff --git a/tests/send-utils.test.js b/tests/send-utils.test.js index 861ec4c..3b7eccc 100644 --- a/tests/send-utils.test.js +++ b/tests/send-utils.test.js @@ -145,6 +145,32 @@ describe('executeSendWithRetries', () => { errorSpy.mockRestore(); }); + it('throws timeout before first attempt when budget already expired', async () => { + let currentTime = 100; + const now = vi.fn(() => currentTime); + 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); @@ -280,6 +306,18 @@ describe('send payload builders', () => { }); }); + 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', @@ -375,7 +413,7 @@ describe('classifySendError', () => { expect(result).toMatchObject({ type: 'telegram', retryable: false, code: 403 }); }); - it('classifies FLOOD_WAIT message as non-retryable telegram error', () => { + 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 }); }); @@ -386,9 +424,9 @@ describe('classifySendError', () => { expect(result).toMatchObject({ type: 'telegram', retryable: false }); }); - it('classifies unknown errors as non-retryable network fallback', () => { + it('classifies unknown errors as non-retryable unknown fallback', () => { const result = classifySendError(new Error('something weird'), { method: 'sendPhoto' }); - expect(result).toMatchObject({ type: 'network', retryable: false }); + expect(result).toMatchObject({ type: 'unknown', retryable: false }); }); it('passes method, attempt, and retries through', () => { @@ -464,6 +502,6 @@ describe('formatSendErrorMessage', () => { it('uses defaults for missing fields', () => { const msg = formatSendErrorMessage({}); - expect(msg).toBe('sendMedia failed [network]: Unknown error (attempt 1/1)'); + expect(msg).toBe('sendMedia failed [unknown]: Unknown error (attempt 1/1)'); }); }); From 9153040a1c9ec60cd42cf454bdae791f9e04fd2a Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Fri, 13 Mar 2026 11:26:42 +0300 Subject: [PATCH 12/20] fix: address PR review issues (iteration 4) - Add tests for MtRpcError name variant classification - Add tests for "Request timeout" substring retryable behavior Co-Authored-By: Claude Opus 4.6 --- tests/send-utils.test.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/send-utils.test.js b/tests/send-utils.test.js index 3b7eccc..b726400 100644 --- a/tests/send-utils.test.js +++ b/tests/send-utils.test.js @@ -395,6 +395,17 @@ describe('classifySendError', () => { 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' }); From 01d2151d0abb91dfa8631b2aae8feac6e0de5989 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Fri, 13 Mar 2026 11:30:02 +0300 Subject: [PATCH 13/20] fix: address PR review issues (iteration 5) - Add test for getRetryDelayMs with explicit backoff objects Co-Authored-By: Claude Opus 4.6 --- tests/send-utils.test.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/send-utils.test.js b/tests/send-utils.test.js index b726400..63e1134 100644 --- a/tests/send-utils.test.js +++ b/tests/send-utils.test.js @@ -471,6 +471,12 @@ describe('getRetryDelayMs', () => { 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', () => { From 8a4f2bb355cfc647eeeda1ae1cb78f483b2e3af1 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Fri, 13 Mar 2026 11:34:17 +0300 Subject: [PATCH 14/20] fix: add edge case tests for error classification and payload building Co-Authored-By: Claude Opus 4.6 --- tests/send-utils.test.js | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/send-utils.test.js b/tests/send-utils.test.js index 63e1134..c57aaf8 100644 --- a/tests/send-utils.test.js +++ b/tests/send-utils.test.js @@ -225,6 +225,23 @@ describe('executeSendWithRetries', () => { 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); @@ -337,6 +354,18 @@ describe('send payload builders', () => { 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', @@ -440,6 +469,14 @@ describe('classifySendError', () => { 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'); From 9d1adc855ae20c5ff11dcf40876f2e11e167719e Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Fri, 13 Mar 2026 11:38:00 +0300 Subject: [PATCH 15/20] fix: add clarifying comments for retry idempotency and safety net throw Co-Authored-By: Claude Opus 4.6 --- core/send-utils.js | 2 ++ telegram-client.js | 1 + 2 files changed, 3 insertions(+) diff --git a/core/send-utils.js b/core/send-utils.js index 2a25fec..2b6fb79 100644 --- a/core/send-utils.js +++ b/core/send-utils.js @@ -308,6 +308,8 @@ export async function executeSendWithRetries(sendFn, options = {}) { } } + // 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 })); } diff --git a/telegram-client.js b/telegram-client.js index 0fcb0da..375faa4 100644 --- a/telegram-client.js +++ b/telegram-client.js @@ -1136,6 +1136,7 @@ class TelegramClient { _: '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, From a446bc5faf1b11130fbf9ba94c58f081a282a416 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Fri, 13 Mar 2026 11:52:47 +0300 Subject: [PATCH 16/20] fix: address PR review issues (iteration 6) - Add warning field to degraded photo response when enrichment fails - Re-throw TypeError/ReferenceError from onRetry callback - Add writeError and logSendRetry tests - Export writeError and logSendRetry for testability - Add warning propagation tests for buildSendSuccessPayload Co-Authored-By: Claude Opus 4.6 --- cli.js | 3 ++ core/send-utils.js | 9 ++++- telegram-client.js | 1 + tests/cli-send-photo.test.js | 75 ++++++++++++++++++++++++++++++++++-- tests/send-messages.test.js | 1 + tests/send-utils.test.js | 44 ++++++++++++++++++++- 6 files changed, 127 insertions(+), 6 deletions(-) diff --git a/cli.js b/cli.js index 694932d..6f5f956 100755 --- a/cli.js +++ b/cli.js @@ -2995,6 +2995,7 @@ function buildSendPhotoSuccessPayload({ method, inputChatId, result, attempts }) messageId: result?.messageId, media: result?.media ?? { type: 'photo' }, attempts, + warning: result?.warning, }); } @@ -4183,11 +4184,13 @@ 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 index 2b6fb79..05b5cfb 100644 --- a/core/send-utils.js +++ b/core/send-utils.js @@ -286,6 +286,9 @@ export async function executeSendWithRetries(sendFn, options = {}) { try { options.onRetry(details); } catch (callbackError) { + if (callbackError instanceof TypeError || callbackError instanceof ReferenceError) { + throw callbackError; + } console.error('[executeSendWithRetries] onRetry callback error:', callbackError); } } @@ -313,7 +316,7 @@ export async function executeSendWithRetries(sendFn, options = {}) { throw new SendCommandError(createTimeoutDetails({ method, attempt: retries + 1, retries })); } -export function buildSendSuccessPayload({ method, chatId, messageId, media, attempts }) { +export function buildSendSuccessPayload({ method, chatId, messageId, media, attempts, warning }) { const payload = { ok: true, method, @@ -331,6 +334,10 @@ export function buildSendSuccessPayload({ method, chatId, messageId, media, atte } } + if (warning) { + payload.warning = warning; + } + return payload; } diff --git a/telegram-client.js b/telegram-client.js index 375faa4..b3b6308 100644 --- a/telegram-client.js +++ b/telegram-client.js @@ -1182,6 +1182,7 @@ class TelegramClient { messageId: Number(messageId), method: 'sendPhoto', media: { type: 'photo' }, + warning: 'Media enrichment failed; file_id unavailable', }; } diff --git a/tests/cli-send-photo.test.js b/tests/cli-send-photo.test.js index f32ce0b..0b4ea9f 100644 --- a/tests/cli-send-photo.test.js +++ b/tests/cli-send-photo.test.js @@ -2,10 +2,10 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { buildSendPhotoSuccessPayload, normalizeSendCommandError, parseNonNegativeInt, shouldRunMain } from '../cli.js'; -import { SendCommandError } from '../core/send-utils.js'; +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 = []; @@ -101,3 +101,72 @@ describe('normalizeSendCommandError', () => { 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 e484bf1..42fcecd 100644 --- a/tests/send-messages.test.js +++ b/tests/send-messages.test.js @@ -468,6 +468,7 @@ describe('sendPhotoMessage', () => { messageId: 505, method: 'sendPhoto', media: { type: 'photo' }, + warning: 'Media enrichment failed; file_id unavailable', }); }); diff --git a/tests/send-utils.test.js b/tests/send-utils.test.js index c57aaf8..b781e27 100644 --- a/tests/send-utils.test.js +++ b/tests/send-utils.test.js @@ -126,7 +126,7 @@ describe('executeSendWithRetries', () => { .mockRejectedValueOnce(Object.assign(new Error('ECONNRESET'), { code: 'ECONNRESET' })) .mockResolvedValueOnce({ messageId: 100 }); const sleep = vi.fn().mockResolvedValue(undefined); - const callbackError = new TypeError('bad callback'); + const callbackError = new Error('bad callback'); const onRetry = vi.fn(() => { throw callbackError; }); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -140,11 +140,29 @@ describe('executeSendWithRetries', () => { expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining('onRetry'), - expect.any(TypeError), + 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 now = vi.fn(() => currentTime); @@ -323,6 +341,28 @@ describe('send payload builders', () => { }); }); + 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', From c6fc96ad49dae5e41e04158c89c626fc43d05a6c Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Fri, 13 Mar 2026 11:57:29 +0300 Subject: [PATCH 17/20] fix: address PR review issues (iteration 7) - Add buildSendPhotoSuccessPayload warning propagation tests - Add sleep clamping to remaining timeout budget test Co-Authored-By: Claude Opus 4.6 --- tests/cli-send-photo.test.js | 32 ++++++++++++++++++++++++++++++++ tests/send-utils.test.js | 20 ++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/tests/cli-send-photo.test.js b/tests/cli-send-photo.test.js index 0b4ea9f..b680595 100644 --- a/tests/cli-send-photo.test.js +++ b/tests/cli-send-photo.test.js @@ -79,6 +79,38 @@ describe('tgcli send photo CLI validation', () => { }); }); +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 }; diff --git a/tests/send-utils.test.js b/tests/send-utils.test.js index b781e27..9cf1b63 100644 --- a/tests/send-utils.test.js +++ b/tests/send-utils.test.js @@ -316,6 +316,26 @@ describe('executeSendWithRetries', () => { 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', () => { From af71da74b1dfda5f713c7b9cd5848c98d409580b Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Fri, 13 Mar 2026 12:00:48 +0300 Subject: [PATCH 18/20] fix: address PR review issues (iteration 8) - Add test for extractMessageIdFromSendUpdates fallback path - Add test for ETIMEDOUT code precedence over bare Timeout message Co-Authored-By: Claude Opus 4.6 --- tests/send-messages.test.js | 12 ++++++++++++ tests/send-utils.test.js | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/tests/send-messages.test.js b/tests/send-messages.test.js index 42fcecd..e52ddcb 100644 --- a/tests/send-messages.test.js +++ b/tests/send-messages.test.js @@ -544,6 +544,18 @@ describe('sendPhotoMessage', () => { ).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(); diff --git a/tests/send-utils.test.js b/tests/send-utils.test.js index 9cf1b63..6268e27 100644 --- a/tests/send-utils.test.js +++ b/tests/send-utils.test.js @@ -474,6 +474,12 @@ describe('classifySendError', () => { 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 }); From 472a3b37a29f547d3297078ee134b500dc709708 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Fri, 13 Mar 2026 12:05:15 +0300 Subject: [PATCH 19/20] fix: address PR review issues (final) - Remove unused vi.fn() mock variable in timeout test Co-Authored-By: Claude Opus 4.6 --- tests/send-utils.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/send-utils.test.js b/tests/send-utils.test.js index 6268e27..3e12d54 100644 --- a/tests/send-utils.test.js +++ b/tests/send-utils.test.js @@ -165,7 +165,6 @@ describe('executeSendWithRetries', () => { it('throws timeout before first attempt when budget already expired', async () => { let currentTime = 100; - const now = vi.fn(() => currentTime); const sendFn = vi.fn(); const sleep = vi.fn(); From 8a2060a2e0f2b3eaf45e870d3d16e013808d9942 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Fri, 13 Mar 2026 12:26:56 +0300 Subject: [PATCH 20/20] fix: remove duplicate formatErrorMessage after rebase The function was already imported from core/retry.js; the local duplicate introduced during rebase caused a SyntaxError. Co-Authored-By: Claude Opus 4.6 --- cli.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/cli.js b/cli.js index 6f5f956..66297ff 100755 --- a/cli.js +++ b/cli.js @@ -1012,12 +1012,6 @@ function logSendRetry(details, globalFlags) { ); } -function formatErrorMessage(error) { - if (error instanceof Error && error.message) { - return error.message; - } -} - function readVersion() { try { const pkgPath = new URL('./package.json', import.meta.url);