diff --git a/README.md b/README.md index 024ed33..6c5fda0 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/cli.js b/cli.js index 2701caf..07665b5 100755 --- a/cli.js +++ b/cli.js @@ -249,6 +249,13 @@ function buildProgram() { .option('--before ', 'Messages before') .option('--after ', 'Messages after') .action(withGlobalOptions((globalFlags, options) => runMessagesContext(globalFlags, options))); + messages + .command('transcribe') + .description('Transcribe voice or video messages (requires Telegram Premium)') + .option('--chat ', 'Channel identifier') + .option('--id ', 'Message id (repeatable)', collectList) + .option('--wait ', '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 @@ -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 () => { diff --git a/telegram-client.js b/telegram-client.js index 89c4c1d..f0dd115 100644 --- a/telegram-client.js +++ b/telegram-client.js @@ -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; @@ -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(); diff --git a/tests/transcribe.test.js b/tests/transcribe.test.js new file mode 100644 index 0000000..6b2dbd7 --- /dev/null +++ b/tests/transcribe.test.js @@ -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'); + }); +});