Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<b>weekly report</b>" --parse-mode html
tgcli send file --to @channel --file ./report.pdf --reply-to 123
tgcli server
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
41 changes: 19 additions & 22 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -59,13 +58,15 @@ tgcli auth
- If command shape is uncertain, verify it first with `tgcli <command> --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 <messageId>` 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 <iso>` 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 <n>` retries transient network/transport failures for `send photo`
- `--retry-backoff <ms|constant|linear|exponential>` controls retry delay for `send photo`
- `--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`):
Expand Down Expand Up @@ -107,9 +108,9 @@ tgcli messages search "Release" --case-sensitive --chat <id|@username> --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 <id|@username> --message "Hello" --json --timeout 30s
Expand All @@ -121,6 +122,13 @@ tgcli send text --to <id|@username> --message "Confidential" --no-forwards --jso
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 photo --to <id|@username> --photo /path/to/image.png --caption "Report" --json --timeout 30s
tgcli send photo --to <id|@username> --photo /path/to/image.png --caption "**Report**" --parse-mode markdown --json --timeout 30s
tgcli send photo --to <id|@username> --photo /path/to/image.png --reply-to <messageId> --json --timeout 30s
tgcli send photo --to <id|@username> --photo /path/to/image.png --caption "Breaking news" --caption-above --json --timeout 30s
tgcli send photo --to <id|@username> --photo /path/to/image.png --spoiler --json --timeout 30s
tgcli send photo --to <id|@username> --photo /path/to/image.png --retries 3 --retry-backoff exponential --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
tgcli send file --to <id|@username> --file /path/to/file --filename custom-name.pdf --json --timeout 30s
Expand All @@ -130,22 +138,11 @@ tgcli send file --to <id|@username> --file /path/to/photo.jpg --spoiler --json -
tgcli send file --to <id|@username> --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=<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

Expand Down
176 changes: 172 additions & 4 deletions cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 },
Expand Down Expand Up @@ -219,7 +229,7 @@ function buildProgram() {
.option('--after <n>', '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')
Expand All @@ -234,6 +244,23 @@ function buildProgram() {
.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('photo')
.description('Send a photo with preview')
.option('--to <id|username>', 'Recipient id or username')
.option('--photo <path>', 'Photo path')
.option('--caption <text>', 'Optional caption')
.option('--parse-mode <mode>', 'Parse mode for caption: markdown|html|none')
.option('--topic <id>', 'Forum topic id')
.option('--reply-to <id>', '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 <iso>', 'Schedule message (ISO 8601 datetime)')
.option('--retries <n>', 'Retry count for transient send failures')
.option('--retry-backoff <value>', 'Retry backoff in ms or strategy: constant|linear|exponential')
.action(withGlobalOptions((globalFlags, options) => runSendPhoto(globalFlags, options)));
send
.command('file')
.description('Send a file')
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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.');
}

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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()) {
Expand Down
Loading
Loading