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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ tgcli auth
- `--caption-above` shows caption above media (`send file` only, requires `--caption`)
- `--spoiler` blurs media until tapped (`send file` only)
- `--force-document` sends photo/video as uncompressed document (`send file` only)
- `--retries <n>` retry on failure with exponential backoff (default 0); JSON output includes `retry_log` and `attempts` when retries occurred
- Telegram markdown formatting (when `--parse-mode markdown`):
- Bold: `**text**`
- Italic: `__text__` (double underscores, NOT single `_`)
Expand Down Expand Up @@ -118,6 +119,7 @@ tgcli send text --to <id|@username> --message "https://example.com check this" -
tgcli send text --to <id|@username> --message "Nightly report" --silent --json --timeout 30s
tgcli send text --to <id|@username> --message "Confidential" --no-forwards --json --timeout 30s
tgcli send text --to <id|@username> --message "Good morning!" --schedule "2025-01-15T09:00:00+03:00" --json --timeout 30s
tgcli send text --to <id|@username> --message "Hello" --retries 3 --json --timeout 30s

tgcli send file --to <id|@username> --file /path/to/file --caption "Report" --json --timeout 30s
tgcli send file --to <id|@username> --file /path/to/file --caption "<b>Report</b>" --parse-mode html --json --timeout 30s
Expand Down
96 changes: 49 additions & 47 deletions cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { acquireStoreLock, acquireReadLock, readStoreLock } from './store-lock.j
import { loadConfig, normalizeConfig, saveConfig, validateConfig } from './core/config.js';
import { createMessageSyncService, createServices, createTelegramClient } from './core/services.js';
import { resolveStoreDir } from './core/store.js';
import { formatErrorMessage, parseRequiredWaitSeconds, withSendRetry } from './core/retry.js';

const CLI_PATH = fileURLToPath(import.meta.url);
const SERVICE_STATE_FILE = 'service-state.json';
Expand Down Expand Up @@ -231,6 +232,7 @@ function buildProgram() {
.option('--silent', 'Send without notification sound')
.option('--no-forwards', 'Protect message from forwarding')
.option('--schedule <iso>', 'Schedule message (ISO 8601 datetime)')
.option('--retries <n>', 'Max retries on failure', '0')
.action(withGlobalOptions((globalFlags, options) => runSendText(globalFlags, options)));
send
.command('file')
Expand All @@ -248,6 +250,7 @@ function buildProgram() {
.option('--spoiler', 'Blur media until tapped')
.option('--schedule <iso>', 'Schedule message (ISO 8601 datetime)')
.option('--force-document', 'Send as uncompressed document')
.option('--retries <n>', 'Max retries on failure', '0')
.action(withGlobalOptions((globalFlags, options) => runSendFile(globalFlags, options)));

const media = program.command('media').description('Download media');
Expand Down Expand Up @@ -549,12 +552,18 @@ function writeJson(payload) {
function writeError(error, asJson) {
const message = error?.message ?? String(error);
if (asJson) {
process.stderr.write(`${JSON.stringify({ ok: false, error: message })}\n`);
const payload = { ok: false, error: message };
if (error?.retryLog?.length > 0) {
payload.attempts = error.attempts;
payload.retry_log = error.retryLog;
}
process.stderr.write(`${JSON.stringify(payload)}\n`);
} else {
process.stderr.write(`${message}\n`);
}
}


function collectOption(value, previous) {
return previous.concat([value]);
}
Expand Down Expand Up @@ -927,29 +936,6 @@ function runWithTimeout(task, timeoutMs, onTimeout) {
});
}

function formatErrorMessage(error) {
if (error instanceof Error && error.message) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
return String(error);
}

function parseRequiredWaitSeconds(error) {
const text = formatErrorMessage(error);
const waitMatch = /wait of (\d+) seconds is required/i.exec(text);
if (waitMatch) {
return Number(waitMatch[1]);
}
const floodWaitMatch = /FLOOD_WAIT_(\d+)/i.exec(text);
if (floodWaitMatch) {
return Number(floodWaitMatch[1]);
}
return null;
}

async function refreshDialogsWithRetry(messageSyncService, options = {}) {
const maxWaitSeconds = options.maxWaitSeconds ?? 30;
try {
Expand All @@ -959,7 +945,7 @@ async function refreshDialogsWithRetry(messageSyncService, options = {}) {
if (!waitSeconds || waitSeconds > maxWaitSeconds) {
throw error;
}
console.log(`Rate limited while seeding dialogs. Waiting ${waitSeconds}s and retrying once...`);
process.stderr.write(`Rate limited while seeding dialogs. Waiting ${waitSeconds}s and retrying once...\n`);
await delay(waitSeconds * 1000);
return await messageSyncService.refreshChannelsFromDialogs();
}
Expand Down Expand Up @@ -2832,16 +2818,24 @@ async function runSendText(globalFlags, options = {}) {
const topicId = parsePositiveInt(options.topic, '--topic');
const replyToMessageId = parsePositiveInt(options.replyTo, '--reply-to');
const scheduleDate = parseScheduleDate(options.schedule);
const result = await telegramClient.sendTextMessage(options.to, options.message, {
topicId,
replyToMessageId,
parseMode,
noPreview: options.noPreview,
silent: options.silent || false,
noforwards: options.forwards === false,
scheduleDate,
});
const retries = Math.max(0, parseInt(options.retries, 10) || 0);
const { result, retryLog, attempts } = await withSendRetry(
() => telegramClient.sendTextMessage(options.to, options.message, {
topicId,
replyToMessageId,
parseMode,
noPreview: options.noPreview,
silent: options.silent || false,
noforwards: options.forwards === false,
scheduleDate,
}),
{ retries, json: globalFlags.json }
);
const payload = { channelId: options.to, ...result };
if (retryLog.length > 0) {
payload.attempts = attempts;
payload.retry_log = retryLog;
}

if (globalFlags.json) {
writeJson(payload);
Expand Down Expand Up @@ -2879,20 +2873,28 @@ async function runSendFile(globalFlags, options = {}) {
const topicId = parsePositiveInt(options.topic, '--topic');
const replyToMessageId = parsePositiveInt(options.replyTo, '--reply-to');
const scheduleDate = parseScheduleDate(options.schedule);
const result = await telegramClient.sendFileMessage(options.to, options.file, {
caption: options.caption,
filename: options.filename,
topicId,
replyToMessageId,
parseMode,
silent: options.silent || false,
noforwards: options.forwards === false,
captionAbove: options.captionAbove || false,
spoiler: options.spoiler || false,
scheduleDate,
forceDocument: options.forceDocument || false,
});
const retries = Math.max(0, parseInt(options.retries, 10) || 0);
const { result, retryLog, attempts } = await withSendRetry(
() => telegramClient.sendFileMessage(options.to, options.file, {
caption: options.caption,
filename: options.filename,
topicId,
replyToMessageId,
parseMode,
silent: options.silent || false,
noforwards: options.forwards === false,
captionAbove: options.captionAbove || false,
spoiler: options.spoiler || false,
scheduleDate,
forceDocument: options.forceDocument || false,
}),
{ retries, json: globalFlags.json }
);
const payload = { channelId: options.to, ...result };
if (retryLog.length > 0) {
payload.attempts = attempts;
payload.retry_log = retryLog;
}

if (globalFlags.json) {
writeJson(payload);
Expand Down
99 changes: 99 additions & 0 deletions core/retry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { setTimeout as delay } from 'timers/promises';

function formatErrorMessage(error) {
if (error instanceof Error && error.message) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
return String(error);
}

function parseRequiredWaitSeconds(error) {
const text = formatErrorMessage(error);
const waitMatch = /wait of (\d+) seconds is required/i.exec(text);
if (waitMatch) {
return Number(waitMatch[1]);
}
const floodWaitMatch = /FLOOD_WAIT_(\d+)/i.exec(text);
if (floodWaitMatch) {
return Number(floodWaitMatch[1]);
}
return null;
}

function classifyError(error) {
const message = formatErrorMessage(error);
const code = error?.code ?? null;
if (parseRequiredWaitSeconds(error) !== null || /FLOOD_WAIT/i.test(message)) {
return { type: 'rate_limit', message, code };
}
if (/ECONNRESET|ETIMEDOUT|ENETUNREACH/i.test(message) ||
/ECONNRESET|ETIMEDOUT|ENETUNREACH/.test(code ?? '')) {
return { type: 'network', message, code };
}
return { type: 'api', message, code };
}

function computeRetryWaitSeconds(error, attempt) {
const rateLimitWait = parseRequiredWaitSeconds(error);
if (rateLimitWait !== null) {
return rateLimitWait;
}
// Exponential backoff: 1s, 2s, 4s, ...
return Math.pow(2, attempt - 1);
}

async function withSendRetry(fn, options = {}) {
const maxRetries = options.retries ?? 0;
const json = options.json ?? false;
const maxWaitSeconds = options.maxWaitSeconds ?? 300;
const retryLog = [];
const maxAttempts = maxRetries + 1;

for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const result = await fn();
return { result, retryLog, attempts: attempt };
} catch (error) {
const classified = classifyError(error);

if (attempt >= maxAttempts || classified.type === 'api') {
error.retryLog = retryLog;
error.attempts = attempt;
throw error;
}

const waitSeconds = computeRetryWaitSeconds(error, attempt);

if (waitSeconds > maxWaitSeconds) {
error.retryLog = retryLog;
error.attempts = attempt;
throw error;
}

retryLog.push({
attempt,
error: classified,
waitSeconds,
});

if (json) {
process.stderr.write(`${JSON.stringify({
event: 'retry',
attempt,
maxAttempts,
error: classified,
waitSeconds,
})}\n`);
} else {
process.stderr.write(`Retry ${attempt}/${maxRetries}: ${classified.type.toUpperCase()} — ${classified.message}. Waiting ${waitSeconds}s...\n`);
}

await delay(waitSeconds * 1000);
}
}
}

export { formatErrorMessage, parseRequiredWaitSeconds, classifyError, computeRetryWaitSeconds, withSendRetry };
Loading
Loading