#!/usr/bin/env node
import { config as loadDotenv } from 'dotenv';
import { TelegramClient } from 'telegram';
import { StringSession } from 'telegram/sessions/index.js';
import { Logger, LogLevel } from 'telegram/extensions/Logger.js';
import * as readline from 'readline';
import { disconnectTelegram, getAllUserGroups, getClient, initializeTelegram } from './telegram.js';
import { loadTelegramConfig, readStoredConfig, saveTelegramConfig, getConfigPath } from './config.js';
import { searchMessages } from './tools/search.js';
import { queryMessageContext } from './tools/thread.js';
import type { DateShortcut, SearchParams, SortOrder, TelegramConfig, ThreadMode, ThreadParams } from './types.js';
loadDotenv();
process.on('unhandledRejection', (reason) => {
const msg = reason instanceof Error ? reason.message : String(reason);
if (msg.includes('TIMEOUT')) {
return;
}
process.stderr.write(`Unhandled rejection: ${msg}\n`);
});
type ErrorCode =
| 'AUTH_REQUIRED'
| 'SESSION_REVOKED'
| 'INVALID_INPUT'
| 'SEARCH_FAILED'
| 'RATE_LIMITED'
| 'INTERNAL_ERROR';
class CliError extends Error {
readonly code: ErrorCode;
readonly retryable: boolean;
readonly exitCode: number;
constructor(message: string, code: ErrorCode, retryable = false, exitCode = 1) {
super(message);
this.code = code;
this.retryable = retryable;
this.exitCode = exitCode;
}
}
interface ParsedArgs {
positionals: string[];
flags: Record<string, string | boolean>;
}
function parseArgs(args: string[]): ParsedArgs {
const positionals: string[] = [];
const flags: Record<string, string | boolean> = {};
for (let i = 0; i < args.length; i += 1) {
const token = args[i];
if (!token.startsWith('--')) {
positionals.push(token);
continue;
}
const trimmed = token.slice(2);
const eqIndex = trimmed.indexOf('=');
if (eqIndex >= 0) {
const key = trimmed.slice(0, eqIndex);
const value = trimmed.slice(eqIndex + 1);
flags[key] = value;
continue;
}
const nextToken = args[i + 1];
if (!nextToken || nextToken.startsWith('--')) {
flags[trimmed] = true;
continue;
}
flags[trimmed] = nextToken;
i += 1;
}
return { positionals, flags };
}
function isJsonMode(flags: Record<string, string | boolean>): boolean {
return flags.json === true || flags.json === 'true';
}
function asString(value: string | boolean | undefined): string | undefined {
if (typeof value === 'string') {
return value;
}
return undefined;
}
function asNumber(value: string | boolean | undefined, name: string): number | undefined {
const str = asString(value);
if (str === undefined) {
return undefined;
}
const num = Number(str);
if (Number.isNaN(num)) {
throw new CliError(`${name} must be a number`, 'INVALID_INPUT');
}
return num;
}
function asBoolean(value: string | boolean | undefined, name: string): boolean | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value === 'boolean') {
return value;
}
if (value === 'true') {
return true;
}
if (value === 'false') {
return false;
}
throw new CliError(`${name} must be true or false`, 'INVALID_INPUT');
}
function asCsv(value: string | boolean | undefined): string[] | undefined {
const str = asString(value);
if (!str) {
return undefined;
}
return str
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0);
}
function printJson(payload: unknown): void {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
}
function printUsage(): void {
const lines = [
'telegram-brief - query Telegram chats from your terminal',
'',
'Usage:',
' telegram-brief auth login [--json]',
' telegram-brief auth status [--json]',
' telegram-brief auth reauth [--json]',
' telegram-brief search --query "keyword" [options] --json',
' telegram-brief thread --group-id <id> --message-id <id> [options] --json',
' telegram-brief chats list [options] --json',
'',
'Search options:',
' --query <text> Required search query',
' --limit <n> Max results (1-100)',
' --offset <n> Result offset',
' --sort-by <relevance|date_desc|date_asc>',
' --start-date <date>',
' --end-date <date>',
' --date-range <last24h|last7days|last30days|last90days>',
' --group-ids <id1,id2>',
' --max-groups <n>',
' --include-channels <true|false>',
' --include-archived-chats <true|false>',
' --group-types <channel,supergroup,gigagroup,basicgroup>',
' --concurrency-limit <1-10>',
' --rate-limit-delay <0-5000>',
' --include-extended-metadata <true|false>',
'',
'Thread options:',
' --group-id <id> Required group/chat id for message context',
' --message-id <id> Required message id',
' --mode <replies|ancestors> Context mode (default: replies)',
' --limit <n> Max results/depth (1-100)',
' --offset-id <id> Replies pagination offset id',
' --offset-date <unix> Replies pagination offset date',
' --add-offset <n> Replies pagination additional offset',
' --max-id <id> Replies pagination max id',
' --min-id <id> Replies pagination min id',
' --max-depth <n> Max ancestor depth (default: limit)',
];
process.stdout.write(`${lines.join('\n')}\n`);
}
function ensureConfigured(): TelegramConfig {
const loaded = loadTelegramConfig();
if (loaded) {
return loaded;
}
throw new CliError(
`Telegram is not configured. Run \`telegram-brief auth login\` first. Config path: ${getConfigPath()}`,
'AUTH_REQUIRED',
true,
3,
);
}
function mapError(error: unknown): CliError {
if (error instanceof CliError) {
return error;
}
const message = error instanceof Error ? error.message : 'Unknown error';
if (message.includes('Not authorized')) {
return new CliError(message, 'AUTH_REQUIRED', true, 3);
}
if (
message.includes('AUTH_KEY_UNREGISTERED') ||
message.includes('SESSION_REVOKED') ||
message.includes('SESSION_PASSWORD_NEEDED')
) {
return new CliError(message, 'SESSION_REVOKED', true, 4);
}
if (message.includes('FLOOD_WAIT')) {
return new CliError(message, 'RATE_LIMITED', true, 5);
}
return new CliError(message, 'INTERNAL_ERROR');
}
function parseSearchParams(flags: Record<string, string | boolean>): SearchParams {
const query = asString(flags.query);
if (!query || query.trim().length === 0) {
throw new CliError('Missing required argument: --query', 'INVALID_INPUT');
}
const sortBy = asString(flags['sort-by']);
if (sortBy && !['relevance', 'date_desc', 'date_asc'].includes(sortBy)) {
throw new CliError('sort-by must be one of: relevance, date_desc, date_asc', 'INVALID_INPUT');
}
const dateRange = asString(flags['date-range']);
if (dateRange && !['last24h', 'last7days', 'last30days', 'last90days'].includes(dateRange)) {
throw new CliError('date-range must be one of: last24h, last7days, last30days, last90days', 'INVALID_INPUT');
}
return {
query,
limit: asNumber(flags.limit, 'limit'),
offset: asNumber(flags.offset, 'offset'),
sortBy: sortBy as SortOrder | undefined,
startDate: asString(flags['start-date']),
endDate: asString(flags['end-date']),
dateRange: dateRange as DateShortcut | undefined,
groupIds: asCsv(flags['group-ids']),
maxGroups: asNumber(flags['max-groups'], 'max-groups'),
includeChannels: asBoolean(flags['include-channels'], 'include-channels'),
includeArchivedChats: asBoolean(flags['include-archived-chats'], 'include-archived-chats'),
groupTypes: asCsv(flags['group-types']),
concurrencyLimit: asNumber(flags['concurrency-limit'], 'concurrency-limit'),
rateLimitDelay: asNumber(flags['rate-limit-delay'], 'rate-limit-delay'),
includeExtendedMetadata: asBoolean(flags['include-extended-metadata'], 'include-extended-metadata'),
};
}
function parseThreadParams(flags: Record<string, string | boolean>): ThreadParams {
const groupId = asString(flags['group-id']);
if (!groupId || groupId.trim().length === 0) {
throw new CliError('Missing required argument: --group-id', 'INVALID_INPUT');
}
const messageId = asNumber(flags['message-id'], 'message-id');
if (!messageId || messageId <= 0) {
throw new CliError('message-id must be a positive number', 'INVALID_INPUT');
}
const mode = asString(flags.mode);
if (mode && mode !== 'replies' && mode !== 'ancestors') {
throw new CliError('mode must be one of: replies, ancestors', 'INVALID_INPUT');
}
const limit = asNumber(flags.limit, 'limit');
if (limit !== undefined && (limit < 1 || limit > 100)) {
throw new CliError('limit must be between 1 and 100', 'INVALID_INPUT');
}
return {
groupId,
messageId,
mode: mode as ThreadMode | undefined,
limit,
offsetId: asNumber(flags['offset-id'], 'offset-id'),
offsetDate: asNumber(flags['offset-date'], 'offset-date'),
addOffset: asNumber(flags['add-offset'], 'add-offset'),
maxId: asNumber(flags['max-id'], 'max-id'),
minId: asNumber(flags['min-id'], 'min-id'),
maxDepth: asNumber(flags['max-depth'], 'max-depth'),
};
}
function question(rl: readline.Interface, text: string): Promise<string> {
return new Promise((resolve) => {
rl.question(text, resolve);
});
}
async function authenticate(forceReauth: boolean, json: boolean): Promise<void> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const existing = readStoredConfig();
const fallbackConfig = loadTelegramConfig();
const defaultApiId = process.env.TELEGRAM_API_ID || String(existing?.apiId ?? fallbackConfig?.apiId ?? '');
const defaultApiHash = process.env.TELEGRAM_API_HASH || existing?.apiHash || fallbackConfig?.apiHash || '';
const defaultPhone = process.env.TELEGRAM_PHONE || existing?.phone || fallbackConfig?.phone || '';
try {
if (!json) {
process.stdout.write(forceReauth ? 'Starting Telegram reauthentication...\n' : 'Starting Telegram authentication...\n');
process.stdout.write(`Session will be saved at ${getConfigPath()}\n\n`);
}
const apiIdInput = await question(
rl,
`API ID${defaultApiId ? ` [${defaultApiId}]` : ''}: `,
);
const apiHashInput = await question(
rl,
`API Hash${defaultApiHash ? ' [saved]' : ''}: `,
);
const phoneInput = await question(
rl,
`Phone${defaultPhone ? ` [${defaultPhone}]` : ''}: `,
);
const apiIdRaw = apiIdInput.trim() || defaultApiId;
const apiHash = apiHashInput.trim() || defaultApiHash;
const phone = phoneInput.trim() || defaultPhone;
const apiId = Number(apiIdRaw);
if (!apiIdRaw || Number.isNaN(apiId)) {
throw new CliError('Invalid API ID', 'INVALID_INPUT');
}
if (!apiHash || !phone) {
throw new CliError('API hash and phone are required', 'INVALID_INPUT');
}
const client = new TelegramClient(new StringSession(''), apiId, apiHash, {
connectionRetries: 5,
baseLogger: new Logger(LogLevel.NONE),
});
await client.start({
phoneNumber: async () => phone,
password: async () => question(rl, '2FA password (if enabled): '),
phoneCode: async () => question(rl, 'Code from Telegram: '),
onError: (err) => {
throw err;
},
});
const me = await client.getMe();
const session = client.session.save() as unknown as string;
saveTelegramConfig({
apiId,
apiHash,
phone,
session,
});
await client.disconnect();
if (json) {
printJson({
success: true,
data: {
authenticated: true,
account: {
id: Number(me.id),
username: me.username,
firstName: me.firstName,
lastName: me.lastName,
phone: me.phone,
},
configPath: getConfigPath(),
},
});
return;
}
process.stdout.write('\nAuthenticated successfully.\n');
process.stdout.write(`Config saved to ${getConfigPath()}\n`);
} finally {
rl.close();
}
}
async function authStatus(json: boolean): Promise<void> {
const config = ensureConfigured();
try {
await initializeTelegram(config);
const client = getClient();
const me = await client.getMe();
if (json) {
printJson({
success: true,
data: {
authenticated: true,
account: {
id: Number(me.id),
username: me.username,
firstName: me.firstName,
lastName: me.lastName,
phone: me.phone,
},
configPath: getConfigPath(),
},
});
return;
}
process.stdout.write('Authenticated: true\n');
process.stdout.write(`Account: ${me.firstName || ''} ${me.lastName || ''} (${me.username || 'no username'})\n`);
} finally {
await disconnectTelegram();
}
}
async function runSearch(flags: Record<string, string | boolean>, json: boolean): Promise<void> {
const config = ensureConfigured();
const params = parseSearchParams(flags);
try {
await initializeTelegram(config);
const result = await searchMessages(config, params);
if (!result.success) {
throw new CliError(result.error || 'Search failed', 'SEARCH_FAILED');
}
if (json) {
printJson({
success: true,
data: result,
});
return;
}
process.stdout.write(`Found ${result.totalFound} messages\n`);
for (const msg of result.results.slice(0, 5)) {
process.stdout.write(`- [${msg.groupTitle}] ${msg.senderName}: ${msg.text.slice(0, 120)}\n`);
}
} finally {
await disconnectTelegram();
}
}
async function runThread(flags: Record<string, string | boolean>, json: boolean): Promise<void> {
const config = ensureConfigured();
const params = parseThreadParams(flags);
try {
await initializeTelegram(config);
const result = await queryMessageContext(params);
if (!result.success) {
throw new CliError(result.error || 'Thread query failed', 'SEARCH_FAILED');
}
if (json) {
printJson({
success: true,
data: result,
});
return;
}
process.stdout.write(`Found ${result.totalFound} ${result.mode} messages\n`);
for (const msg of result.results.slice(0, 5)) {
process.stdout.write(`- [${msg.groupTitle}] ${msg.senderName}: ${msg.text.slice(0, 120)}\n`);
}
} finally {
await disconnectTelegram();
}
}
async function listChats(flags: Record<string, string | boolean>, json: boolean): Promise<void> {
const config = ensureConfigured();
const maxGroups = asNumber(flags['max-groups'], 'max-groups');
const includeChannels = asBoolean(flags['include-channels'], 'include-channels');
const includeArchivedChats = asBoolean(flags['include-archived-chats'], 'include-archived-chats');
const groupTypes = asCsv(flags['group-types']);
try {
await initializeTelegram(config);
const client = getClient();
const chats = await getAllUserGroups(client, {
maxGroups,
includeChannels,
includeArchivedChats,
groupTypes,
});
if (json) {
printJson({
success: true,
data: {
total: chats.length,
chats,
},
});
return;
}
process.stdout.write(`Discovered ${chats.length} chats\n`);
for (const chat of chats) {
process.stdout.write(`- ${chat.title} (${chat.type}) ${chat.id}\n`);
}
} finally {
await disconnectTelegram();
}
}
async function run(): Promise<void> {
const args = process.argv.slice(2);
const command = args[0];
try {
if (!command || command === 'help' || command === '--help') {
printUsage();
return;
}
if (command === 'auth') {
const subcommand = args[1];
const parsed = parseArgs(args.slice(2));
const json = isJsonMode(parsed.flags);
if (subcommand === 'login') {
await authenticate(false, json);
return;
}
if (subcommand === 'reauth') {
await authenticate(true, json);
return;
}
if (subcommand === 'status') {
await authStatus(json);
return;
}
throw new CliError('Unknown auth command. Use: login, status, or reauth', 'INVALID_INPUT');
}
if (command === 'search') {
const parsed = parseArgs(args.slice(1));
const json = isJsonMode(parsed.flags);
await runSearch(parsed.flags, json);
return;
}
if (command === 'thread') {
const parsed = parseArgs(args.slice(1));
const json = isJsonMode(parsed.flags);
await runThread(parsed.flags, json);
return;
}
if (command === 'chats' && args[1] === 'list') {
const parsed = parseArgs(args.slice(2));
const json = isJsonMode(parsed.flags);
await listChats(parsed.flags, json);
return;
}
throw new CliError('Unknown command. Run `telegram-brief help` for usage.', 'INVALID_INPUT');
} catch (err) {
const parsed = parseArgs(args.slice(1));
const json = isJsonMode(parsed.flags);
const mapped = mapError(err);
if (json) {
printJson({
success: false,
error: {
code: mapped.code,
message: mapped.message,
retryable: mapped.retryable,
},
});
} else {
process.stderr.write(`Error [${mapped.code}]: ${mapped.message}\n`);
}
process.exit(mapped.exitCode);
}
}
run().catch((err) => {
const mapped = mapError(err);
process.stderr.write(`Fatal error [${mapped.code}]: ${mapped.message}\n`);
process.exit(mapped.exitCode);
});