Skip to content
Open
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ tgcli auth
tgcli sync --follow
tgcli messages list --chat @username --limit 20
tgcli messages search "course" --chat @channel --source archive
tgcli messages transcribe --chat @username --id 123 --id 124
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
Expand All @@ -137,7 +138,7 @@ tgcli sync Archive backfill and realtime sync
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 messages List/search/transcribe messages
tgcli send Send text, photos, or files
tgcli media Download media
tgcli topics Forum topics
Expand Down
52 changes: 52 additions & 0 deletions cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,13 @@ function buildProgram() {
.option('--before <n>', 'Messages before')
.option('--after <n>', 'Messages after')
.action(withGlobalOptions((globalFlags, options) => runMessagesContext(globalFlags, options)));
messages
.command('transcribe')
.description('Transcribe voice or video messages (requires Telegram Premium)')
.option('--chat <id|username>', 'Channel identifier')
.option('--id <msgId>', 'Message id (repeatable)', collectList)
.option('--wait <seconds>', 'How long to wait for the final text (default 60)')
.action(withGlobalOptions((globalFlags, options) => runMessagesTranscribe(globalFlags, options)));

const send = program.command('send').description('Send text, photos, or files');
send
Expand Down Expand Up @@ -2848,6 +2855,51 @@ async function runMessagesShow(globalFlags, options = {}) {
}, timeoutMs);
}

async function runMessagesTranscribe(globalFlags, options = {}) {
const timeoutMs = globalFlags.timeoutMs;
return runWithTimeout(async () => {
if (!options.chat) {
throw new Error('--chat is required');
}
const ids = (Array.isArray(options.id) ? options.id : [options.id]).filter(Boolean);
if (!ids.length) {
throw new Error('--id is required');
}
const waitMs = options.wait ? parsePositiveInt(options.wait, '--wait') * 1000 : undefined;
const storeDir = resolveStoreDir();
const release = acquireReadLock(storeDir);
const { telegramClient, messageSyncService } = createServices({ storeDir });
try {
if (!(await telegramClient.isAuthorized().catch(() => false))) {
throw new Error('Not authenticated. Run `node cli.js auth` first.');
}
const results = [];
for (const rawId of ids) {
const messageId = parsePositiveInt(rawId, '--id');
try {
const transcription = await telegramClient.transcribeVoice(options.chat, messageId, { waitMs });
results.push({ messageId, ...transcription });
} catch (error) {
results.push({ messageId, error: error?.message ?? String(error) });
}
}

const payload = { chat: String(options.chat), results };
if (globalFlags.json) {
writeJson(payload);
} else {
for (const item of results) {
console.log(`#${item.messageId}${item.pending ? ' (incomplete)' : ''}: ${item.error ? `error: ${item.error}` : item.text}`);
}
}
} finally {
await messageSyncService.shutdown();
await telegramClient.destroy();
release();
}
}, timeoutMs);
}

async function runMessagesContext(globalFlags, options = {}) {
const timeoutMs = globalFlags.timeoutMs;
return runWithTimeout(async () => {
Expand Down
80 changes: 80 additions & 0 deletions telegram-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,23 @@ function resolveDownloadLocation(media) {
return null;
}

export function floodWaitSeconds(error) {
if (Number.isFinite(error?.seconds)) {
return Number(error.seconds);
}
const match = /FLOOD_WAIT_(\d+)/.exec(String(error?.message ?? ''));
return match ? Number(match[1]) : null;
}

export function formatTranscription(result, overrides = {}) {
return {
text: result?.text ?? '',
pending: overrides.pending ?? Boolean(result?.pending),
transcriptionId: result?.transcriptionId != null ? String(result.transcriptionId) : null,
trialRemaining: result?.trialRemainsNum ?? null,
};
}

export function normalizeChannelId(channelId) {
if (typeof channelId === 'number') {
return channelId;
Expand Down Expand Up @@ -1385,6 +1402,69 @@ class TelegramClient {
};
}

async transcribeVoice(channelId, messageId, options = {}) {
await this.ensureLogin();
const peer = await this.client.resolvePeer(normalizeChannelId(channelId));
const msgId = Number(messageId);
const waitMs = Number.isFinite(options.waitMs) ? options.waitMs : 60000;

const first = await this._callWithFloodWait(
{ _: 'messages.transcribeAudio', peer, msgId },
options,
);
if (!first.pending) {
return formatTranscription(first);
}

// The finished text arrives as an update, not in the reply. Polling instead
// of waiting spends the rate limit and returns the unrefined first pass.
const finished = await this._awaitTranscribedAudio(first.transcriptionId, msgId, waitMs);
return formatTranscription(finished ?? first, { pending: !finished });
}

async _callWithFloodWait(request, options = {}) {
const maxWaitSeconds = Number.isFinite(options.maxFloodWaitSeconds)
? options.maxFloodWaitSeconds
: 60;
for (;;) {
try {
return await this.client.call(request);
} catch (error) {
const seconds = floodWaitSeconds(error);
if (seconds === null || seconds > maxWaitSeconds) {
throw error;
}
await new Promise((resolve) => setTimeout(resolve, seconds * 1000 + 500));
}
}
}

async _awaitTranscribedAudio(transcriptionId, msgId, waitMs) {
await this.startUpdates();
return new Promise((resolve) => {
let unsubscribe = () => {};
const timer = setTimeout(() => {
unsubscribe();
resolve(null);
}, waitMs);
unsubscribe = this.onUpdate((info) => {
const update = info?.update;
if (update?._ !== 'updateTranscribedAudio' || update.pending) {
return;
}
if (Number(update.msgId) !== msgId) {
return;
}
if (transcriptionId != null && String(update.transcriptionId) !== String(transcriptionId)) {
return;
}
clearTimeout(timer);
unsubscribe();
resolve(update);
});
});
}

async listContacts() {
await this.ensureLogin();
return this.client.getContacts();
Expand Down
89 changes: 89 additions & 0 deletions tests/transcribe.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from 'vitest';

import TelegramClient, { floodWaitSeconds, formatTranscription } from '../telegram-client.js';

describe('floodWaitSeconds', () => {
it('reads the wait from a FLOOD_WAIT error message', () => {
expect(floodWaitSeconds(new Error('Telegram API error 420: FLOOD_WAIT_13'))).toBe(13);
});

it('prefers a numeric seconds field when the error carries one', () => {
expect(floodWaitSeconds({ seconds: 7, message: 'FLOOD_WAIT_13' })).toBe(7);
});

it('returns null for unrelated errors so they are rethrown', () => {
expect(floodWaitSeconds(new Error('CHAT_ADMIN_REQUIRED'))).toBeNull();
});
});

describe('formatTranscription', () => {
it('normalizes a finished transcription', () => {
expect(formatTranscription({ text: 'hello', pending: false, transcriptionId: 12n, trialRemainsNum: 2 }))
.toEqual({ text: 'hello', pending: false, transcriptionId: '12', trialRemaining: 2 });
});

it('lets the caller mark a result as still incomplete', () => {
expect(formatTranscription({ text: 'half', pending: true }, { pending: true }).pending).toBe(true);
});
});

describe('transcribeVoice', () => {
const build = () => {
const client = Object.create(TelegramClient.prototype);
client.ensureLogin = vi.fn().mockResolvedValue(undefined);
client.startUpdates = vi.fn().mockResolvedValue(undefined);
client.client = { resolvePeer: vi.fn().mockResolvedValue({ _: 'inputPeerUser' }), call: vi.fn() };
return client;
};

it('returns immediately when Telegram reports the transcription as done', async () => {
const client = build();
client.client.call.mockResolvedValue({ text: 'done', pending: false, transcriptionId: 5n });

const result = await client.transcribeVoice(123, 7);

expect(result.text).toBe('done');
expect(client.client.call).toHaveBeenCalledTimes(1);
});

// Polling instead of awaiting the update spends the rate limit and yields draft text
it('waits for the update instead of polling when the first answer is pending', async () => {
const client = build();
client.client.call.mockResolvedValue({ text: 'draft', pending: true, transcriptionId: 5n });
client._awaitTranscribedAudio = vi.fn().mockResolvedValue({ text: 'final', transcriptionId: 5n });

const result = await client.transcribeVoice(123, 7, { waitMs: 10 });

expect(result.text).toBe('final');
expect(client.client.call).toHaveBeenCalledTimes(1);
});

it('marks the result incomplete when the final update never arrives', async () => {
const client = build();
client.client.call.mockResolvedValue({ text: 'draft', pending: true, transcriptionId: 5n });
client._awaitTranscribedAudio = vi.fn().mockResolvedValue(null);

const result = await client.transcribeVoice(123, 7, { waitMs: 10 });

expect(result).toMatchObject({ text: 'draft', pending: true });
});

it('sleeps through a short flood wait and retries once', async () => {
const client = build();
client.client.call
.mockRejectedValueOnce(new Error('Telegram API error 420: FLOOD_WAIT_1'))
.mockResolvedValueOnce({ text: 'after the wait', pending: false });

const result = await client.transcribeVoice(123, 7);

expect(result.text).toBe('after the wait');
expect(client.client.call).toHaveBeenCalledTimes(2);
});

it('rethrows a flood wait that is longer than the caller allows', async () => {
const client = build();
client.client.call.mockRejectedValue(new Error('Telegram API error 420: FLOOD_WAIT_600'));

await expect(client.transcribeVoice(123, 7, { maxFloodWaitSeconds: 5 })).rejects.toThrow('FLOOD_WAIT_600');
});
});