// The Billion chat (server/owner.js): what the owner types in the Billion tab // reaching Billion as [Owner via app], a typed answer to a question, Billion's // tell_owner and notify_owner as bubbles that follow their question's state, // Telegram messages in the same thread, the cap, and the token redacted. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { rmSync, writeFileSync, readFileSync, statSync, existsSync, readdirSync } from 'fs'; import { join } from 'path'; import { ownerSays, chatMessages, chatFilePath, chatPayload, notifyOwner, tellOwner, handleUpdate, dismissWaiting, waitingItems, APP_PREFIX, OWNER_PREFIX, CHAT_CAP, NOTIFY_WINDOW_MS, MAX_OWNER_CHARS, } from '../server/owner.js'; import { dropMessages } from '../server/messages.js'; import { sessions, CONFIG_DIR } from '../server/state.js'; const TOKEN = '123456:SECRET-token'; const ENV = { TELEGRAM_BOT_TOKEN: TOKEN, TELEGRAM_CHAT_ID: '42' }; let clock = 3e12; const now = () => (clock += 10 * NOTIFY_WINDOW_MS); let b; // Without the bracketed-paste markers each chunk is wrapped in. const typed = () => b.pty.write.mock.calls.map(c => c[0]).join("").replace(/\x1b\[20[01]~/g, ""); beforeEach(() => { vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ ok: true, result: { message_id: 7 } }) }))); rmSync(join(CONFIG_DIR, 'waiting.json'), { force: true }); rmSync(join(CONFIG_DIR, 'chat.json'), { force: true }); rmSync(join(CONFIG_DIR, 'chat-files'), { recursive: true, force: true }); b = { id: 'chat-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() }, }; sessions.set(b.id, b); }); afterEach(() => { vi.unstubAllGlobals(); sessions.delete(b.id); dropMessages(b.id); }); describe('the owner typing in the Billion tab', () => { it('reaches Billion as [Owner via app] and shows as the owner\'s bubble', async () => { const broadcast = vi.fn(); expect(await ownerSays(' How is the release going? ', { broadcast })).toMatchObject({ ok: true }); expect(typed()).toContain(`${APP_PREFIX} How is the release going?`); const [m] = chatMessages(); expect(m).toMatchObject({ from: 'owner', via: 'app', text: 'How is the release going?' }); expect(broadcast).toHaveBeenCalledWith({ type: 'chat-message', message: m }); }); it('refuses while Billion is not running, and keeps nothing, so the text stays in the box', async () => { b.exited = true; expect(await ownerSays('hello')).toEqual({ error: 'Billion is not running; start it, then send again.' }); expect(chatMessages()).toEqual([]); }); it('refuses empty text and text past the technical ceiling', async () => { expect((await ownerSays(' ')).error).toMatch(/empty/); expect((await ownerSays('x'.repeat(MAX_OWNER_CHARS + 1))).error).toMatch(/send it in parts/); expect(b.pty.write).not.toHaveBeenCalled(); }); it('delivers a long pasted message whole, newlines kept, and keeps it whole in the thread', async () => { const listing = Array.from({ length: 60 }, (_, i) => `Item ${i}: a product with a long description`.padEnd(150, '.')).join('\n'); expect(listing.length).toBeGreaterThan(4096); expect(await ownerSays(listing)).toMatchObject({ ok: true }); // Typed in small bracketed pastes, one after another, then one Enter: one turn. await vi.waitFor(() => expect(typed()).toBe(`${APP_PREFIX} ${listing}\r`), { timeout: 20000 }); expect(chatMessages()[0].text).toBe(listing); expect(readFileSync(join(CONFIG_DIR, 'chat.json'), 'utf8')).toContain(JSON.stringify(listing)); }); it('delivers a long answer whole with its line breaks', async () => { await notifyOwner('Which listing?', { env: {}, now: now(), queue: false }); const [q] = waitingItems(); const listing = Array.from({ length: 60 }, (_, i) => `Line ${i} of the pasted listing`).join('\n'); expect(await ownerSays(listing, { answers: q.id })).toMatchObject({ ok: true }); await vi.waitFor(() => expect(typed()).toBe(`${APP_PREFIX} Q1: ${listing} (re: "Which listing?")\r`), { timeout: 20000 }); expect(chatMessages().at(-1).text).toBe(listing); }); it('answers the question it names, as a typed answer: the question collapses and the words show as the owner\'s', async () => { const broadcast = vi.fn(); await notifyOwner('Merge #12?', { env: {}, now: now(), choices: ['yes', 'no'], recommended: 'yes', broadcast, queue: false }); const [q] = waitingItems(); expect(await ownerSays('yes, after CI', { answers: q.id, broadcast })).toMatchObject({ ok: true }); expect(typed()).toContain(`${APP_PREFIX} Q1: yes, after CI (re: "Merge #12?")`); const [question, answer] = chatMessages(); expect(question.q).toMatchObject({ n: 1, status: 'answered', answer: 'yes, after CI', answeredVia: 'app', choices: ['yes', 'no'], recommended: 'yes' }); expect(answer).toMatchObject({ from: 'owner', via: 'app', text: 'yes, after CI', re: 1 }); }); }); describe('Billion\'s side of the thread', () => { it('a question is a bubble carrying its choices, and follows it to dismissed', async () => { const broadcast = vi.fn(); await notifyOwner('Buy the domain?', { env: {}, now: now(), choices: ['yes', 'no'], urgency: 'blocking', broadcast }); const [m] = chatMessages(); expect(m).toMatchObject({ from: 'billion', text: 'Buy the domain?', q: { n: 1, urgency: 'blocking', status: 'open', choices: ['yes', 'no'] } }); dismissWaiting(m.q.id, broadcast); expect(chatMessages()[0].q.status).toBe('dismissed'); expect(broadcast).toHaveBeenCalledWith({ type: 'chat-message', message: expect.objectContaining({ q: expect.objectContaining({ status: 'dismissed' }) }) }); }); it('redacts the bot token from everything the thread keeps', async () => { await tellOwner(`the token is ${TOKEN}`, { env: ENV, now: now() }); expect(chatMessages().at(-1).text).toBe('the token is '); expect(JSON.stringify(chatPayload())).not.toContain(TOKEN); }); it('keeps the newest 500 and lets older ones fall off', async () => { const old = Array.from({ length: CHAT_CAP }, (_, i) => ({ id: `m${i}`, at: new Date(i).toISOString(), from: 'billion', text: `m${i}` })); writeFileSync(join(CONFIG_DIR, 'chat.json'), JSON.stringify(old)); await tellOwner('newest', { env: {}, now: now() }); const kept = chatMessages(); expect(kept).toHaveLength(CHAT_CAP); expect([kept[0].text, kept.at(-1).text]).toEqual(['m1', 'newest']); }); it('opens a thread that does not exist yet on the questions already asked', async () => { await notifyOwner('Asked before the chat', { env: {}, now: now(), queue: false }); rmSync(join(CONFIG_DIR, 'chat.json')); expect(chatMessages()).toEqual([expect.objectContaining({ from: 'billion', text: 'Asked before the chat', q: expect.objectContaining({ n: 1, status: 'open' }) })]); }); it('keeps an open question past the cap: its bubble is where it is answered', async () => { const openQ = { id: 'oq', at: new Date(0).toISOString(), from: 'billion', text: 'still open?', q: { id: 'x', n: 1, status: 'open' } }; const old = Array.from({ length: CHAT_CAP - 1 }, (_, i) => ({ id: `m${i}`, at: new Date(i + 1).toISOString(), from: 'billion', text: `m${i}` })); writeFileSync(join(CONFIG_DIR, 'chat.json'), JSON.stringify([openQ, ...old])); await tellOwner('newest', { env: {}, now: now() }); const kept = chatMessages(); expect(kept).toHaveLength(CHAT_CAP); expect(kept.map(m => m.id).slice(0, 2)).toEqual(['oq', 'm1']); }); it('reads a missing file as an empty thread, and moves a broken one aside rather than overwrite it', async () => { expect(chatMessages()).toEqual([]); writeFileSync(join(CONFIG_DIR, 'chat.json'), '{nope'); const error = vi.spyOn(console, 'error').mockImplementation(() => {}); expect(chatMessages()).toEqual([]); expect(readFileSync(join(CONFIG_DIR, 'chat.json.bad'), 'utf8')).toBe('{nope'); error.mockRestore(); }); it('redacts the token from a question too, in the Waiting list the strip shows', async () => { await notifyOwner(`use ${TOKEN}?`, { env: ENV, now: now(), queue: false }); expect(waitingItems()[0].text).toBe('use ?'); expect(chatMessages()[0].text).toBe('use ?'); }); }); describe('Telegram in the same thread', () => { it('the owner\'s Telegram message shows as theirs, via Telegram', async () => { const broadcast = vi.fn(); expect(await handleUpdate({ update_id: 1, message: { chat: { id: 42 }, text: 'ship it' } }, { env: ENV, broadcast })).toBe('delivered'); expect(typed()).toContain(`${OWNER_PREFIX} ship it`); expect(chatMessages()).toEqual([expect.objectContaining({ from: 'owner', via: 'telegram', text: 'ship it' })]); }); it('a Telegram reply to a question shows as a typed answer; a button tap only collapses it', async () => { await notifyOwner('Rename the repo?', { env: ENV, now: now(), choices: ['yes', 'no'], telegram: true }); const [q] = waitingItems(); expect(await handleUpdate({ update_id: 2, message: { chat: { id: 42 }, text: 'not yet', reply_to_message: { message_id: q.tgMessageId } } }, { env: ENV })).toBe('answered'); expect(chatMessages().map(m => [m.from, m.text])).toEqual([['billion', 'Rename the repo?'], ['owner', 'not yet']]); await notifyOwner('Tag v2?', { env: ENV, now: now(), choices: ['yes', 'no'], telegram: true }); const q2 = waitingItems().at(-1); await handleUpdate({ update_id: 3, callback_query: { id: 'c', data: `${q2.id}:0`, message: { chat: { id: 42 } } } }, { env: ENV }); const thread = chatMessages(); expect(thread).toHaveLength(3); expect(thread.at(-1).q).toMatchObject({ status: 'answered', answer: 'yes', answeredVia: 'telegram' }); }); }); // Files pasted, dropped or picked in the Billion tab (server/owner.js "The // chat's attachments"). describe('attachments in the Billion tab', () => { const file = (name, text = 'hello', type = 'text/plain') => ({ name, type, data: Buffer.from(text).toString('base64') }); const dir = () => join(CONFIG_DIR, 'chat-files'); it('saves them owner-only under chat-files/, and one turn carries their absolute paths', async () => { expect(await ownerSays('look at this', { files: [file('shot.png', 'png', 'image/png'), file('notes.pdf')] })).toMatchObject({ ok: true }); const [m] = chatMessages(); expect(m.files).toEqual([{ name: 'shot.png', size: 3, type: 'image/png' }, { name: 'notes.pdf', size: 5, type: 'text/plain' }]); const a = join(dir(), m.id, 'shot.png'); const b2 = join(dir(), m.id, 'notes.pdf'); await vi.waitFor(() => expect(typed()).toBe(`${APP_PREFIX} look at this (attached: ${a}, ${b2})\r`)); expect(readFileSync(a, 'utf8')).toBe('png'); if (process.platform !== 'win32') { expect(statSync(a).mode & 0o777).toBe(0o600); expect(statSync(join(dir(), m.id)).mode & 0o777).toBe(0o700); } // In chat.json, so a restart keeps the bubble's files. expect(JSON.parse(readFileSync(join(CONFIG_DIR, 'chat.json'), 'utf8'))[0].files).toEqual(m.files); }); it('sends files with no text', async () => { expect(await ownerSays('', { files: [file('a.txt')] })).toMatchObject({ ok: true }); const [m] = chatMessages(); expect(m.text).toBe(''); await vi.waitFor(() => expect(typed()).toBe(`${APP_PREFIX} (attached: ${join(dir(), m.id, 'a.txt')})\r`)); }); it('answers a question with them, the paths on the same turn', async () => { await notifyOwner('Which logo?', { env: {}, now: now(), queue: false }); const [q] = waitingItems(); expect(await ownerSays('this one', { answers: q.id, files: [file('logo.svg', '', 'image/svg+xml')] })).toMatchObject({ ok: true }); const answer = chatMessages().at(-1); expect(answer).toMatchObject({ re: 1, text: 'this one', files: [{ name: 'logo.svg', type: 'image/svg+xml' }] }); await vi.waitFor(() => expect(typed()).toBe(`${APP_PREFIX} Q1: this one (re: "Which logo?") (attached: ${join(dir(), answer.id, 'logo.svg')})\r`)); }); it('sanitises names and refuses what the job form refuses: too big, too many, unnamed, clashing', async () => { expect(await ownerSays('x', { files: [file('../../etc/passwd')] })).toMatchObject({ ok: true }); expect(chatMessages()[0].files[0].name).toBe('.._.._etc_passwd'); const big = { name: 'big.bin', data: Buffer.alloc(10 * 1024 * 1024 + 1).toString('base64') }; expect((await ownerSays('x', { files: [big] })).error).toMatch(/too large/); expect((await ownerSays('x', { files: Array.from({ length: 21 }, (_, i) => file(`f${i}.txt`)) })).error).toMatch(/At most 20/); expect((await ownerSays('x', { files: [file('...')] })).error).toMatch(/Unusable/); expect((await ownerSays('x', { files: [file('A.png'), file('a.png')] })).error).toMatch(/Two files/); expect((await ownerSays('x', { files: 'nope' })).error).toMatch(/not a list/); expect(chatMessages()).toHaveLength(1); }); it('keeps nothing on disk when Billion cannot take the turn', async () => { b.exited = true; expect((await ownerSays('x', { files: [file('a.txt')] })).error).toMatch(/not running/); expect(existsSync(dir()) ? readdirSync(dir()) : []).toEqual([]); }); it('finds a file only by a message that has it, never outside chat-files', async () => { await ownerSays('x', { files: [file('a.txt')] }); const [m] = chatMessages(); expect(chatFilePath(m.id, 'a.txt')).toBe(join(dir(), m.id, 'a.txt')); expect(chatFilePath(m.id, 'b.txt')).toBeNull(); expect(chatFilePath('nope', 'a.txt')).toBeNull(); // A hand-edited chat.json cannot point the route elsewhere. writeFileSync(join(CONFIG_DIR, 'chat.json'), JSON.stringify([{ id: '..', from: 'owner', text: '', files: [{ name: 'chat.json' }] }])); expect(chatFilePath('..', 'chat.json')).toBeNull(); }); it('deletes a message\'s files when the cap lets the message go', async () => { await ownerSays('x', { files: [file('a.txt')] }); const [m] = chatMessages(); const later = Array.from({ length: CHAT_CAP }, (_, i) => ({ id: `m${i}`, at: new Date(i).toISOString(), from: 'billion', text: `m${i}` })); writeFileSync(join(CONFIG_DIR, 'chat.json'), JSON.stringify([m, ...later])); await tellOwner('one more', { env: {}, now: now() }); expect(chatMessages().some(x => x.id === m.id)).toBe(false); expect(existsSync(join(dir(), m.id))).toBe(false); }); });