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