// Telegram voice both ways (server/voice.js, server/owner.js): when Billion // speaks, the sendVoice upload, falling back to text, and the owner's voice // notes transcribed by whisper.cpp. fetch, spawn and the PATH lookup are all // mocked: nothing here talks to Telegram or runs say, ffmpeg or whisper. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { EventEmitter } from 'events'; import { writeFileSync, readFileSync, rmSync, mkdtempSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; const tools = { available: new Set(), whisperOut: 'Yes, buy the domain.', exit: 0, calls: [], said: [] }; vi.mock('child_process', async (importOriginal) => ({ ...(await importOriginal()), spawn: vi.fn((cmd, args) => { tools.calls.push([cmd, args]); const child = new EventEmitter(); child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); process.nextTick(() => { if (cmd === 'say' && args[1] === '?') child.stdout.emit('data', tools.voices || ''); else if (cmd === 'say') tools.said.push(readFileSync(args[args.indexOf('-f') + 1], 'utf8')); if (cmd === 'ffmpeg') writeFileSync(args[args.length - 1], 'OGGDATA'); if (cmd.includes('whisper')) child.stdout.emit('data', `\n ${tools.whisperOut}\n`); child.emit('close', cmd.includes('whisper') ? tools.exit : 0); }); return child; }), })); vi.mock('../server/command-path.js', async (importOriginal) => ({ ...(await importOriginal()), commandExists: (file) => tools.available.has(file), })); const { notifyOwner, sendToOwner, handleUpdate, lastOwnerMode, OWNER_VOICE_PREFIX, NOTIFY_WINDOW_MS, chatMessages } = await import('../server/owner.js'); const { chooseMode, textOnlyReason, whisperSetup, MAX_NOTE_SECONDS, MAX_NOTE_BYTES } = await import('../server/voice.js'); const { sessions, CONFIG_DIR } = await import('../server/state.js'); const { dropMessages } = await import('../server/messages.js'); const TOKEN = '123456:SECRET-token'; const model = join(mkdtempSync(join(tmpdir(), 'a007-whisper-')), 'ggml-base.en.bin'); writeFileSync(model, 'model'); const ENV = { TELEGRAM_BOT_TOKEN: TOKEN, TELEGRAM_CHAT_ID: '42', WHISPER_MODEL: model }; const reply = (result) => ({ ok: true, json: async () => ({ ok: true, result }), arrayBuffer: async () => new TextEncoder().encode('OPUS').buffer }); let fetchMock; let clock = 2e12; const now = () => (clock += 10 * NOTIFY_WINDOW_MS); const setMode = (mode) => writeFileSync(join(CONFIG_DIR, 'telegram-voice.json'), JSON.stringify({ lastMode: mode })); const billion = () => ({ id: 'voice-billion', name: 'Billion', isBillion: true, command: 'claude', state: 'WAITING', exited: false, ownerId: null, stateChangedAt: Date.now() - 5000, recentStrippedLines: [], isTUI: true, lastOutputAt: 0, pty: { write: vi.fn() }, }); const typedInto = (b) => b.pty.write.mock.calls.map(c => c[0]).join(''); const methods = () => fetchMock.mock.calls.map(([url]) => url.split('/').pop()); const voiceUpdate = (chat, note) => ({ update_id: 1, message: { chat: { id: chat }, voice: { file_id: 'F1', duration: 4, ...note } } }); beforeEach(() => { fetchMock = vi.fn(async (url) => reply(url.endsWith('/getFile') ? { file_path: 'voice/file_1.oga', file_size: 1000 } : true)); vi.stubGlobal('fetch', fetchMock); tools.available = new Set(['say', 'ffmpeg', 'whisper-cli']); tools.calls = []; tools.said = []; tools.exit = 0; tools.whisperOut = 'Yes, buy the domain.'; rmSync(join(CONFIG_DIR, 'telegram-voice.json'), { force: true }); }); afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); sessions.delete('voice-billion'); dropMessages('voice-billion'); }); describe('when Billion speaks', () => { it('TELEGRAM_VOICE: never is text, always is voice, mirror follows the owner\'s last message', () => { const say = 'Spend twenty dollars on the domain? I recommend yes.'; expect(chooseMode(say, { env: { TELEGRAM_VOICE: 'never' }, lastMode: 'voice' }).mode).toBe('text'); expect(chooseMode(say, { env: { TELEGRAM_VOICE: 'always' }, lastMode: 'text' }).mode).toBe('voice'); expect(chooseMode(say, { env: {}, lastMode: 'voice' }).mode).toBe('voice'); expect(chooseMode(say, { env: {}, lastMode: 'text' }).mode).toBe('text'); expect(chooseMode(say, { env: { TELEGRAM_VOICE: 'mirror' } }).mode).toBe('text'); // before the owner has said anything }); it('keeps long messages and ones that are mostly links, code or paths as text, even with always', () => { const always = { env: { TELEGRAM_VOICE: 'always' } }; expect(chooseMode('word '.repeat(200), always)).toEqual({ mode: 'text', reason: 'long' }); expect(textOnlyReason('PR: https://github.com/bill10/agent-007/pull/114')).toBe('mostly links, code or paths'); expect(textOnlyReason('Run `npm ci && npm test` in ~/src/agent-007/server')).toBe('mostly links, code or paths'); expect(textOnlyReason('```\nconst x = 1;\n```')).toBe('mostly links, code or paths'); // A sentence that happens to carry a link is still spoken. expect(textOnlyReason('The landing page is live and the signups look good so far, have a look: https://x.co/a')).toBeNull(); expect(textOnlyReason('Should I buy the domain? I recommend yes.')).toBeNull(); expect(textOnlyReason('yes/no? and/or 24/7')).toBeNull(); // one slash is prose expect(textOnlyReason('see server/owner.js')).toBe('mostly links, code or paths'); }); it('sends voice as multipart sendVoice with the text as its caption, the text never on a command line', async () => { setMode('voice'); expect(await notifyOwner('Buy the domain? See https://x.co/d for the price, I recommend yes.', { env: ENV, now: now(), platform: 'darwin', telegram: true })).toMatchObject({ ok: true }); const [[url, init]] = fetchMock.mock.calls; expect(url).toBe(`https://api.telegram.org/bot${TOKEN}/sendVoice`); expect(init.headers).toBeUndefined(); // fetch sets the multipart boundary itself expect(init.body).toBeInstanceOf(FormData); expect(init.body.get('chat_id')).toBe('42'); expect(init.body.get('caption')).toMatch(/^Q\d+: Buy the domain\? See https:\/\/x\.co\/d for the price, I recommend yes\.$/); const voice = init.body.get('voice'); expect(voice.type).toBe('audio/ogg'); expect(voice.name).toBe('billion.ogg'); expect(Buffer.from(await voice.arrayBuffer()).toString()).toBe('OGGDATA'); const [say, ffmpeg] = tools.calls.filter(c => c[1][1] !== '?'); // say -v '?' found no voices: no -v expect(say[0]).toBe('say'); expect(say[1]).toEqual(['-o', expect.stringMatching(/say\.aiff$/), '-f', expect.stringMatching(/say\.txt$/)]); expect(ffmpeg[1]).toEqual(expect.arrayContaining(['-c:a', 'libopus', '-b:a', '32k'])); expect(tools.calls.flatMap(c => c[1]).join(' ')).not.toContain('domain'); expect(tools.said).toEqual([expect.stringMatching(/^Q\d+: Buy the domain\? See link for the price, I recommend yes\.$/)]); }); it('sends text, and says why once, when say or ffmpeg is missing or this is not macOS', async () => { setMode('voice'); const log = vi.spyOn(console, 'log').mockImplementation(() => {}); tools.available.delete('ffmpeg'); expect(await sendToOwner('Billion: hello there', { env: ENV, platform: 'darwin' })).toEqual({ ok: true }); expect(await sendToOwner('Billion: hello again', { env: ENV, platform: 'linux' })).toEqual({ ok: true }); expect(methods()).toEqual(['sendMessage', 'sendMessage']); expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ chat_id: '42', text: 'Billion: hello there' }); expect(tools.calls).toEqual([]); expect(log.mock.calls.filter(c => /not voice/.test(c[0]))).toEqual([[expect.stringMatching(/ffmpeg is not installed/)]]); }); it('falls back to text when the upload fails, with the token kept out of the error', async () => { setMode('voice'); vi.spyOn(console, 'log').mockImplementation(() => {}); fetchMock.mockRejectedValueOnce(new Error(`connect failed for https://api.telegram.org/bot${TOKEN}/sendVoice`)); expect(await sendToOwner('Billion: hi', { env: ENV, platform: 'darwin' })).toEqual({ ok: true }); expect(methods()).toEqual(['sendVoice', 'sendMessage']); }); }); describe('when the owner speaks', () => { it('transcribes the owner\'s voice note locally and types it into Billion, marked as voice', async () => { const b = billion(); sessions.set(b.id, b); expect(await handleUpdate(voiceUpdate(42), { env: ENV })).toBe('delivered'); expect(typedInto(b)).toContain(`${OWNER_VOICE_PREFIX} Yes, buy the domain.`); // The Billion tab shows the transcript, marked as voice. expect(chatMessages().at(-1)).toMatchObject({ from: 'owner', via: 'telegram', voice: true, text: 'Yes, buy the domain.' }); expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ `https://api.telegram.org/bot${TOKEN}/getFile`, `https://api.telegram.org/file/bot${TOKEN}/voice/file_1.oga`, ]); const [ffmpeg, whisper] = tools.calls; expect(ffmpeg[1]).toEqual(expect.arrayContaining(['-ar', '16000', '-ac', '1'])); expect(whisper).toEqual(['whisper-cli', ['-m', model, '-f', expect.stringMatching(/note\.wav$/), '-nt', '-np']]); // Mirror remembers it, on disk, so Billion's next message is spoken. expect(lastOwnerMode()).toBe('voice'); expect(JSON.parse(readFileSync(join(CONFIG_DIR, 'telegram-voice.json'), 'utf8'))).toEqual({ lastMode: 'voice' }); await handleUpdate({ update_id: 2, message: { chat: { id: 42 }, text: 'and one more thing' } }, { env: ENV }); expect(lastOwnerMode()).toBe('text'); }); it('names the member who spoke when the chat is a group', async () => { const b = billion(); sessions.set(b.id, b); const note = { update_id: 1, message: { chat: { id: 42, type: 'group' }, from: { first_name: 'Alice' }, voice: { file_id: 'F1', duration: 4 } } }; expect(await handleUpdate(note, { env: ENV })).toBe('delivered'); expect(typedInto(b)).toContain('[Owner via Telegram (Alice), voice] Yes, buy the domain.'); expect(chatMessages().at(-1)).toMatchObject({ name: 'Alice', voice: true }); }); it('takes an audio file the same way', async () => { const b = billion(); sessions.set(b.id, b); expect(await handleUpdate({ update_id: 1, message: { chat: { id: 42 }, audio: { file_id: 'A1', duration: 10 } } }, { env: ENV })).toBe('delivered'); expect(typedInto(b)).toContain(OWNER_VOICE_PREFIX); }); it('finds whisper.cpp by WHISPER_CPP_BIN or its other names, and needs ffmpeg too', () => { tools.available = new Set(['ffmpeg', 'whisper-cpp', '/opt/w/bin/whisper-cli']); expect(whisperSetup(ENV)).toEqual({ bin: 'whisper-cpp', model }); expect(whisperSetup({ ...ENV, WHISPER_CPP_BIN: '/opt/w/bin/whisper-cli' })).toEqual({ bin: '/opt/w/bin/whisper-cli', model }); tools.available.delete('ffmpeg'); expect(whisperSetup(ENV).missing).toMatch(/ffmpeg/); }); it('answers a note with no words in it, or a failed transcription, and delivers nothing', async () => { const b = billion(); sessions.set(b.id, b); vi.spyOn(console, 'error').mockImplementation(() => {}); tools.whisperOut = '[BLANK_AUDIO]'; expect(await handleUpdate(voiceUpdate(42), { env: ENV })).toBe('empty'); tools.whisperOut = 'hello'; tools.exit = 1; expect(await handleUpdate(voiceUpdate(42), { env: ENV })).toBe('failed'); expect(b.pty.write).not.toHaveBeenCalled(); }); it('says Billion is not running without transcribing', async () => { expect(await handleUpdate(voiceUpdate(42), { env: ENV })).toBe('not-running'); expect(methods()).toEqual(['sendMessage']); expect(tools.calls).toEqual([]); }); it('keeps a caption the owner typed on the note', async () => { const b = billion(); sessions.set(b.id, b); const update = voiceUpdate(42); update.message.caption = 'about the domain'; expect(await handleUpdate(update, { env: ENV })).toBe('delivered'); expect(typedInto(b)).toContain(`${OWNER_VOICE_PREFIX} Yes, buy the domain. (caption: about the domain)`); }); it('ignores a voice note from any other chat: no download, no transcription, no reply', async () => { const b = billion(); sessions.set(b.id, b); expect(await handleUpdate(voiceUpdate(43), { env: ENV })).toBe('ignored'); expect(fetchMock).not.toHaveBeenCalled(); expect(tools.calls).toEqual([]); expect(b.pty.write).not.toHaveBeenCalled(); expect(lastOwnerMode()).toBeUndefined(); }); it('without whisper.cpp or a model, says how to enable it and delivers nothing', async () => { const b = billion(); sessions.set(b.id, b); tools.available.delete('whisper-cli'); expect(await handleUpdate(voiceUpdate(42), { env: ENV })).toBe('no-whisper'); tools.available.add('whisper-cli'); expect(await handleUpdate(voiceUpdate(42), { env: { ...ENV, WHISPER_MODEL: '/no/such/model.bin' } })).toBe('no-whisper'); const replies = fetchMock.mock.calls.map(([, init]) => JSON.parse(init.body).text); expect(replies[0]).toMatch(/brew install whisper-cpp.*send text instead/); expect(replies[1]).toMatch(/WHISPER_MODEL.*send text instead/); expect(methods()).toEqual(['sendMessage', 'sendMessage']); expect(b.pty.write).not.toHaveBeenCalled(); expect(tools.calls).toEqual([]); }); it('refuses notes over 5 minutes or 20 MB, before or after asking Telegram for the file', async () => { const b = billion(); sessions.set(b.id, b); expect(await handleUpdate(voiceUpdate(42, { duration: MAX_NOTE_SECONDS + 1 }), { env: ENV })).toBe('too-big'); expect(await handleUpdate(voiceUpdate(42, { file_size: MAX_NOTE_BYTES + 1 }), { env: ENV })).toBe('too-big'); expect(methods()).toEqual(['sendMessage', 'sendMessage']); fetchMock.mockResolvedValueOnce(reply({ file_path: 'voice/big.oga', file_size: MAX_NOTE_BYTES + 1 })); expect(await handleUpdate(voiceUpdate(42, { file_size: undefined }), { env: ENV })).toBe('too-big'); expect(methods().slice(2)).toEqual(['getFile', 'sendMessage']); expect(JSON.parse(fetchMock.mock.calls[3][1].body).text).toMatch(/up to 5 minutes and 20 MB/); expect(tools.calls).toEqual([]); expect(b.pty.write).not.toHaveBeenCalled(); }); it('keeps the token out of the log and the reply when the download fails', async () => { const b = billion(); sessions.set(b.id, b); const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); fetchMock.mockImplementation(async (url) => { if (url.endsWith('/getFile')) return reply({ file_path: 'voice/x.oga', file_size: 10 }); if (url.includes('/file/')) throw new Error(`fetch failed: ${url}`); return reply(true); }); expect(await handleUpdate(voiceUpdate(42), { env: ENV })).toBe('failed'); const logged = errors.mock.calls.flat().join(' '); expect(logged).toContain(''); expect(logged).not.toContain(TOKEN); expect(JSON.stringify(fetchMock.mock.calls.at(-1)[1].body)).not.toContain(TOKEN); expect(b.pty.write).not.toHaveBeenCalled(); }); });