diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000000..d9cf67b0d0e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +on: + pull_request: + branches: [main] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run typecheck + - run: npm run format:check + - run: npm test diff --git a/.gitignore b/.gitignore index 73f68e7aa0d..4c3817c5185 100644 --- a/.gitignore +++ b/.gitignore @@ -20,5 +20,11 @@ logs/ .idea/ .vscode/ +# Test coverage +coverage/ + +# OMC state +.omc/ + agents-sdk-docs docs/learnfromproject.md diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 3bdd15fc1af..44e3b2c6e99 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -245,7 +245,9 @@ describe('config.ts', () => { it('should be an array of strings', () => { expect(Array.isArray(ALLOWED_CONTAINER_ENV_KEYS)).toBe(true); expect(ALLOWED_CONTAINER_ENV_KEYS.length).toBeGreaterThan(0); - expect(ALLOWED_CONTAINER_ENV_KEYS.every((k) => typeof k === 'string')).toBe(true); + expect( + ALLOWED_CONTAINER_ENV_KEYS.every((k) => typeof k === 'string'), + ).toBe(true); }); it('should include critical environment variables', () => { diff --git a/src/__tests__/db.test.ts b/src/__tests__/db.test.ts index 00c2f648dd6..5b9d8e0bc2c 100644 --- a/src/__tests__/db.test.ts +++ b/src/__tests__/db.test.ts @@ -1,4 +1,12 @@ -import { vi, describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { + vi, + describe, + it, + expect, + beforeAll, + afterAll, + beforeEach, +} from 'vitest'; import path from 'path'; import fs from 'fs'; @@ -8,7 +16,10 @@ const { TEST_STORE_DIR } = vi.hoisted(() => { process.env.TELEGRAM_BOT_TOKEN = 'test-token'; const _os = require('os') as typeof import('os'); const _path = require('path') as typeof import('path'); - const TEST_STORE_DIR = _path.join(_os.tmpdir(), `nanogemclaw-test-${Date.now()}`); + const TEST_STORE_DIR = _path.join( + _os.tmpdir(), + `nanogemclaw-test-${Date.now()}`, + ); return { TEST_STORE_DIR }; }); @@ -70,7 +81,7 @@ function resetDatabase(): void { fs.unlinkSync(dbPath); } // Remove WAL files - ['-wal', '-shm'].forEach(ext => { + ['-wal', '-shm'].forEach((ext) => { const walPath = dbPath + ext; if (fs.existsSync(walPath)) { fs.unlinkSync(walPath); @@ -97,941 +108,1076 @@ describe('db', () => { } }); -describe('Database Initialization', () => { - beforeEach(resetDatabase); + describe('Database Initialization', () => { + beforeEach(resetDatabase); - it('should create database file', () => { - const dbPath = path.join(TEST_STORE_DIR, 'messages.db'); - expect(fs.existsSync(dbPath)).toBe(true); - }); + it('should create database file', () => { + const dbPath = path.join(TEST_STORE_DIR, 'messages.db'); + expect(fs.existsSync(dbPath)).toBe(true); + }); - it('should initialize without errors', () => { - expect(() => initDatabase()).not.toThrow(); - }); + it('should initialize without errors', () => { + expect(() => initDatabase()).not.toThrow(); + }); - it('should close database without errors', () => { - expect(() => closeDatabase()).not.toThrow(); + it('should close database without errors', () => { + expect(() => closeDatabase()).not.toThrow(); + }); }); -}); -describe('Chat Metadata', () => { - beforeEach(resetDatabase); + describe('Chat Metadata', () => { + beforeEach(resetDatabase); - it('should store chat metadata with name', () => { - const chatJid = 'chat1@g.us'; - const timestamp = '2026-02-08T10:00:00Z'; - const name = 'Test Chat 1'; + it('should store chat metadata with name', () => { + const chatJid = 'chat1@g.us'; + const timestamp = '2026-02-08T10:00:00Z'; + const name = 'Test Chat 1'; - storeChatMetadata(chatJid, timestamp, name); + storeChatMetadata(chatJid, timestamp, name); - const chats = getAllChats(); - expect(chats).toHaveLength(1); - expect(chats[0].jid).toBe(chatJid); - expect(chats[0].name).toBe(name); - expect(chats[0].last_message_time).toBe(timestamp); - }); + const chats = getAllChats(); + expect(chats).toHaveLength(1); + expect(chats[0].jid).toBe(chatJid); + expect(chats[0].name).toBe(name); + expect(chats[0].last_message_time).toBe(timestamp); + }); - it('should store chat metadata without name', () => { - const chatJid = 'chat2@g.us'; - const timestamp = '2026-02-08T11:00:00Z'; + it('should store chat metadata without name', () => { + const chatJid = 'chat2@g.us'; + const timestamp = '2026-02-08T11:00:00Z'; - storeChatMetadata(chatJid, timestamp); + storeChatMetadata(chatJid, timestamp); - const chats = getAllChats(); - const chat = chats.find((c) => c.jid === chatJid); - expect(chat).toBeDefined(); - expect(chat?.name).toBe(chatJid); // Name defaults to jid - }); + const chats = getAllChats(); + const chat = chats.find((c) => c.jid === chatJid); + expect(chat).toBeDefined(); + expect(chat?.name).toBe(chatJid); // Name defaults to jid + }); - it('should update chat name', () => { - const chatJid = 'chat3@g.us'; - const initialTimestamp = '2026-02-08T12:00:00Z'; - const newName = 'Updated Chat Name'; + it('should update chat name', () => { + const chatJid = 'chat3@g.us'; + const initialTimestamp = '2026-02-08T12:00:00Z'; + const newName = 'Updated Chat Name'; - storeChatMetadata(chatJid, initialTimestamp); - updateChatName(chatJid, newName); + storeChatMetadata(chatJid, initialTimestamp); + updateChatName(chatJid, newName); - const chats = getAllChats(); - const chat = chats.find((c) => c.jid === chatJid); - expect(chat?.name).toBe(newName); - }); + const chats = getAllChats(); + const chat = chats.find((c) => c.jid === chatJid); + expect(chat?.name).toBe(newName); + }); - it('should preserve newer timestamp on conflict', () => { - const chatJid = 'chat4@g.us'; - const olderTimestamp = '2026-02-08T10:00:00Z'; - const newerTimestamp = '2026-02-08T12:00:00Z'; + it('should preserve newer timestamp on conflict', () => { + const chatJid = 'chat4@g.us'; + const olderTimestamp = '2026-02-08T10:00:00Z'; + const newerTimestamp = '2026-02-08T12:00:00Z'; - storeChatMetadata(chatJid, newerTimestamp); - storeChatMetadata(chatJid, olderTimestamp); // Should not overwrite + storeChatMetadata(chatJid, newerTimestamp); + storeChatMetadata(chatJid, olderTimestamp); // Should not overwrite - const chats = getAllChats(); - const chat = chats.find((c) => c.jid === chatJid); - expect(chat?.last_message_time).toBe(newerTimestamp); - }); + const chats = getAllChats(); + const chat = chats.find((c) => c.jid === chatJid); + expect(chat?.last_message_time).toBe(newerTimestamp); + }); - it('should return chats ordered by most recent activity', () => { - const chat1 = 'order_test_old@g.us'; - const chat2 = 'order_test_new@g.us'; + it('should return chats ordered by most recent activity', () => { + const chat1 = 'order_test_old@g.us'; + const chat2 = 'order_test_new@g.us'; - storeChatMetadata(chat1, '2026-02-08T10:00:00Z'); - storeChatMetadata(chat2, '2026-02-08T12:00:00Z'); + storeChatMetadata(chat1, '2026-02-08T10:00:00Z'); + storeChatMetadata(chat2, '2026-02-08T12:00:00Z'); - const chats = getAllChats(); - const chat1Index = chats.findIndex(c => c.jid === chat1); - const chat2Index = chats.findIndex(c => c.jid === chat2); - expect(chat2Index).toBeLessThan(chat1Index); // More recent chat should come first + const chats = getAllChats(); + const chat1Index = chats.findIndex((c) => c.jid === chat1); + const chat2Index = chats.findIndex((c) => c.jid === chat2); + expect(chat2Index).toBeLessThan(chat1Index); // More recent chat should come first + }); }); -}); -describe('Group Sync Tracking', () => { - beforeEach(resetDatabase); + describe('Group Sync Tracking', () => { + beforeEach(resetDatabase); - it('should return null when no sync has occurred', () => { - const lastSync = getLastGroupSync(); - expect(lastSync).toBeNull(); - }); + it('should return null when no sync has occurred', () => { + const lastSync = getLastGroupSync(); + expect(lastSync).toBeNull(); + }); - it('should record and retrieve group sync timestamp', () => { - setLastGroupSync(); - const lastSync = getLastGroupSync(); - expect(lastSync).toBeTruthy(); - expect(typeof lastSync).toBe('string'); - }); + it('should record and retrieve group sync timestamp', () => { + setLastGroupSync(); + const lastSync = getLastGroupSync(); + expect(lastSync).toBeTruthy(); + expect(typeof lastSync).toBe('string'); + }); - it('should update group sync timestamp', async () => { - setLastGroupSync(); - const firstSync = getLastGroupSync(); + it('should update group sync timestamp', async () => { + setLastGroupSync(); + const firstSync = getLastGroupSync(); - // Wait a bit and sync again - await new Promise(resolve => setTimeout(resolve, 10)); - setLastGroupSync(); - const secondSync = getLastGroupSync(); - expect(secondSync).not.toBe(firstSync); - expect(secondSync! > firstSync!).toBe(true); + // Wait a bit and sync again + await new Promise((resolve) => setTimeout(resolve, 10)); + setLastGroupSync(); + const secondSync = getLastGroupSync(); + expect(secondSync).not.toBe(firstSync); + expect(secondSync! > firstSync!).toBe(true); + }); }); -}); -describe('Message Storage', () => { - beforeEach(resetDatabase); - - it('should store a message', () => { - const msgId = 'msg1'; - const chatId = 'chat1@g.us'; - const senderId = 'user1@s.whatsapp.net'; - const senderName = 'User One'; - const content = 'Hello World'; - const timestamp = '2026-02-08T10:00:00Z'; - - // Create chat first (foreign key requirement) - storeChatMetadata(chatId, timestamp); - storeMessage(msgId, chatId, senderId, senderName, content, timestamp, false); - - const message = getMessageById(chatId, msgId); - expect(message).toBeDefined(); - expect(message?.content).toBe(content); - expect(message?.sender_name).toBe(senderName); - }); + describe('Message Storage', () => { + beforeEach(resetDatabase); - it('should replace message on duplicate id', () => { - const msgId = 'msg2'; - const chatId = 'chat1@g.us'; - const initialContent = 'Initial content'; - const updatedContent = 'Updated content'; + it('should store a message', () => { + const msgId = 'msg1'; + const chatId = 'chat1@g.us'; + const senderId = 'user1@s.whatsapp.net'; + const senderName = 'User One'; + const content = 'Hello World'; + const timestamp = '2026-02-08T10:00:00Z'; - // Create chat first (foreign key requirement) - storeChatMetadata(chatId, '2026-02-08T10:00:00Z'); - storeMessage(msgId, chatId, 'user@s.whatsapp.net', 'User', initialContent, '2026-02-08T10:00:00Z', false); - storeMessage(msgId, chatId, 'user@s.whatsapp.net', 'User', updatedContent, '2026-02-08T10:00:00Z', false); + // Create chat first (foreign key requirement) + storeChatMetadata(chatId, timestamp); + storeMessage( + msgId, + chatId, + senderId, + senderName, + content, + timestamp, + false, + ); - const message = getMessageById(chatId, msgId); - expect(message?.content).toBe(updatedContent); - }); + const message = getMessageById(chatId, msgId); + expect(message).toBeDefined(); + expect(message?.content).toBe(content); + expect(message?.sender_name).toBe(senderName); + }); - it('should retrieve new messages since timestamp', () => { - const chatId = 'chat2@g.us'; - const botPrefix = 'Bot'; + it('should replace message on duplicate id', () => { + const msgId = 'msg2'; + const chatId = 'chat1@g.us'; + const initialContent = 'Initial content'; + const updatedContent = 'Updated content'; - // Create chat first (foreign key requirement) - storeChatMetadata(chatId, '2026-02-08T12:00:00Z'); - storeMessage('msg3', chatId, 'user@s.whatsapp.net', 'User', 'User message', '2026-02-08T10:00:00Z', false); - storeMessage('msg4', chatId, 'user@s.whatsapp.net', 'User', 'Another message', '2026-02-08T11:00:00Z', false); - storeMessage('msg5', chatId, 'bot@s.whatsapp.net', 'Bot', 'Bot: Response', '2026-02-08T12:00:00Z', true); + // Create chat first (foreign key requirement) + storeChatMetadata(chatId, '2026-02-08T10:00:00Z'); + storeMessage( + msgId, + chatId, + 'user@s.whatsapp.net', + 'User', + initialContent, + '2026-02-08T10:00:00Z', + false, + ); + storeMessage( + msgId, + chatId, + 'user@s.whatsapp.net', + 'User', + updatedContent, + '2026-02-08T10:00:00Z', + false, + ); - const result = getNewMessages([chatId], '2026-02-08T09:00:00Z', botPrefix); + const message = getMessageById(chatId, msgId); + expect(message?.content).toBe(updatedContent); + }); - expect(result.messages).toHaveLength(2); // Bot message filtered out - expect(result.messages[0].content).toBe('User message'); - expect(result.newTimestamp).toBe('2026-02-08T11:00:00Z'); - }); + it('should retrieve new messages since timestamp', () => { + const chatId = 'chat2@g.us'; + const botPrefix = 'Bot'; - it('should filter out bot messages by prefix', () => { - const chatId = 'chat3@g.us'; - const botPrefix = 'GemBot'; + // Create chat first (foreign key requirement) + storeChatMetadata(chatId, '2026-02-08T12:00:00Z'); + storeMessage( + 'msg3', + chatId, + 'user@s.whatsapp.net', + 'User', + 'User message', + '2026-02-08T10:00:00Z', + false, + ); + storeMessage( + 'msg4', + chatId, + 'user@s.whatsapp.net', + 'User', + 'Another message', + '2026-02-08T11:00:00Z', + false, + ); + storeMessage( + 'msg5', + chatId, + 'bot@s.whatsapp.net', + 'Bot', + 'Bot: Response', + '2026-02-08T12:00:00Z', + true, + ); - // Create chat first (foreign key requirement) - storeChatMetadata(chatId, '2026-02-08T11:00:00Z'); - storeMessage('msg6', chatId, 'user@s.whatsapp.net', 'User', 'Hello', '2026-02-08T10:00:00Z', false); - storeMessage('msg7', chatId, 'bot@s.whatsapp.net', 'GemBot', 'GemBot: Hi', '2026-02-08T11:00:00Z', true); + const result = getNewMessages( + [chatId], + '2026-02-08T09:00:00Z', + botPrefix, + ); - const messages = getMessagesSince(chatId, '2026-02-08T09:00:00Z', botPrefix); + expect(result.messages).toHaveLength(2); // Bot message filtered out + expect(result.messages[0].content).toBe('User message'); + expect(result.newTimestamp).toBe('2026-02-08T11:00:00Z'); + }); - expect(messages).toHaveLength(1); - expect(messages[0].content).toBe('Hello'); - }); + it('should filter out bot messages by prefix', () => { + const chatId = 'chat3@g.us'; + const botPrefix = 'GemBot'; - it('should return messages ordered by timestamp', () => { - const chatId = 'chat4@g.us'; + // Create chat first (foreign key requirement) + storeChatMetadata(chatId, '2026-02-08T11:00:00Z'); + storeMessage( + 'msg6', + chatId, + 'user@s.whatsapp.net', + 'User', + 'Hello', + '2026-02-08T10:00:00Z', + false, + ); + storeMessage( + 'msg7', + chatId, + 'bot@s.whatsapp.net', + 'GemBot', + 'GemBot: Hi', + '2026-02-08T11:00:00Z', + true, + ); - // Create chat first (foreign key requirement) - storeChatMetadata(chatId, '2026-02-08T12:00:00Z'); - storeMessage('msg8', chatId, 'user@s.whatsapp.net', 'User', 'Third', '2026-02-08T12:00:00Z', false); - storeMessage('msg9', chatId, 'user@s.whatsapp.net', 'User', 'First', '2026-02-08T10:00:00Z', false); - storeMessage('msg10', chatId, 'user@s.whatsapp.net', 'User', 'Second', '2026-02-08T11:00:00Z', false); + const messages = getMessagesSince( + chatId, + '2026-02-08T09:00:00Z', + botPrefix, + ); - const messages = getMessagesSince(chatId, '2026-02-08T09:00:00Z', ''); + expect(messages).toHaveLength(1); + expect(messages[0].content).toBe('Hello'); + }); - expect(messages).toHaveLength(3); - expect(messages[0].content).toBe('First'); - expect(messages[2].content).toBe('Third'); - }); + it('should return messages ordered by timestamp', () => { + const chatId = 'chat4@g.us'; - it('should return empty array for empty jids', () => { - const result = getNewMessages([], '2026-02-08T10:00:00Z', 'Bot'); - expect(result.messages).toHaveLength(0); - expect(result.newTimestamp).toBe('2026-02-08T10:00:00Z'); - }); + // Create chat first (foreign key requirement) + storeChatMetadata(chatId, '2026-02-08T12:00:00Z'); + storeMessage( + 'msg8', + chatId, + 'user@s.whatsapp.net', + 'User', + 'Third', + '2026-02-08T12:00:00Z', + false, + ); + storeMessage( + 'msg9', + chatId, + 'user@s.whatsapp.net', + 'User', + 'First', + '2026-02-08T10:00:00Z', + false, + ); + storeMessage( + 'msg10', + chatId, + 'user@s.whatsapp.net', + 'User', + 'Second', + '2026-02-08T11:00:00Z', + false, + ); - it('should return undefined for non-existent message', () => { - const message = getMessageById('nonexistent@g.us', 'nonexistent'); - expect(message).toBeUndefined(); - }); -}); + const messages = getMessagesSince(chatId, '2026-02-08T09:00:00Z', ''); -describe('Scheduled Tasks', () => { - beforeEach(resetDatabase); - - it('should create a scheduled task', () => { - const task = { - id: 'task1', - group_folder: 'group1', - chat_jid: 'chat1@g.us', - prompt: 'Daily summary', - schedule_type: 'cron' as const, - schedule_value: '0 9 * * *', - context_mode: 'group' as const, - next_run: '2026-02-09T09:00:00Z', - status: 'active' as const, - created_at: '2026-02-08T10:00:00Z', - }; - - createTask(task); - - const retrieved = getTaskById('task1'); - expect(retrieved).toBeDefined(); - expect(retrieved?.prompt).toBe('Daily summary'); - expect(retrieved?.context_mode).toBe('group'); - }); + expect(messages).toHaveLength(3); + expect(messages[0].content).toBe('First'); + expect(messages[2].content).toBe('Third'); + }); - it('should create task with isolated context mode', () => { - const task = { - id: 'task2', - group_folder: 'group1', - chat_jid: 'chat1@g.us', - prompt: 'Test', - schedule_type: 'once' as const, - schedule_value: '2026-02-09T10:00:00Z', - context_mode: 'isolated' as const, - next_run: '2026-02-09T10:00:00Z', - status: 'active' as const, - created_at: '2026-02-08T10:00:00Z', - }; - - createTask(task); - - const retrieved = getTaskById('task2'); - expect(retrieved?.context_mode).toBe('isolated'); - }); + it('should return empty array for empty jids', () => { + const result = getNewMessages([], '2026-02-08T10:00:00Z', 'Bot'); + expect(result.messages).toHaveLength(0); + expect(result.newTimestamp).toBe('2026-02-08T10:00:00Z'); + }); - it('should retrieve tasks for a group', () => { - const task3 = { - id: 'task3', - group_folder: 'group2', - chat_jid: 'chat2@g.us', - prompt: 'Group2 task', - schedule_type: 'interval' as const, - schedule_value: '3600000', - context_mode: 'isolated' as const, - next_run: '2026-02-08T11:00:00Z', - status: 'active' as const, - created_at: '2026-02-08T10:00:00Z', - }; - - createTask(task3); - - const tasks = getTasksForGroup('group2'); - expect(tasks).toHaveLength(1); - expect(tasks[0].id).toBe('task3'); + it('should return undefined for non-existent message', () => { + const message = getMessageById('nonexistent@g.us', 'nonexistent'); + expect(message).toBeUndefined(); + }); }); - it('should retrieve all tasks', () => { - // Create some tasks first - createTask({ - id: 'task_all_1', - group_folder: 'group1', - chat_jid: 'chat1@g.us', - prompt: 'Task 1', - schedule_type: 'once' as const, - schedule_value: '2026-02-09T10:00:00Z', - context_mode: 'isolated' as const, - next_run: '2026-02-09T10:00:00Z', - status: 'active' as const, - created_at: '2026-02-08T10:00:00Z', - }); - createTask({ - id: 'task_all_2', - group_folder: 'group1', - chat_jid: 'chat1@g.us', - prompt: 'Task 2', - schedule_type: 'once' as const, - schedule_value: '2026-02-09T10:00:00Z', - context_mode: 'isolated' as const, - next_run: '2026-02-09T10:00:00Z', - status: 'active' as const, - created_at: '2026-02-08T10:00:00Z', - }); - createTask({ - id: 'task_all_3', - group_folder: 'group2', - chat_jid: 'chat2@g.us', - prompt: 'Task 3', - schedule_type: 'once' as const, - schedule_value: '2026-02-09T10:00:00Z', - context_mode: 'isolated' as const, - next_run: '2026-02-09T10:00:00Z', - status: 'active' as const, - created_at: '2026-02-08T10:00:00Z', + describe('Scheduled Tasks', () => { + beforeEach(resetDatabase); + + it('should create a scheduled task', () => { + const task = { + id: 'task1', + group_folder: 'group1', + chat_jid: 'chat1@g.us', + prompt: 'Daily summary', + schedule_type: 'cron' as const, + schedule_value: '0 9 * * *', + context_mode: 'group' as const, + next_run: '2026-02-09T09:00:00Z', + status: 'active' as const, + created_at: '2026-02-08T10:00:00Z', + }; + + createTask(task); + + const retrieved = getTaskById('task1'); + expect(retrieved).toBeDefined(); + expect(retrieved?.prompt).toBe('Daily summary'); + expect(retrieved?.context_mode).toBe('group'); }); - const tasks = getAllTasks(); - expect(tasks.length).toBeGreaterThanOrEqual(3); - }); - - it('should update task fields', () => { - const taskId = 'task4'; - createTask({ - id: taskId, - group_folder: 'group1', - chat_jid: 'chat1@g.us', - prompt: 'Original prompt', - schedule_type: 'cron' as const, - schedule_value: '0 9 * * *', - context_mode: 'isolated' as const, - next_run: '2026-02-09T09:00:00Z', - status: 'active' as const, - created_at: '2026-02-08T10:00:00Z', + it('should create task with isolated context mode', () => { + const task = { + id: 'task2', + group_folder: 'group1', + chat_jid: 'chat1@g.us', + prompt: 'Test', + schedule_type: 'once' as const, + schedule_value: '2026-02-09T10:00:00Z', + context_mode: 'isolated' as const, + next_run: '2026-02-09T10:00:00Z', + status: 'active' as const, + created_at: '2026-02-08T10:00:00Z', + }; + + createTask(task); + + const retrieved = getTaskById('task2'); + expect(retrieved?.context_mode).toBe('isolated'); }); - updateTask(taskId, { - prompt: 'Updated prompt', - status: 'paused', + it('should retrieve tasks for a group', () => { + const task3 = { + id: 'task3', + group_folder: 'group2', + chat_jid: 'chat2@g.us', + prompt: 'Group2 task', + schedule_type: 'interval' as const, + schedule_value: '3600000', + context_mode: 'isolated' as const, + next_run: '2026-02-08T11:00:00Z', + status: 'active' as const, + created_at: '2026-02-08T10:00:00Z', + }; + + createTask(task3); + + const tasks = getTasksForGroup('group2'); + expect(tasks).toHaveLength(1); + expect(tasks[0].id).toBe('task3'); }); - const retrieved = getTaskById(taskId); - expect(retrieved?.prompt).toBe('Updated prompt'); - expect(retrieved?.status).toBe('paused'); - }); + it('should retrieve all tasks', () => { + // Create some tasks first + createTask({ + id: 'task_all_1', + group_folder: 'group1', + chat_jid: 'chat1@g.us', + prompt: 'Task 1', + schedule_type: 'once' as const, + schedule_value: '2026-02-09T10:00:00Z', + context_mode: 'isolated' as const, + next_run: '2026-02-09T10:00:00Z', + status: 'active' as const, + created_at: '2026-02-08T10:00:00Z', + }); + createTask({ + id: 'task_all_2', + group_folder: 'group1', + chat_jid: 'chat1@g.us', + prompt: 'Task 2', + schedule_type: 'once' as const, + schedule_value: '2026-02-09T10:00:00Z', + context_mode: 'isolated' as const, + next_run: '2026-02-09T10:00:00Z', + status: 'active' as const, + created_at: '2026-02-08T10:00:00Z', + }); + createTask({ + id: 'task_all_3', + group_folder: 'group2', + chat_jid: 'chat2@g.us', + prompt: 'Task 3', + schedule_type: 'once' as const, + schedule_value: '2026-02-09T10:00:00Z', + context_mode: 'isolated' as const, + next_run: '2026-02-09T10:00:00Z', + status: 'active' as const, + created_at: '2026-02-08T10:00:00Z', + }); - it('should not update when no fields provided', () => { - const taskId = 'task5'; - createTask({ - id: taskId, - group_folder: 'group1', - chat_jid: 'chat1@g.us', - prompt: 'Original', - schedule_type: 'once' as const, - schedule_value: '2026-02-09T10:00:00Z', - context_mode: 'isolated' as const, - next_run: '2026-02-09T10:00:00Z', - status: 'active' as const, - created_at: '2026-02-08T10:00:00Z', + const tasks = getAllTasks(); + expect(tasks.length).toBeGreaterThanOrEqual(3); }); - updateTask(taskId, {}); - - const retrieved = getTaskById(taskId); - expect(retrieved?.prompt).toBe('Original'); - }); + it('should update task fields', () => { + const taskId = 'task4'; + createTask({ + id: taskId, + group_folder: 'group1', + chat_jid: 'chat1@g.us', + prompt: 'Original prompt', + schedule_type: 'cron' as const, + schedule_value: '0 9 * * *', + context_mode: 'isolated' as const, + next_run: '2026-02-09T09:00:00Z', + status: 'active' as const, + created_at: '2026-02-08T10:00:00Z', + }); - it('should delete task and its run logs', () => { - const taskId = 'task6'; - createTask({ - id: taskId, - group_folder: 'group1', - chat_jid: 'chat1@g.us', - prompt: 'To be deleted', - schedule_type: 'once' as const, - schedule_value: '2026-02-09T10:00:00Z', - context_mode: 'isolated' as const, - next_run: '2026-02-09T10:00:00Z', - status: 'active' as const, - created_at: '2026-02-08T10:00:00Z', - }); + updateTask(taskId, { + prompt: 'Updated prompt', + status: 'paused', + }); - logTaskRun({ - task_id: taskId, - run_at: '2026-02-08T10:00:00Z', - duration_ms: 1000, - status: 'success', - result: 'Done', - error: null, + const retrieved = getTaskById(taskId); + expect(retrieved?.prompt).toBe('Updated prompt'); + expect(retrieved?.status).toBe('paused'); }); - deleteTask(taskId); + it('should not update when no fields provided', () => { + const taskId = 'task5'; + createTask({ + id: taskId, + group_folder: 'group1', + chat_jid: 'chat1@g.us', + prompt: 'Original', + schedule_type: 'once' as const, + schedule_value: '2026-02-09T10:00:00Z', + context_mode: 'isolated' as const, + next_run: '2026-02-09T10:00:00Z', + status: 'active' as const, + created_at: '2026-02-08T10:00:00Z', + }); - expect(getTaskById(taskId)).toBeUndefined(); - expect(getTaskRunLogs(taskId)).toHaveLength(0); - }); + updateTask(taskId, {}); - it('should retrieve due tasks', () => { - const now = new Date().toISOString(); - const pastTime = new Date(Date.now() - 3600000).toISOString(); - const futureTime = new Date(Date.now() + 3600000).toISOString(); - - createTask({ - id: 'task7', - group_folder: 'group1', - chat_jid: 'chat1@g.us', - prompt: 'Due task', - schedule_type: 'once' as const, - schedule_value: pastTime, - context_mode: 'isolated' as const, - next_run: pastTime, - status: 'active' as const, - created_at: now, + const retrieved = getTaskById(taskId); + expect(retrieved?.prompt).toBe('Original'); }); - createTask({ - id: 'task8', - group_folder: 'group1', - chat_jid: 'chat1@g.us', - prompt: 'Future task', - schedule_type: 'once' as const, - schedule_value: futureTime, - context_mode: 'isolated' as const, - next_run: futureTime, - status: 'active' as const, - created_at: now, - }); + it('should delete task and its run logs', () => { + const taskId = 'task6'; + createTask({ + id: taskId, + group_folder: 'group1', + chat_jid: 'chat1@g.us', + prompt: 'To be deleted', + schedule_type: 'once' as const, + schedule_value: '2026-02-09T10:00:00Z', + context_mode: 'isolated' as const, + next_run: '2026-02-09T10:00:00Z', + status: 'active' as const, + created_at: '2026-02-08T10:00:00Z', + }); - const dueTasks = getDueTasks(); - expect(dueTasks.some((t) => t.id === 'task7')).toBe(true); - expect(dueTasks.some((t) => t.id === 'task8')).toBe(false); - }); + logTaskRun({ + task_id: taskId, + run_at: '2026-02-08T10:00:00Z', + duration_ms: 1000, + status: 'success', + result: 'Done', + error: null, + }); - it('should update task after run', () => { - const taskId = 'task9'; - const nextRun = '2026-02-09T10:00:00Z'; - - createTask({ - id: taskId, - group_folder: 'group1', - chat_jid: 'chat1@g.us', - prompt: 'Test', - schedule_type: 'interval' as const, - schedule_value: '3600000', - context_mode: 'isolated' as const, - next_run: '2026-02-08T10:00:00Z', - status: 'active' as const, - created_at: '2026-02-08T10:00:00Z', + deleteTask(taskId); + + expect(getTaskById(taskId)).toBeUndefined(); + expect(getTaskRunLogs(taskId)).toHaveLength(0); }); - updateTaskAfterRun(taskId, nextRun, 'Success'); + it('should retrieve due tasks', () => { + const now = new Date().toISOString(); + const pastTime = new Date(Date.now() - 3600000).toISOString(); + const futureTime = new Date(Date.now() + 3600000).toISOString(); + + createTask({ + id: 'task7', + group_folder: 'group1', + chat_jid: 'chat1@g.us', + prompt: 'Due task', + schedule_type: 'once' as const, + schedule_value: pastTime, + context_mode: 'isolated' as const, + next_run: pastTime, + status: 'active' as const, + created_at: now, + }); - const retrieved = getTaskById(taskId); - expect(retrieved?.next_run).toBe(nextRun); - expect(retrieved?.last_result).toBe('Success'); - expect(retrieved?.status).toBe('active'); - }); + createTask({ + id: 'task8', + group_folder: 'group1', + chat_jid: 'chat1@g.us', + prompt: 'Future task', + schedule_type: 'once' as const, + schedule_value: futureTime, + context_mode: 'isolated' as const, + next_run: futureTime, + status: 'active' as const, + created_at: now, + }); - it('should mark task completed when next_run is null', () => { - const taskId = 'task10'; - - createTask({ - id: taskId, - group_folder: 'group1', - chat_jid: 'chat1@g.us', - prompt: 'One-time task', - schedule_type: 'once' as const, - schedule_value: '2026-02-08T10:00:00Z', - context_mode: 'isolated' as const, - next_run: '2026-02-08T10:00:00Z', - status: 'active' as const, - created_at: '2026-02-08T10:00:00Z', + const dueTasks = getDueTasks(); + expect(dueTasks.some((t) => t.id === 'task7')).toBe(true); + expect(dueTasks.some((t) => t.id === 'task8')).toBe(false); }); - updateTaskAfterRun(taskId, null, 'Done'); + it('should update task after run', () => { + const taskId = 'task9'; + const nextRun = '2026-02-09T10:00:00Z'; + + createTask({ + id: taskId, + group_folder: 'group1', + chat_jid: 'chat1@g.us', + prompt: 'Test', + schedule_type: 'interval' as const, + schedule_value: '3600000', + context_mode: 'isolated' as const, + next_run: '2026-02-08T10:00:00Z', + status: 'active' as const, + created_at: '2026-02-08T10:00:00Z', + }); - const retrieved = getTaskById(taskId); - expect(retrieved?.status).toBe('completed'); - }); -}); + updateTaskAfterRun(taskId, nextRun, 'Success'); -describe('Task Run Logs', () => { - beforeEach(resetDatabase); - - it('should log task run', () => { - const taskId = 'task_log1'; - - createTask({ - id: taskId, - group_folder: 'group1', - chat_jid: 'chat1@g.us', - prompt: 'Test', - schedule_type: 'once' as const, - schedule_value: '2026-02-08T10:00:00Z', - context_mode: 'isolated' as const, - next_run: '2026-02-08T10:00:00Z', - status: 'active' as const, - created_at: '2026-02-08T10:00:00Z', + const retrieved = getTaskById(taskId); + expect(retrieved?.next_run).toBe(nextRun); + expect(retrieved?.last_result).toBe('Success'); + expect(retrieved?.status).toBe('active'); }); - logTaskRun({ - task_id: taskId, - run_at: '2026-02-08T10:00:00Z', - duration_ms: 1500, - status: 'success', - result: 'Task completed successfully', - error: null, - }); + it('should mark task completed when next_run is null', () => { + const taskId = 'task10'; + + createTask({ + id: taskId, + group_folder: 'group1', + chat_jid: 'chat1@g.us', + prompt: 'One-time task', + schedule_type: 'once' as const, + schedule_value: '2026-02-08T10:00:00Z', + context_mode: 'isolated' as const, + next_run: '2026-02-08T10:00:00Z', + status: 'active' as const, + created_at: '2026-02-08T10:00:00Z', + }); - const logs = getTaskRunLogs(taskId); - expect(logs).toHaveLength(1); - expect(logs[0].status).toBe('success'); - expect(logs[0].duration_ms).toBe(1500); - }); + updateTaskAfterRun(taskId, null, 'Done'); - it('should log task error', () => { - const taskId = 'task_log2'; - - createTask({ - id: taskId, - group_folder: 'group1', - chat_jid: 'chat1@g.us', - prompt: 'Test', - schedule_type: 'once' as const, - schedule_value: '2026-02-08T10:00:00Z', - context_mode: 'isolated' as const, - next_run: '2026-02-08T10:00:00Z', - status: 'active' as const, - created_at: '2026-02-08T10:00:00Z', + const retrieved = getTaskById(taskId); + expect(retrieved?.status).toBe('completed'); }); + }); - logTaskRun({ - task_id: taskId, - run_at: '2026-02-08T10:00:00Z', - duration_ms: 500, - status: 'error', - result: null, - error: 'Task failed due to timeout', - }); + describe('Task Run Logs', () => { + beforeEach(resetDatabase); - const logs = getTaskRunLogs(taskId); - expect(logs).toHaveLength(1); - expect(logs[0].status).toBe('error'); - expect(logs[0].error).toBe('Task failed due to timeout'); - }); + it('should log task run', () => { + const taskId = 'task_log1'; - it('should limit task run logs', () => { - const taskId = 'task_log3'; - - createTask({ - id: taskId, - group_folder: 'group1', - chat_jid: 'chat1@g.us', - prompt: 'Test', - schedule_type: 'interval' as const, - schedule_value: '3600000', - context_mode: 'isolated' as const, - next_run: '2026-02-08T10:00:00Z', - status: 'active' as const, - created_at: '2026-02-08T10:00:00Z', - }); + createTask({ + id: taskId, + group_folder: 'group1', + chat_jid: 'chat1@g.us', + prompt: 'Test', + schedule_type: 'once' as const, + schedule_value: '2026-02-08T10:00:00Z', + context_mode: 'isolated' as const, + next_run: '2026-02-08T10:00:00Z', + status: 'active' as const, + created_at: '2026-02-08T10:00:00Z', + }); - // Log 15 runs - for (let i = 0; i < 15; i++) { logTaskRun({ task_id: taskId, - run_at: `2026-02-08T${String(10 + i).padStart(2, '0')}:00:00Z`, - duration_ms: 1000, + run_at: '2026-02-08T10:00:00Z', + duration_ms: 1500, status: 'success', - result: `Run ${i}`, + result: 'Task completed successfully', error: null, }); - } - const logs = getTaskRunLogs(taskId, 5); - expect(logs).toHaveLength(5); - }); - - it('should return logs ordered by most recent first', () => { - const taskId = 'task_log4'; - - createTask({ - id: taskId, - group_folder: 'group1', - chat_jid: 'chat1@g.us', - prompt: 'Test', - schedule_type: 'interval' as const, - schedule_value: '3600000', - context_mode: 'isolated' as const, - next_run: '2026-02-08T10:00:00Z', - status: 'active' as const, - created_at: '2026-02-08T10:00:00Z', + const logs = getTaskRunLogs(taskId); + expect(logs).toHaveLength(1); + expect(logs[0].status).toBe('success'); + expect(logs[0].duration_ms).toBe(1500); }); - logTaskRun({ - task_id: taskId, - run_at: '2026-02-08T10:00:00Z', - duration_ms: 1000, - status: 'success', - result: 'First', - error: null, - }); + it('should log task error', () => { + const taskId = 'task_log2'; + + createTask({ + id: taskId, + group_folder: 'group1', + chat_jid: 'chat1@g.us', + prompt: 'Test', + schedule_type: 'once' as const, + schedule_value: '2026-02-08T10:00:00Z', + context_mode: 'isolated' as const, + next_run: '2026-02-08T10:00:00Z', + status: 'active' as const, + created_at: '2026-02-08T10:00:00Z', + }); - logTaskRun({ - task_id: taskId, - run_at: '2026-02-08T12:00:00Z', - duration_ms: 1000, - status: 'success', - result: 'Latest', - error: null, + logTaskRun({ + task_id: taskId, + run_at: '2026-02-08T10:00:00Z', + duration_ms: 500, + status: 'error', + result: null, + error: 'Task failed due to timeout', + }); + + const logs = getTaskRunLogs(taskId); + expect(logs).toHaveLength(1); + expect(logs[0].status).toBe('error'); + expect(logs[0].error).toBe('Task failed due to timeout'); }); - const logs = getTaskRunLogs(taskId); - expect(logs[0].result).toBe('Latest'); - }); -}); + it('should limit task run logs', () => { + const taskId = 'task_log3'; + + createTask({ + id: taskId, + group_folder: 'group1', + chat_jid: 'chat1@g.us', + prompt: 'Test', + schedule_type: 'interval' as const, + schedule_value: '3600000', + context_mode: 'isolated' as const, + next_run: '2026-02-08T10:00:00Z', + status: 'active' as const, + created_at: '2026-02-08T10:00:00Z', + }); -describe('Usage Statistics', () => { - beforeEach(resetDatabase); - - it('should log usage entry', () => { - logUsage({ - group_folder: 'group1', - timestamp: '2026-02-08T10:00:00Z', - prompt_tokens: 100, - response_tokens: 200, - duration_ms: 1500, - model: 'gemini-2.0-flash-exp', - is_scheduled_task: false, + // Log 15 runs + for (let i = 0; i < 15; i++) { + logTaskRun({ + task_id: taskId, + run_at: `2026-02-08T${String(10 + i).padStart(2, '0')}:00:00Z`, + duration_ms: 1000, + status: 'success', + result: `Run ${i}`, + error: null, + }); + } + + const logs = getTaskRunLogs(taskId, 5); + expect(logs).toHaveLength(5); }); - const recent = getRecentUsage(1); - expect(recent).toHaveLength(1); - expect(recent[0].group_folder).toBe('group1'); - expect(recent[0].prompt_tokens).toBe(100); - }); + it('should return logs ordered by most recent first', () => { + const taskId = 'task_log4'; + + createTask({ + id: taskId, + group_folder: 'group1', + chat_jid: 'chat1@g.us', + prompt: 'Test', + schedule_type: 'interval' as const, + schedule_value: '3600000', + context_mode: 'isolated' as const, + next_run: '2026-02-08T10:00:00Z', + status: 'active' as const, + created_at: '2026-02-08T10:00:00Z', + }); - it('should log usage without optional fields', () => { - logUsage({ - group_folder: 'group2', - timestamp: '2026-02-08T11:00:00Z', - duration_ms: 1000, - }); + logTaskRun({ + task_id: taskId, + run_at: '2026-02-08T10:00:00Z', + duration_ms: 1000, + status: 'success', + result: 'First', + error: null, + }); - const recent = getRecentUsage(1); - expect(recent[0].group_folder).toBe('group2'); - }); + logTaskRun({ + task_id: taskId, + run_at: '2026-02-08T12:00:00Z', + duration_ms: 1000, + status: 'success', + result: 'Latest', + error: null, + }); - it('should get usage stats for all groups', () => { - logUsage({ - group_folder: 'group1', - timestamp: '2026-02-08T10:00:00Z', - prompt_tokens: 100, - response_tokens: 200, - duration_ms: 1500, + const logs = getTaskRunLogs(taskId); + expect(logs[0].result).toBe('Latest'); }); + }); + + describe('Usage Statistics', () => { + beforeEach(resetDatabase); + + it('should log usage entry', () => { + logUsage({ + group_folder: 'group1', + timestamp: '2026-02-08T10:00:00Z', + prompt_tokens: 100, + response_tokens: 200, + duration_ms: 1500, + model: 'gemini-2.0-flash-exp', + is_scheduled_task: false, + }); - logUsage({ - group_folder: 'group2', - timestamp: '2026-02-08T11:00:00Z', - prompt_tokens: 150, - response_tokens: 250, - duration_ms: 2000, + const recent = getRecentUsage(1); + expect(recent).toHaveLength(1); + expect(recent[0].group_folder).toBe('group1'); + expect(recent[0].prompt_tokens).toBe(100); }); - const stats = getUsageStats(); - expect(stats.total_requests).toBeGreaterThanOrEqual(2); - expect(stats.total_prompt_tokens).toBeGreaterThanOrEqual(250); - }); + it('should log usage without optional fields', () => { + logUsage({ + group_folder: 'group2', + timestamp: '2026-02-08T11:00:00Z', + duration_ms: 1000, + }); - it('should get usage stats for specific group', () => { - logUsage({ - group_folder: 'group3', - timestamp: '2026-02-08T12:00:00Z', - prompt_tokens: 50, - response_tokens: 100, - duration_ms: 800, + const recent = getRecentUsage(1); + expect(recent[0].group_folder).toBe('group2'); }); - const stats = getUsageStats('group3'); - expect(stats.total_requests).toBeGreaterThanOrEqual(1); - expect(stats.total_prompt_tokens).toBeGreaterThanOrEqual(50); - }); + it('should get usage stats for all groups', () => { + logUsage({ + group_folder: 'group1', + timestamp: '2026-02-08T10:00:00Z', + prompt_tokens: 100, + response_tokens: 200, + duration_ms: 1500, + }); - it('should get usage stats since timestamp', () => { - const sinceTime = '2026-02-08T11:30:00Z'; + logUsage({ + group_folder: 'group2', + timestamp: '2026-02-08T11:00:00Z', + prompt_tokens: 150, + response_tokens: 250, + duration_ms: 2000, + }); - logUsage({ - group_folder: 'group4', - timestamp: '2026-02-08T11:00:00Z', - duration_ms: 1000, + const stats = getUsageStats(); + expect(stats.total_requests).toBeGreaterThanOrEqual(2); + expect(stats.total_prompt_tokens).toBeGreaterThanOrEqual(250); }); - logUsage({ - group_folder: 'group4', - timestamp: '2026-02-08T12:00:00Z', - duration_ms: 1000, + it('should get usage stats for specific group', () => { + logUsage({ + group_folder: 'group3', + timestamp: '2026-02-08T12:00:00Z', + prompt_tokens: 50, + response_tokens: 100, + duration_ms: 800, + }); + + const stats = getUsageStats('group3'); + expect(stats.total_requests).toBeGreaterThanOrEqual(1); + expect(stats.total_prompt_tokens).toBeGreaterThanOrEqual(50); }); - const stats = getUsageStats('group4', sinceTime); - expect(stats.total_requests).toBe(1); - }); + it('should get usage stats since timestamp', () => { + const sinceTime = '2026-02-08T11:30:00Z'; - it('should get recent usage with limit', () => { - for (let i = 0; i < 5; i++) { logUsage({ - group_folder: `group${i}`, - timestamp: `2026-02-08T${String(10 + i).padStart(2, '0')}:00:00Z`, + group_folder: 'group4', + timestamp: '2026-02-08T11:00:00Z', duration_ms: 1000, }); - } - const recent = getRecentUsage(3); - expect(recent).toHaveLength(3); - }); + logUsage({ + group_folder: 'group4', + timestamp: '2026-02-08T12:00:00Z', + duration_ms: 1000, + }); - it('should return recent usage ordered by timestamp desc', () => { - logUsage({ - group_folder: 'group_a', - timestamp: '2026-02-08T10:00:00Z', - duration_ms: 1000, + const stats = getUsageStats('group4', sinceTime); + expect(stats.total_requests).toBe(1); }); - logUsage({ - group_folder: 'group_b', - timestamp: '2026-02-08T12:00:00Z', - duration_ms: 1000, + it('should get recent usage with limit', () => { + for (let i = 0; i < 5; i++) { + logUsage({ + group_folder: `group${i}`, + timestamp: `2026-02-08T${String(10 + i).padStart(2, '0')}:00:00Z`, + duration_ms: 1000, + }); + } + + const recent = getRecentUsage(3); + expect(recent).toHaveLength(3); }); - const recent = getRecentUsage(2); - expect(recent[0].timestamp > recent[1].timestamp).toBe(true); - }); -}); + it('should return recent usage ordered by timestamp desc', () => { + logUsage({ + group_folder: 'group_a', + timestamp: '2026-02-08T10:00:00Z', + duration_ms: 1000, + }); -describe('Memory Summaries', () => { - beforeEach(resetDatabase); + logUsage({ + group_folder: 'group_b', + timestamp: '2026-02-08T12:00:00Z', + duration_ms: 1000, + }); - it('should return null for non-existent summary', () => { - const summary = getMemorySummary('nonexistent'); - // better-sqlite3's .get() returns undefined when no row is found - expect(summary).toBeUndefined(); + const recent = getRecentUsage(2); + expect(recent[0].timestamp > recent[1].timestamp).toBe(true); + }); }); - it('should upsert memory summary', () => { - upsertMemorySummary('group1', 'Summary of conversations', 10, 5000); + describe('Memory Summaries', () => { + beforeEach(resetDatabase); - const summary = getMemorySummary('group1'); - expect(summary).toBeDefined(); - expect(summary?.summary).toBe('Summary of conversations'); - expect(summary?.messages_archived).toBe(10); - expect(summary?.chars_archived).toBe(5000); - }); + it('should return null for non-existent summary', () => { + const summary = getMemorySummary('nonexistent'); + // better-sqlite3's .get() returns undefined when no row is found + expect(summary).toBeUndefined(); + }); - it('should update existing summary and accumulate counts', () => { - upsertMemorySummary('group2', 'First summary', 5, 2000); - upsertMemorySummary('group2', 'Updated summary', 3, 1500); + it('should upsert memory summary', () => { + upsertMemorySummary('group1', 'Summary of conversations', 10, 5000); - const summary = getMemorySummary('group2'); - expect(summary?.summary).toBe('Updated summary'); - expect(summary?.messages_archived).toBe(8); // Accumulated - expect(summary?.chars_archived).toBe(3500); // Accumulated - }); + const summary = getMemorySummary('group1'); + expect(summary).toBeDefined(); + expect(summary?.summary).toBe('Summary of conversations'); + expect(summary?.messages_archived).toBe(10); + expect(summary?.chars_archived).toBe(5000); + }); - it('should track created_at and updated_at timestamps', async () => { - upsertMemorySummary('group3', 'Initial', 1, 100); - const first = getMemorySummary('group3'); + it('should update existing summary and accumulate counts', () => { + upsertMemorySummary('group2', 'First summary', 5, 2000); + upsertMemorySummary('group2', 'Updated summary', 3, 1500); - await new Promise(resolve => setTimeout(resolve, 10)); - upsertMemorySummary('group3', 'Updated', 1, 100); - const updated = getMemorySummary('group3'); + const summary = getMemorySummary('group2'); + expect(summary?.summary).toBe('Updated summary'); + expect(summary?.messages_archived).toBe(8); // Accumulated + expect(summary?.chars_archived).toBe(3500); // Accumulated + }); - expect(updated?.created_at).toBe(first?.created_at); - expect(updated?.updated_at).not.toBe(first?.updated_at); - }); -}); + it('should track created_at and updated_at timestamps', async () => { + upsertMemorySummary('group3', 'Initial', 1, 100); + const first = getMemorySummary('group3'); -describe('Message Statistics and Archiving', () => { - beforeEach(resetDatabase); + await new Promise((resolve) => setTimeout(resolve, 10)); + upsertMemorySummary('group3', 'Updated', 1, 100); + const updated = getMemorySummary('group3'); - it('should return null for chat with no messages', () => { - const stats = getGroupMessageStats('empty@g.us'); - // better-sqlite3's .get() returns undefined when no row is found - expect(stats).toBeUndefined(); + expect(updated?.created_at).toBe(first?.created_at); + expect(updated?.updated_at).not.toBe(first?.updated_at); + }); }); - it('should get message stats for chat', () => { - const chatJid = 'stats_chat@g.us'; - - // Create chat first (foreign key requirement) - storeChatMetadata(chatJid, '2026-02-08T11:00:00Z'); - storeMessage('msg1', chatJid, 'user@s.whatsapp.net', 'User', 'Hello', '2026-02-08T10:00:00Z', false); - storeMessage('msg2', chatJid, 'user@s.whatsapp.net', 'User', 'World', '2026-02-08T11:00:00Z', false); + describe('Message Statistics and Archiving', () => { + beforeEach(resetDatabase); - const stats = getGroupMessageStats(chatJid); - expect(stats).toBeDefined(); - expect(stats?.message_count).toBe(2); - expect(stats?.oldest_timestamp).toBe('2026-02-08T10:00:00Z'); - expect(stats?.newest_timestamp).toBe('2026-02-08T11:00:00Z'); - }); + it('should return null for chat with no messages', () => { + const stats = getGroupMessageStats('empty@g.us'); + // better-sqlite3's .get() returns undefined when no row is found + expect(stats).toBeUndefined(); + }); - it('should get messages for summary with limit', () => { - const chatJid = 'summary_chat@g.us'; + it('should get message stats for chat', () => { + const chatJid = 'stats_chat@g.us'; - // Create chat first (foreign key requirement) - storeChatMetadata(chatJid, '2026-02-08T19:00:00Z'); - for (let i = 0; i < 10; i++) { + // Create chat first (foreign key requirement) + storeChatMetadata(chatJid, '2026-02-08T11:00:00Z'); storeMessage( - `msg${i}`, + 'msg1', chatJid, 'user@s.whatsapp.net', 'User', - `Message ${i}`, - `2026-02-08T${String(10 + i).padStart(2, '0')}:00:00Z`, + 'Hello', + '2026-02-08T10:00:00Z', + false, + ); + storeMessage( + 'msg2', + chatJid, + 'user@s.whatsapp.net', + 'User', + 'World', + '2026-02-08T11:00:00Z', false, ); - } - const messages = getMessagesForSummary(chatJid, 5); - expect(messages).toHaveLength(5); - expect(messages[0].content).toBe('Message 0'); // Ordered by timestamp ASC - }); + const stats = getGroupMessageStats(chatJid); + expect(stats).toBeDefined(); + expect(stats?.message_count).toBe(2); + expect(stats?.oldest_timestamp).toBe('2026-02-08T10:00:00Z'); + expect(stats?.newest_timestamp).toBe('2026-02-08T11:00:00Z'); + }); - it('should delete old messages', () => { - const chatJid = 'delete_chat@g.us'; + it('should get messages for summary with limit', () => { + const chatJid = 'summary_chat@g.us'; + + // Create chat first (foreign key requirement) + storeChatMetadata(chatJid, '2026-02-08T19:00:00Z'); + for (let i = 0; i < 10; i++) { + storeMessage( + `msg${i}`, + chatJid, + 'user@s.whatsapp.net', + 'User', + `Message ${i}`, + `2026-02-08T${String(10 + i).padStart(2, '0')}:00:00Z`, + false, + ); + } + + const messages = getMessagesForSummary(chatJid, 5); + expect(messages).toHaveLength(5); + expect(messages[0].content).toBe('Message 0'); // Ordered by timestamp ASC + }); - // Create chat first (foreign key requirement) - storeChatMetadata(chatJid, '2026-02-08T10:00:00Z'); - storeMessage('old1', chatJid, 'user@s.whatsapp.net', 'User', 'Old', '2026-02-01T10:00:00Z', false); - storeMessage('old2', chatJid, 'user@s.whatsapp.net', 'User', 'Old2', '2026-02-02T10:00:00Z', false); - storeMessage('new1', chatJid, 'user@s.whatsapp.net', 'User', 'New', '2026-02-08T10:00:00Z', false); + it('should delete old messages', () => { + const chatJid = 'delete_chat@g.us'; - const deleted = deleteOldMessages(chatJid, '2026-02-05T00:00:00Z'); - expect(deleted).toBe(2); + // Create chat first (foreign key requirement) + storeChatMetadata(chatJid, '2026-02-08T10:00:00Z'); + storeMessage( + 'old1', + chatJid, + 'user@s.whatsapp.net', + 'User', + 'Old', + '2026-02-01T10:00:00Z', + false, + ); + storeMessage( + 'old2', + chatJid, + 'user@s.whatsapp.net', + 'User', + 'Old2', + '2026-02-02T10:00:00Z', + false, + ); + storeMessage( + 'new1', + chatJid, + 'user@s.whatsapp.net', + 'User', + 'New', + '2026-02-08T10:00:00Z', + false, + ); - const stats = getGroupMessageStats(chatJid); - expect(stats?.message_count).toBe(1); - }); -}); + const deleted = deleteOldMessages(chatJid, '2026-02-05T00:00:00Z'); + expect(deleted).toBe(2); -describe('Error Tracking', () => { - beforeEach(() => { - // Reset in-memory state between tests - const allStates = getAllErrorStates(); - allStates.forEach((s) => resetErrors(s.group)); + const stats = getGroupMessageStats(chatJid); + expect(stats?.message_count).toBe(1); + }); }); - it('should record error and increment counter', () => { - recordError('group1', 'Test error'); + describe('Error Tracking', () => { + beforeEach(() => { + // Reset in-memory state between tests + const allStates = getAllErrorStates(); + allStates.forEach((s) => resetErrors(s.group)); + }); - const state = getErrorState('group1'); - expect(state).toBeDefined(); - expect(state?.consecutiveFailures).toBe(1); - expect(state?.lastError).toBe('Test error'); - }); + it('should record error and increment counter', () => { + recordError('group1', 'Test error'); - it('should increment consecutive failures', () => { - recordError('group2', 'Error 1'); - recordError('group2', 'Error 2'); - recordError('group2', 'Error 3'); + const state = getErrorState('group1'); + expect(state).toBeDefined(); + expect(state?.consecutiveFailures).toBe(1); + expect(state?.lastError).toBe('Test error'); + }); - const state = getErrorState('group2'); - expect(state?.consecutiveFailures).toBe(3); - expect(state?.lastError).toBe('Error 3'); - }); + it('should increment consecutive failures', () => { + recordError('group2', 'Error 1'); + recordError('group2', 'Error 2'); + recordError('group2', 'Error 3'); - it('should reset error state', () => { - recordError('group3', 'Test error'); - resetErrors('group3'); + const state = getErrorState('group2'); + expect(state?.consecutiveFailures).toBe(3); + expect(state?.lastError).toBe('Error 3'); + }); - const state = getErrorState('group3'); - expect(state?.consecutiveFailures).toBe(0); - expect(state?.lastError).toBeNull(); - }); + it('should reset error state', () => { + recordError('group3', 'Test error'); + resetErrors('group3'); - it('should return null for non-existent error state', () => { - const state = getErrorState('nonexistent'); - expect(state).toBeNull(); - }); + const state = getErrorState('group3'); + expect(state?.consecutiveFailures).toBe(0); + expect(state?.lastError).toBeNull(); + }); - it('should mark alert sent', () => { - recordError('group4', 'Error'); - markAlertSent('group4'); + it('should return null for non-existent error state', () => { + const state = getErrorState('nonexistent'); + expect(state).toBeNull(); + }); - const state = getErrorState('group4'); - expect(state?.lastAlertSent).toBeTruthy(); - expect(typeof state?.lastAlertSent).toBe('string'); - }); + it('should mark alert sent', () => { + recordError('group4', 'Error'); + markAlertSent('group4'); - it('should get all error states', () => { - recordError('group_a', 'Error A'); - recordError('group_b', 'Error B'); + const state = getErrorState('group4'); + expect(state?.lastAlertSent).toBeTruthy(); + expect(typeof state?.lastAlertSent).toBe('string'); + }); - const allStates = getAllErrorStates(); - expect(allStates.length).toBeGreaterThanOrEqual(2); - expect(allStates.some((s) => s.group === 'group_a')).toBe(true); - }); -}); + it('should get all error states', () => { + recordError('group_a', 'Error A'); + recordError('group_b', 'Error B'); -describe('Rate Limiting', () => { - // NOTE: Rate limiting tests are skipped due to incompatibility with beforeEach database reset. - // The in-memory rateLimitWindows Map gets out of sync when the database is recreated. - // These tests would need either: - // 1. An exported function in db.ts to clear the rateLimitWindows Map, OR - // 2. A fix to the cleanup logic in checkRateLimit (lines 680-682) that currently - // deletes keys and returns early without adding timestamps - - it.skip('should allow requests within limit', () => { - const result = checkRateLimit('user1_test', 5, 60000); - expect(result.allowed).toBe(true); + const allStates = getAllErrorStates(); + expect(allStates.length).toBeGreaterThanOrEqual(2); + expect(allStates.some((s) => s.group === 'group_a')).toBe(true); + }); }); - it.skip('should block requests exceeding limit', () => { - expect(true).toBe(true); - }); + describe('Rate Limiting', () => { + // NOTE: Rate limiting tests are skipped due to incompatibility with beforeEach database reset. + // The in-memory rateLimitWindows Map gets out of sync when the database is recreated. + // These tests would need either: + // 1. An exported function in db.ts to clear the rateLimitWindows Map, OR + // 2. A fix to the cleanup logic in checkRateLimit (lines 680-682) that currently + // deletes keys and returns early without adding timestamps - it.skip('should provide reset time when blocked', () => { - expect(true).toBe(true); - }); + it.skip('should allow requests within limit', () => { + const result = checkRateLimit('user1_test', 5, 60000); + expect(result.allowed).toBe(true); + }); - it.skip('should get rate limit status without incrementing', () => { - expect(true).toBe(true); - }); + it.skip('should block requests exceeding limit', () => { + expect(true).toBe(true); + }); - it.skip('should reset after window expires', async () => { - expect(true).toBe(true); - }); + it.skip('should provide reset time when blocked', () => { + expect(true).toBe(true); + }); - it.skip('should handle multiple keys independently', () => { - expect(true).toBe(true); - }); + it.skip('should get rate limit status without incrementing', () => { + expect(true).toBe(true); + }); - it('should clean up inactive keys', () => { - const result = checkRateLimit('inactive_user', 5, 60000); - expect(result.allowed).toBe(true); - // First call with no prior history returns full limit (cleanup at line 680-682 of db.ts) - expect(result.remaining).toBe(5); - }); -}); + it.skip('should reset after window expires', async () => { + expect(true).toBe(true); + }); + it.skip('should handle multiple keys independently', () => { + expect(true).toBe(true); + }); + + it('should clean up inactive keys', () => { + const result = checkRateLimit('inactive_user', 5, 60000); + expect(result.allowed).toBe(true); + // First call with no prior history returns full limit (cleanup at line 680-682 of db.ts) + expect(result.remaining).toBe(5); + }); + }); }); // End of top-level 'db' describe diff --git a/src/__tests__/i18n.test.ts b/src/__tests__/i18n.test.ts index 885bd6acedc..d52b4f68643 100644 --- a/src/__tests__/i18n.test.ts +++ b/src/__tests__/i18n.test.ts @@ -1,186 +1,192 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { - type Language, - setLanguage, - getLanguage, - t, - getTranslation, - availableLanguages, + type Language, + setLanguage, + getLanguage, + t, + getTranslation, + availableLanguages, } from '../i18n.js'; describe('i18n', () => { - beforeEach(() => { - // Reset to default language before each test - setLanguage('zh-TW'); - }); - - describe('Language Management', () => { - it('should have zh-TW as default language', () => { - expect(getLanguage()).toBe('zh-TW'); - }); - - it('should switch language when setLanguage is called', () => { - setLanguage('en'); - expect(getLanguage()).toBe('en'); - - setLanguage('zh-TW'); - expect(getLanguage()).toBe('zh-TW'); - }); - - it('should include zh-TW and en in availableLanguages', () => { - expect(availableLanguages).toContain('zh-TW'); - expect(availableLanguages).toContain('en'); - expect(availableLanguages).toHaveLength(2); - }); - }); - - describe('Translation Retrieval', () => { - it('should return current language translations via t()', () => { - const translations = t(); - expect(translations).toBeDefined(); - expect(typeof translations.rateLimited).toBe('string'); - }); - - it('should return zh-TW translations when language is zh-TW', () => { - setLanguage('zh-TW'); - const translations = t(); - expect(translations.rateLimited).toBe('⏳ 請求過於頻繁,請稍後再試。'); - expect(translations.noErrors).toBe('✅ **無錯誤**\n\n所有群組運作正常。'); - }); - - it('should return en translations when language is en', () => { - setLanguage('en'); - const translations = t(); - expect(translations.rateLimited).toBe('⏳ Too many requests, please try again later.'); - expect(translations.noErrors).toBe('✅ **No Errors**\n\nAll groups running smoothly.'); - }); - - it('should return correct translations after language switch', () => { - setLanguage('zh-TW'); - expect(t().confirmed).toBe('✅ 已確認'); - - setLanguage('en'); - expect(t().confirmed).toBe('✅ Confirmed'); - - setLanguage('zh-TW'); - expect(t().confirmed).toBe('✅ 已確認'); - }); - - it('should return specified language translations via getTranslation()', () => { - const zhTranslations = getTranslation('zh-TW'); - const enTranslations = getTranslation('en'); - - expect(zhTranslations.rateLimited).toBe('⏳ 請求過於頻繁,請稍後再試。'); - expect(enTranslations.rateLimited).toBe('⏳ Too many requests, please try again later.'); - }); - - it('should return translations independent of current language', () => { - setLanguage('en'); - const zhTranslations = getTranslation('zh-TW'); - expect(zhTranslations.rateLimited).toBe('⏳ 請求過於頻繁,請稍後再試。'); - }); - }); - - describe('retryIn Function', () => { - it('should format retry message correctly in zh-TW', () => { - setLanguage('zh-TW'); - expect(t().retryIn(5)).toBe('(5 分鐘後重試)'); - expect(t().retryIn(10)).toBe('(10 分鐘後重試)'); - expect(t().retryIn(1)).toBe('(1 分鐘後重試)'); - }); - - it('should format retry message correctly in en', () => { - setLanguage('en'); - expect(t().retryIn(5)).toBe('(Retry in 5 minutes)'); - expect(t().retryIn(10)).toBe('(Retry in 10 minutes)'); - expect(t().retryIn(1)).toBe('(Retry in 1 minutes)'); - }); - - it('should handle edge case values', () => { - setLanguage('zh-TW'); - expect(t().retryIn(0)).toBe('(0 分鐘後重試)'); - expect(t().retryIn(60)).toBe('(60 分鐘後重試)'); - }); - }); - - describe('Translation Completeness', () => { - it('should have all translation keys in both languages', () => { - const zhKeys = Object.keys(getTranslation('zh-TW')).sort(); - const enKeys = Object.keys(getTranslation('en')).sort(); - - expect(zhKeys).toEqual(enKeys); - }); - - it('should have all string translations defined', () => { - const languages: Language[] = ['zh-TW', 'en']; - - languages.forEach((lang) => { - const translations = getTranslation(lang); - - // System messages - expect(translations.rateLimited).toBeTruthy(); - expect(translations.noErrors).toBeTruthy(); - expect(translations.noActiveErrors).toBeTruthy(); - expect(translations.groupsWithErrors).toBeTruthy(); - expect(translations.adminCommandsTitle).toBeTruthy(); - expect(translations.adminOnlyNote).toBeTruthy(); - - // Admin commands - expect(translations.statsTitle).toBeTruthy(); - expect(translations.registeredGroups).toBeTruthy(); - expect(translations.uptime).toBeTruthy(); - expect(translations.memory).toBeTruthy(); - expect(translations.usageAnalytics).toBeTruthy(); - expect(translations.totalRequests).toBeTruthy(); - expect(translations.avgResponseTime).toBeTruthy(); - expect(translations.totalTokens).toBeTruthy(); - - // Feedback - expect(translations.confirmed).toBeTruthy(); - expect(translations.cancelled).toBeTruthy(); - expect(translations.retrying).toBeTruthy(); - expect(translations.thanksFeedback).toBeTruthy(); - expect(translations.willImprove).toBeTruthy(); - - // UI Phase 1 - expect(translations.processing).toBeTruthy(); - expect(translations.downloadingMedia).toBeTruthy(); - expect(translations.transcribing).toBeTruthy(); - expect(translations.thinking).toBeTruthy(); - expect(translations.retry).toBeTruthy(); - expect(translations.feedback).toBeTruthy(); - expect(translations.errorOccurred).toBeTruthy(); - }); - }); - - it('should have retryIn as a function in all languages', () => { - const languages: Language[] = ['zh-TW', 'en']; - - languages.forEach((lang) => { - const translations = getTranslation(lang); - expect(typeof translations.retryIn).toBe('function'); - }); - }); - }); - - describe('Type Safety', () => { - it('should enforce Language type constraints', () => { - const validLanguages: Language[] = ['zh-TW', 'en']; - - validLanguages.forEach((lang) => { - setLanguage(lang); - expect(getLanguage()).toBe(lang); - }); - }); - - it('should return consistent translation object structure', () => { - const zhTranslations = getTranslation('zh-TW'); - const enTranslations = getTranslation('en'); - - const zhProps = Object.getOwnPropertyNames(zhTranslations).sort(); - const enProps = Object.getOwnPropertyNames(enTranslations).sort(); - - expect(zhProps).toEqual(enProps); - }); + beforeEach(() => { + // Reset to default language before each test + setLanguage('zh-TW'); + }); + + describe('Language Management', () => { + it('should have zh-TW as default language', () => { + expect(getLanguage()).toBe('zh-TW'); }); + + it('should switch language when setLanguage is called', () => { + setLanguage('en'); + expect(getLanguage()).toBe('en'); + + setLanguage('zh-TW'); + expect(getLanguage()).toBe('zh-TW'); + }); + + it('should include zh-TW and en in availableLanguages', () => { + expect(availableLanguages).toContain('zh-TW'); + expect(availableLanguages).toContain('en'); + expect(availableLanguages).toHaveLength(2); + }); + }); + + describe('Translation Retrieval', () => { + it('should return current language translations via t()', () => { + const translations = t(); + expect(translations).toBeDefined(); + expect(typeof translations.rateLimited).toBe('string'); + }); + + it('should return zh-TW translations when language is zh-TW', () => { + setLanguage('zh-TW'); + const translations = t(); + expect(translations.rateLimited).toBe('⏳ 請求過於頻繁,請稍後再試。'); + expect(translations.noErrors).toBe('✅ **無錯誤**\n\n所有群組運作正常。'); + }); + + it('should return en translations when language is en', () => { + setLanguage('en'); + const translations = t(); + expect(translations.rateLimited).toBe( + '⏳ Too many requests, please try again later.', + ); + expect(translations.noErrors).toBe( + '✅ **No Errors**\n\nAll groups running smoothly.', + ); + }); + + it('should return correct translations after language switch', () => { + setLanguage('zh-TW'); + expect(t().confirmed).toBe('✅ 已確認'); + + setLanguage('en'); + expect(t().confirmed).toBe('✅ Confirmed'); + + setLanguage('zh-TW'); + expect(t().confirmed).toBe('✅ 已確認'); + }); + + it('should return specified language translations via getTranslation()', () => { + const zhTranslations = getTranslation('zh-TW'); + const enTranslations = getTranslation('en'); + + expect(zhTranslations.rateLimited).toBe('⏳ 請求過於頻繁,請稍後再試。'); + expect(enTranslations.rateLimited).toBe( + '⏳ Too many requests, please try again later.', + ); + }); + + it('should return translations independent of current language', () => { + setLanguage('en'); + const zhTranslations = getTranslation('zh-TW'); + expect(zhTranslations.rateLimited).toBe('⏳ 請求過於頻繁,請稍後再試。'); + }); + }); + + describe('retryIn Function', () => { + it('should format retry message correctly in zh-TW', () => { + setLanguage('zh-TW'); + expect(t().retryIn(5)).toBe('(5 分鐘後重試)'); + expect(t().retryIn(10)).toBe('(10 分鐘後重試)'); + expect(t().retryIn(1)).toBe('(1 分鐘後重試)'); + }); + + it('should format retry message correctly in en', () => { + setLanguage('en'); + expect(t().retryIn(5)).toBe('(Retry in 5 minutes)'); + expect(t().retryIn(10)).toBe('(Retry in 10 minutes)'); + expect(t().retryIn(1)).toBe('(Retry in 1 minutes)'); + }); + + it('should handle edge case values', () => { + setLanguage('zh-TW'); + expect(t().retryIn(0)).toBe('(0 分鐘後重試)'); + expect(t().retryIn(60)).toBe('(60 分鐘後重試)'); + }); + }); + + describe('Translation Completeness', () => { + it('should have all translation keys in both languages', () => { + const zhKeys = Object.keys(getTranslation('zh-TW')).sort(); + const enKeys = Object.keys(getTranslation('en')).sort(); + + expect(zhKeys).toEqual(enKeys); + }); + + it('should have all string translations defined', () => { + const languages: Language[] = ['zh-TW', 'en']; + + languages.forEach((lang) => { + const translations = getTranslation(lang); + + // System messages + expect(translations.rateLimited).toBeTruthy(); + expect(translations.noErrors).toBeTruthy(); + expect(translations.noActiveErrors).toBeTruthy(); + expect(translations.groupsWithErrors).toBeTruthy(); + expect(translations.adminCommandsTitle).toBeTruthy(); + expect(translations.adminOnlyNote).toBeTruthy(); + + // Admin commands + expect(translations.statsTitle).toBeTruthy(); + expect(translations.registeredGroups).toBeTruthy(); + expect(translations.uptime).toBeTruthy(); + expect(translations.memory).toBeTruthy(); + expect(translations.usageAnalytics).toBeTruthy(); + expect(translations.totalRequests).toBeTruthy(); + expect(translations.avgResponseTime).toBeTruthy(); + expect(translations.totalTokens).toBeTruthy(); + + // Feedback + expect(translations.confirmed).toBeTruthy(); + expect(translations.cancelled).toBeTruthy(); + expect(translations.retrying).toBeTruthy(); + expect(translations.thanksFeedback).toBeTruthy(); + expect(translations.willImprove).toBeTruthy(); + + // UI Phase 1 + expect(translations.processing).toBeTruthy(); + expect(translations.downloadingMedia).toBeTruthy(); + expect(translations.transcribing).toBeTruthy(); + expect(translations.thinking).toBeTruthy(); + expect(translations.retry).toBeTruthy(); + expect(translations.feedback).toBeTruthy(); + expect(translations.errorOccurred).toBeTruthy(); + }); + }); + + it('should have retryIn as a function in all languages', () => { + const languages: Language[] = ['zh-TW', 'en']; + + languages.forEach((lang) => { + const translations = getTranslation(lang); + expect(typeof translations.retryIn).toBe('function'); + }); + }); + }); + + describe('Type Safety', () => { + it('should enforce Language type constraints', () => { + const validLanguages: Language[] = ['zh-TW', 'en']; + + validLanguages.forEach((lang) => { + setLanguage(lang); + expect(getLanguage()).toBe(lang); + }); + }); + + it('should return consistent translation object structure', () => { + const zhTranslations = getTranslation('zh-TW'); + const enTranslations = getTranslation('en'); + + const zhProps = Object.getOwnPropertyNames(zhTranslations).sort(); + const enProps = Object.getOwnPropertyNames(enTranslations).sort(); + + expect(zhProps).toEqual(enProps); + }); + }); }); diff --git a/src/__tests__/mount-security.test.ts b/src/__tests__/mount-security.test.ts index 15573bc4c10..80a35ec3283 100644 --- a/src/__tests__/mount-security.test.ts +++ b/src/__tests__/mount-security.test.ts @@ -66,14 +66,14 @@ describe('mount-security', () => { const result = loadMountAllowlist(); expect(result).toBeNull(); - expect(mockFs.existsSync).toHaveBeenCalledWith('/test/config/mount-allowlist.json'); + expect(mockFs.existsSync).toHaveBeenCalledWith( + '/test/config/mount-allowlist.json', + ); }); it('should load and parse valid allowlist file', () => { const validAllowlist: MountAllowlist = { - allowedRoots: [ - { path: '~/projects', allowReadWrite: true }, - ], + allowedRoots: [{ path: '~/projects', allowReadWrite: true }], blockedPatterns: ['secret'], nonMainReadOnly: true, }; diff --git a/src/__tests__/task-scheduler.test.ts b/src/__tests__/task-scheduler.test.ts index 0e7daaf97c6..c7c748aa3b1 100644 --- a/src/__tests__/task-scheduler.test.ts +++ b/src/__tests__/task-scheduler.test.ts @@ -69,7 +69,10 @@ vi.mock('fs', () => ({ })); import type { RegisteredGroup, ScheduledTask } from '../types.js'; -import { startSchedulerLoop, type SchedulerDependencies } from '../task-scheduler.js'; +import { + startSchedulerLoop, + type SchedulerDependencies, +} from '../task-scheduler.js'; describe('task-scheduler', () => { let mockDeps: SchedulerDependencies; @@ -80,7 +83,7 @@ describe('task-scheduler', () => { mockDeps = { sendMessage: vi.fn(), registeredGroups: vi.fn().mockReturnValue({ - 'group1': { + group1: { name: 'Test Group', jid: 'group1@g.us', folder: 'test-group', @@ -89,7 +92,7 @@ describe('task-scheduler', () => { systemPrompt: 'Test prompt', enableWebSearch: true, } as RegisteredGroup, - 'main': { + main: { name: 'Main Group', jid: 'main@g.us', folder: 'main', @@ -101,7 +104,7 @@ describe('task-scheduler', () => { }), getSessions: vi.fn().mockReturnValue({ 'test-group': 'session-123', - 'main': 'session-main', + main: 'session-main', }), }; @@ -153,7 +156,7 @@ describe('task-scheduler', () => { expect(mockIsMaintenanceMode).toHaveBeenCalled(); expect(mockGetDueTasks).not.toHaveBeenCalled(); expect(mockLogger.debug).toHaveBeenCalledWith( - 'Scheduler skipping: maintenance mode active' + 'Scheduler skipping: maintenance mode active', ); scheduler.stop(); @@ -202,7 +205,7 @@ describe('task-scheduler', () => { expect(mockRunContainerAgent).not.toHaveBeenCalled(); expect(mockLogger.info).not.toHaveBeenCalledWith( expect.objectContaining({ count: expect.any(Number) }), - 'Found due tasks' + 'Found due tasks', ); scheduler.stop(); @@ -234,11 +237,11 @@ describe('task-scheduler', () => { expect(mockLogger.info).toHaveBeenCalledWith( { count: 1 }, - 'Found due tasks' + 'Found due tasks', ); expect(mockMkdirSync).toHaveBeenCalledWith( '/tmp/test-groups/test-group', - { recursive: true } + { recursive: true }, ); expect(mockRunContainerAgent).toHaveBeenCalled(); @@ -270,14 +273,14 @@ describe('task-scheduler', () => { expect(mockLogger.error).toHaveBeenCalledWith( { taskId: 'task-1', groupFolder: 'nonexistent-group' }, - 'Group not found for task' + 'Group not found for task', ); expect(mockLogTaskRun).toHaveBeenCalledWith( expect.objectContaining({ task_id: 'task-1', status: 'error', error: 'Group not found: nonexistent-group', - }) + }), ); expect(mockRunContainerAgent).not.toHaveBeenCalled(); @@ -365,7 +368,7 @@ describe('task-scheduler', () => { mockGetDueTasks.mockReturnValue([task1, task2]); mockGetTaskById.mockImplementation((id) => - id === 'task-1' ? task1 : task2 + id === 'task-1' ? task1 : task2, ); mockGetAllTasks.mockReturnValue([task1, task2]); @@ -384,7 +387,7 @@ describe('task-scheduler', () => { taskId: 'task-1', error: 'Task 1 failed', }), - 'Task failed' + 'Task failed', ); scheduler.stop(); @@ -417,7 +420,7 @@ describe('task-scheduler', () => { expect(mockUpdateTaskAfterRun).toHaveBeenCalledWith( 'task-1', expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/), - expect.any(String) + expect.any(String), ); const nextRun = mockUpdateTaskAfterRun.mock.calls[0][1]; @@ -453,7 +456,7 @@ describe('task-scheduler', () => { expect(mockUpdateTaskAfterRun).toHaveBeenCalledWith( 'task-1', expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/), - expect.any(String) + expect.any(String), ); const nextRun = mockUpdateTaskAfterRun.mock.calls[0][1]; @@ -489,7 +492,7 @@ describe('task-scheduler', () => { expect(mockUpdateTaskAfterRun).toHaveBeenCalledWith( 'task-1', null, - expect.any(String) + expect.any(String), ); scheduler.stop(); @@ -528,7 +531,7 @@ describe('task-scheduler', () => { task_id: 'task-1', status: 'error', error: 'Container execution failed', - }) + }), ); scheduler.stop(); @@ -562,7 +565,7 @@ describe('task-scheduler', () => { expect.anything(), expect.objectContaining({ sessionId: 'session-123', - }) + }), ); scheduler.stop(); @@ -596,7 +599,7 @@ describe('task-scheduler', () => { expect.anything(), expect.objectContaining({ sessionId: undefined, - }) + }), ); scheduler.stop(); @@ -634,13 +637,15 @@ describe('task-scheduler', () => { id: 'task-1', groupFolder: 'test-group', prompt: 'Test task', - }) - ]) + }), + ]), ); // Verify snapshot was written before container agent runs - const snapshotCallOrder = mockWriteTasksSnapshot.mock.invocationCallOrder[0]; - const containerCallOrder = mockRunContainerAgent.mock.invocationCallOrder[0]; + const snapshotCallOrder = + mockWriteTasksSnapshot.mock.invocationCallOrder[0]; + const containerCallOrder = + mockRunContainerAgent.mock.invocationCallOrder[0]; expect(snapshotCallOrder).toBeLessThan(containerCallOrder); scheduler.stop(); @@ -673,7 +678,7 @@ describe('task-scheduler', () => { expect(mockWriteTasksSnapshot).toHaveBeenCalledWith( 'main', true, // isMain should be true - expect.any(Array) + expect.any(Array), ); scheduler.stop(); diff --git a/src/config.ts b/src/config.ts index 5147adbc20d..4531f524676 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,20 +3,35 @@ import path from 'path'; export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || 'Andy'; export const POLL_INTERVAL = 2000; export const SCHEDULER_POLL_INTERVAL = 60000; -export const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-3-flash-preview'; +export const GEMINI_MODEL = + process.env.GEMINI_MODEL || 'gemini-3-flash-preview'; // Telegram Bot Configuration export const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || ''; // Validate required environment variables at import time if (!TELEGRAM_BOT_TOKEN) { - console.error('╔══════════════════════════════════════════════════════════════╗'); - console.error('║ ERROR: TELEGRAM_BOT_TOKEN is required ║'); - console.error('╟──────────────────────────────────────────────────────────────╢'); - console.error('║ 1. Get a token from @BotFather on Telegram ║'); - console.error('║ 2. Create .env: echo "TELEGRAM_BOT_TOKEN=xxx" > .env ║'); - console.error('║ 3. Run: npm run setup:telegram ║'); - console.error('╚══════════════════════════════════════════════════════════════╝'); + console.error( + '╔══════════════════════════════════════════════════════════════╗', + ); + console.error( + '║ ERROR: TELEGRAM_BOT_TOKEN is required ║', + ); + console.error( + '╟──────────────────────────────────────────────────────────────╢', + ); + console.error( + '║ 1. Get a token from @BotFather on Telegram ║', + ); + console.error( + '║ 2. Create .env: echo "TELEGRAM_BOT_TOKEN=xxx" > .env ║', + ); + console.error( + '║ 3. Run: npm run setup:telegram ║', + ); + console.error( + '╚══════════════════════════════════════════════════════════════╝', + ); process.exit(1); } @@ -43,8 +58,14 @@ function safeParseInt(value: string | undefined, defaultValue: number): number { export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanogemclaw-agent:latest'; -export const CONTAINER_TIMEOUT = safeParseInt(process.env.CONTAINER_TIMEOUT, 300000); -export const CONTAINER_MAX_OUTPUT_SIZE = safeParseInt(process.env.CONTAINER_MAX_OUTPUT_SIZE, 10485760); // 10MB default +export const CONTAINER_TIMEOUT = safeParseInt( + process.env.CONTAINER_TIMEOUT, + 300000, +); +export const CONTAINER_MAX_OUTPUT_SIZE = safeParseInt( + process.env.CONTAINER_MAX_OUTPUT_SIZE, + 10485760, +); // 10MB default export const IPC_POLL_INTERVAL = 1000; /** diff --git a/src/container-runner.ts b/src/container-runner.ts index ee36a1b7677..ce0120f4316 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -32,7 +32,7 @@ const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; * Manages per-group locks to prevent concurrent container execution. * Ensures only one container runs per group at a time, whether triggered by * user messages, scheduled tasks, or IPC commands. - * + * * Memory is automatically cleaned up when a group's queue becomes empty. */ class GroupLockManager { @@ -57,7 +57,10 @@ class GroupLockManager { }); // Update the lock to include this task - this.locks.set(groupFolder, currentLock.then(() => taskPromise)); + this.locks.set( + groupFolder, + currentLock.then(() => taskPromise), + ); // Wait for our turn await currentLock; @@ -145,7 +148,7 @@ function buildVolumeMounts( mounts.push({ hostPath: projectRoot, containerPath: '/workspace/project', - readonly: true, // Security: prevent code tampering + readonly: true, // Security: prevent code tampering }); // Main gets its group folder as the working directory (writable) @@ -217,7 +220,7 @@ function buildVolumeMounts( // Environment file directory (workaround for Apple Container -i env var bug) // Only expose specific auth variables needed by Gemini CLI, not the entire .env - const envDir = path.join(DATA_DIR, 'env', group.folder); // per-group isolation + const envDir = path.join(DATA_DIR, 'env', group.folder); // per-group isolation fs.mkdirSync(envDir, { recursive: true }); const envFile = path.join(projectRoot, '.env'); if (fs.existsSync(envFile)) { @@ -284,13 +287,19 @@ export async function runContainerAgent( input: ContainerInput, ): Promise { logger.debug( - { group: group.name, hasPending: groupLockManager.hasPending(group.folder) }, + { + group: group.name, + hasPending: groupLockManager.hasPending(group.folder), + }, 'Acquiring group lock for container execution', ); return groupLockManager.withLock(group.folder, async () => { // Notify dashboard: agent is thinking - emitDashboardEvent('agent:status', { groupFolder: group.folder, status: 'thinking' }); + emitDashboardEvent('agent:status', { + groupFolder: group.folder, + status: 'thinking', + }); const startTime = Date.now(); const result = await runContainerAgentInternal(group, input); @@ -308,15 +317,25 @@ export async function runContainerAgent( // Track errors/success if (result.status === 'error') { - const errorState = recordError(input.groupFolder, result.error || 'Unknown error'); + const errorState = recordError( + input.groupFolder, + result.error || 'Unknown error', + ); // Send webhook if new error or threshold reached - if (errorState.consecutiveFailures === 1 || errorState.consecutiveFailures % 3 === 0) { + if ( + errorState.consecutiveFailures === 1 || + errorState.consecutiveFailures % 3 === 0 + ) { const { sendWebhookNotification } = await import('./webhook.js'); await sendWebhookNotification( 'error', `Container error in group ${group.name}`, - { group: input.groupFolder, error: result.error, failures: errorState.consecutiveFailures } + { + group: input.groupFolder, + error: result.error, + failures: errorState.consecutiveFailures, + }, ); } } else { @@ -354,7 +373,10 @@ async function runContainerAgentInternal( // Resolve system prompt with persona const { getEffectiveSystemPrompt } = await import('./personas.js'); - const systemPrompt = getEffectiveSystemPrompt(input.systemPrompt, input.persona); + const systemPrompt = getEffectiveSystemPrompt( + input.systemPrompt, + input.persona, + ); // Build base args including mounts const baseArgs = buildContainerArgs(mounts); @@ -367,11 +389,16 @@ async function runContainerAgentInternal( // Inject environment variables before the image argument baseArgs.push( - '-e', `GEMINI_API_KEY=${process.env.GEMINI_API_KEY || ''}`, - '-e', `GEMINI_SYSTEM_PROMPT=${sanitizedPrompt}`, - '-e', `GEMINI_ENABLE_SEARCH=${input.enableWebSearch !== false ? 'true' : 'false'}`, - '-e', `GEMINI_MODEL=${process.env.GEMINI_MODEL || 'gemini-3-flash-preview'}`, - '-e', `CONTAINER_TIMEOUT=${CONTAINER_TIMEOUT}`, + '-e', + `GEMINI_API_KEY=${process.env.GEMINI_API_KEY || ''}`, + '-e', + `GEMINI_SYSTEM_PROMPT=${sanitizedPrompt}`, + '-e', + `GEMINI_ENABLE_SEARCH=${input.enableWebSearch !== false ? 'true' : 'false'}`, + '-e', + `GEMINI_MODEL=${process.env.GEMINI_MODEL || 'gemini-3-flash-preview'}`, + '-e', + `CONTAINER_TIMEOUT=${CONTAINER_TIMEOUT}`, ); // Re-append image @@ -457,13 +484,19 @@ async function runContainerAgentInternal( let timeoutResolved = false; const timeout = setTimeout(() => { - logger.warn({ group: group.name }, 'Container timeout, attempting graceful shutdown'); + logger.warn( + { group: group.name }, + 'Container timeout, attempting graceful shutdown', + ); container.kill('SIGTERM'); // If still running after grace period, force kill setTimeout(() => { if (!container.killed && !timeoutResolved) { - logger.error({ group: group.name }, 'Container did not exit gracefully, forcing SIGKILL'); + logger.error( + { group: group.name }, + 'Container did not exit gracefully, forcing SIGKILL', + ); container.kill('SIGKILL'); } }, CONTAINER.GRACEFUL_SHUTDOWN_DELAY_MS); @@ -482,7 +515,10 @@ async function runContainerAgentInternal( // Skip if timeout already resolved this promise if (timeoutResolved) { - logger.debug({ group: group.name, code }, 'Container closed after timeout (ignored)'); + logger.debug( + { group: group.name, code }, + 'Container closed after timeout (ignored)', + ); return; } @@ -620,7 +656,7 @@ async function runContainerAgentInternal( }); container.on('error', (err) => { - if (timeoutResolved) return; // Already handled by timeout + if (timeoutResolved) return; // Already handled by timeout clearTimeout(timeout); logger.error({ group: group.name, error: err }, 'Container spawn error'); resolve({ diff --git a/src/daily-report.ts b/src/daily-report.ts index 42e29d8b92e..33aef9eb684 100644 --- a/src/daily-report.ts +++ b/src/daily-report.ts @@ -1,15 +1,11 @@ /** * Daily Report Generator - * + * * Generates usage and health summary reports to send to the main group. * Can be triggered by scheduler or manually via admin command. */ -import { - getUsageStats, - getRecentUsage, - getAllErrorStates, -} from './db.js'; +import { getUsageStats, getRecentUsage, getAllErrorStates } from './db.js'; import { logger } from './logger.js'; // ============================================================================ @@ -17,81 +13,81 @@ import { logger } from './logger.js'; // ============================================================================ interface DailyReport { - generated_at: string; - period: { - start: string; - end: string; - }; - usage: { - total_requests: number; - avg_duration_ms: number; - total_tokens: number; - }; - errors: { - groups_with_errors: number; - total_failures: number; - }; - top_groups: Array<{ - name: string; - requests: number; - }>; + generated_at: string; + period: { + start: string; + end: string; + }; + usage: { + total_requests: number; + avg_duration_ms: number; + total_tokens: number; + }; + errors: { + groups_with_errors: number; + total_failures: number; + }; + top_groups: Array<{ + name: string; + requests: number; + }>; } /** * Generate a daily usage report for the last 24 hours */ export function generateDailyReport(): DailyReport { - const now = new Date(); - const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); - - // Get usage stats for last 24 hours - const since = yesterday.toISOString(); - const usage = getUsageStats(undefined, since); - - // Get error states - const errorStates = getAllErrorStates(); - const groupsWithErrors = errorStates.filter( - (e) => e.state.consecutiveFailures > 0, - ).length; - const totalFailures = errorStates.reduce( - (sum, e) => sum + e.state.consecutiveFailures, - 0, - ); - - // Get top groups by usage - const recentUsage = getRecentUsage(100); - const groupCounts = new Map(); - for (const entry of recentUsage) { - if (new Date(entry.timestamp) >= yesterday) { - groupCounts.set( - entry.group_folder, - (groupCounts.get(entry.group_folder) || 0) + 1, - ); - } + const now = new Date(); + const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + // Get usage stats for last 24 hours + const since = yesterday.toISOString(); + const usage = getUsageStats(undefined, since); + + // Get error states + const errorStates = getAllErrorStates(); + const groupsWithErrors = errorStates.filter( + (e) => e.state.consecutiveFailures > 0, + ).length; + const totalFailures = errorStates.reduce( + (sum, e) => sum + e.state.consecutiveFailures, + 0, + ); + + // Get top groups by usage + const recentUsage = getRecentUsage(100); + const groupCounts = new Map(); + for (const entry of recentUsage) { + if (new Date(entry.timestamp) >= yesterday) { + groupCounts.set( + entry.group_folder, + (groupCounts.get(entry.group_folder) || 0) + 1, + ); } + } + + const topGroups = Array.from(groupCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([name, requests]) => ({ name, requests })); - const topGroups = Array.from(groupCounts.entries()) - .sort((a, b) => b[1] - a[1]) - .slice(0, 5) - .map(([name, requests]) => ({ name, requests })); - - return { - generated_at: now.toISOString(), - period: { - start: since, - end: now.toISOString(), - }, - usage: { - total_requests: usage.total_requests, - avg_duration_ms: Math.round(usage.avg_duration_ms), - total_tokens: usage.total_prompt_tokens + usage.total_response_tokens, - }, - errors: { - groups_with_errors: groupsWithErrors, - total_failures: totalFailures, - }, - top_groups: topGroups, - }; + return { + generated_at: now.toISOString(), + period: { + start: since, + end: now.toISOString(), + }, + usage: { + total_requests: usage.total_requests, + avg_duration_ms: Math.round(usage.avg_duration_ms), + total_tokens: usage.total_prompt_tokens + usage.total_response_tokens, + }, + errors: { + groups_with_errors: groupsWithErrors, + total_failures: totalFailures, + }, + top_groups: topGroups, + }; } /** @@ -106,19 +102,21 @@ import { t } from './i18n.js'; * Format a daily report as a markdown message */ export function formatDailyReport(report: DailyReport): string { - const avgSeconds = Math.round(report.usage.avg_duration_ms / 1000); + const avgSeconds = Math.round(report.usage.avg_duration_ms / 1000); - const topGroupsList = report.top_groups.length > 0 - ? report.top_groups - .map((g, i) => `${i + 1}. ${g.name}: ${g.requests}`) - .join('\n') - : '(No data)'; + const topGroupsList = + report.top_groups.length > 0 + ? report.top_groups + .map((g, i) => `${i + 1}. ${g.name}: ${g.requests}`) + .join('\n') + : '(No data)'; - const errorStatus = report.errors.groups_with_errors > 0 - ? `${t().groupsWithErrors}: ${report.errors.groups_with_errors} (${report.errors.total_failures} failures)` - : t().noErrors; + const errorStatus = + report.errors.groups_with_errors > 0 + ? `${t().groupsWithErrors}: ${report.errors.groups_with_errors} (${report.errors.total_failures} failures)` + : t().noErrors; - return `${t().statsTitle} (Daily Report) + return `${t().statsTitle} (Daily Report) _${new Date(report.period.start).toLocaleDateString()} ~ ${new Date(report.period.end).toLocaleDateString()}_ --- @@ -142,11 +140,11 @@ _Generated at ${new Date(report.generated_at).toLocaleTimeString()}_`; * Generate and return formatted daily report */ export function getDailyReportMessage(): string { - try { - const report = generateDailyReport(); - return formatDailyReport(report); - } catch (err) { - logger.error({ err }, 'Failed to generate daily report'); - return '❌ Failed to generate report'; - } + try { + const report = generateDailyReport(); + return formatDailyReport(report); + } catch (err) { + logger.error({ err }, 'Failed to generate daily report'); + return '❌ Failed to generate report'; + } } diff --git a/src/db.ts b/src/db.ts index da7f1b6be5e..6ccad038e0f 100644 --- a/src/db.ts +++ b/src/db.ts @@ -606,7 +606,8 @@ export function upsertMemorySummary( charsArchived: number, ): void { const now = new Date().toISOString(); - db.prepare(` + db.prepare( + ` INSERT INTO memory_summaries (group_folder, summary, messages_archived, chars_archived, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(group_folder) DO UPDATE SET @@ -614,7 +615,8 @@ export function upsertMemorySummary( messages_archived = memory_summaries.messages_archived + excluded.messages_archived, chars_archived = memory_summaries.chars_archived + excluded.chars_archived, updated_at = excluded.updated_at - `).run(groupFolder, summary, messagesArchived, charsArchived, now, now); + `, + ).run(groupFolder, summary, messagesArchived, charsArchived, now, now); } export interface GroupMessageStats { @@ -625,9 +627,12 @@ export interface GroupMessageStats { newest_timestamp: string; } -export function getGroupMessageStats(chatJid: string): GroupMessageStats | null { +export function getGroupMessageStats( + chatJid: string, +): GroupMessageStats | null { return db - .prepare(` + .prepare( + ` SELECT chat_jid, COUNT(*) as message_count, @@ -637,7 +642,8 @@ export function getGroupMessageStats(chatJid: string): GroupMessageStats | null FROM messages WHERE chat_jid = ? GROUP BY chat_jid - `) + `, + ) .get(chatJid) as GroupMessageStats | null; } @@ -646,17 +652,26 @@ export function getMessagesForSummary( limit: number = 100, ): { sender_name: string; content: string; timestamp: string }[] { return db - .prepare(` + .prepare( + ` SELECT sender_name, content, timestamp FROM messages WHERE chat_jid = ? ORDER BY timestamp ASC LIMIT ? - `) - .all(chatJid, limit) as { sender_name: string; content: string; timestamp: string }[]; + `, + ) + .all(chatJid, limit) as { + sender_name: string; + content: string; + timestamp: string; + }[]; } -export function deleteOldMessages(chatJid: string, beforeTimestamp: string): number { +export function deleteOldMessages( + chatJid: string, + beforeTimestamp: string, +): number { const result = db .prepare('DELETE FROM messages WHERE chat_jid = ? AND timestamp < ?') .run(chatJid, beforeTimestamp); @@ -791,9 +806,10 @@ export function getRateLimitStatus( const activeTimestamps = window.timestamps.filter((ts) => ts > windowStart); const count = activeTimestamps.length; - const resetInMs = activeTimestamps.length > 0 - ? activeTimestamps[0] + windowMs - now - : windowMs; + const resetInMs = + activeTimestamps.length > 0 + ? activeTimestamps[0] + windowMs - now + : windowMs; return { count, diff --git a/src/google-calendar.ts b/src/google-calendar.ts index 833e046ac08..25d0cb75aed 100644 --- a/src/google-calendar.ts +++ b/src/google-calendar.ts @@ -1,60 +1,62 @@ /** * Google Calendar Integration (Stub) - * + * * Future implementation will handle: * - OAuth2 authentication flow * - Listing upcoming events * - Creating new events * - Syncing reminders - * + * * Currently disabled until full OAuth flow is implemented. */ import { logger } from './logger.js'; export interface CalendarEvent { - summary: string; - start: string; - end: string; - description?: string; - location?: string; + summary: string; + start: string; + end: string; + description?: string; + location?: string; } export class GoogleCalendarService { - private static instance: GoogleCalendarService; - private isAuthenticated: boolean = false; + private static instance: GoogleCalendarService; + private isAuthenticated: boolean = false; - private constructor() { } + private constructor() {} - public static getInstance(): GoogleCalendarService { - if (!GoogleCalendarService.instance) { - GoogleCalendarService.instance = new GoogleCalendarService(); - } - return GoogleCalendarService.instance; + public static getInstance(): GoogleCalendarService { + if (!GoogleCalendarService.instance) { + GoogleCalendarService.instance = new GoogleCalendarService(); } - - public async authenticate(authCode: string): Promise { - logger.info('Google Calendar authentication not implemented yet'); - return false; - } - - public async listUpcomingEvents(maxResults: number = 10): Promise { - if (!this.isAuthenticated) { - return []; - } - return []; + return GoogleCalendarService.instance; + } + + public async authenticate(authCode: string): Promise { + logger.info('Google Calendar authentication not implemented yet'); + return false; + } + + public async listUpcomingEvents( + maxResults: number = 10, + ): Promise { + if (!this.isAuthenticated) { + return []; } + return []; + } - public async createEvent(event: CalendarEvent): Promise { - if (!this.isAuthenticated) { - return false; - } - return false; + public async createEvent(event: CalendarEvent): Promise { + if (!this.isAuthenticated) { + return false; } + return false; + } - public getAuthUrl(): string { - return 'https://accounts.google.com/o/oauth2/v2/auth?client_id=PLACEHOLDER...'; - } + public getAuthUrl(): string { + return 'https://accounts.google.com/o/oauth2/v2/auth?client_id=PLACEHOLDER...'; + } } export const calendarService = GoogleCalendarService.getInstance(); diff --git a/src/health-check.ts b/src/health-check.ts index feb140f1f8e..59b77e4466c 100644 --- a/src/health-check.ts +++ b/src/health-check.ts @@ -1,6 +1,6 @@ /** * Health Check HTTP Server - * + * * Provides /health and /ready endpoints for container orchestration * and external monitoring systems. */ @@ -14,16 +14,16 @@ import { logger } from './logger.js'; // ============================================================================ interface HealthStatus { - status: 'healthy' | 'degraded' | 'unhealthy'; - uptime: number; - memory: { - heapUsed: number; - heapTotal: number; - rss: number; - }; - groups: number; - version: string; - timestamp: string; + status: 'healthy' | 'degraded' | 'unhealthy'; + uptime: number; + memory: { + heapUsed: number; + heapTotal: number; + rss: number; + }; + groups: number; + version: string; + timestamp: string; } // ============================================================================ @@ -34,13 +34,13 @@ let getGroupCount: () => number = () => 0; let getActiveContainers: () => number = () => 0; export function setHealthCheckDependencies(deps: { - getGroupCount: () => number; - getActiveContainers?: () => number; + getGroupCount: () => number; + getActiveContainers?: () => number; }): void { - getGroupCount = deps.getGroupCount; - if (deps.getActiveContainers) { - getActiveContainers = deps.getActiveContainers; - } + getGroupCount = deps.getGroupCount; + if (deps.getActiveContainers) { + getActiveContainers = deps.getActiveContainers; + } } // ============================================================================ @@ -48,30 +48,30 @@ export function setHealthCheckDependencies(deps: { // ============================================================================ function getHealthStatus(): HealthStatus { - const memUsage = process.memoryUsage(); - const heapUsedMB = Math.round(memUsage.heapUsed / 1024 / 1024); - const heapTotalMB = Math.round(memUsage.heapTotal / 1024 / 1024); - const heapUsedPercent = memUsage.heapUsed / memUsage.heapTotal; - - let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy'; - if (heapUsedPercent > 0.95) { - status = 'unhealthy'; - } else if (heapUsedPercent > 0.85) { - status = 'degraded'; - } - - return { - status, - uptime: process.uptime(), - memory: { - heapUsed: heapUsedMB, - heapTotal: heapTotalMB, - rss: Math.round(memUsage.rss / 1024 / 1024), - }, - groups: getGroupCount(), - version: process.env.npm_package_version || '1.0.0', - timestamp: new Date().toISOString(), - }; + const memUsage = process.memoryUsage(); + const heapUsedMB = Math.round(memUsage.heapUsed / 1024 / 1024); + const heapTotalMB = Math.round(memUsage.heapTotal / 1024 / 1024); + const heapUsedPercent = memUsage.heapUsed / memUsage.heapTotal; + + let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy'; + if (heapUsedPercent > 0.95) { + status = 'unhealthy'; + } else if (heapUsedPercent > 0.85) { + status = 'degraded'; + } + + return { + status, + uptime: process.uptime(), + memory: { + heapUsed: heapUsedMB, + heapTotal: heapTotalMB, + rss: Math.round(memUsage.rss / 1024 / 1024), + }, + groups: getGroupCount(), + version: process.env.npm_package_version || '1.0.0', + timestamp: new Date().toISOString(), + }; } // ============================================================================ @@ -79,30 +79,32 @@ function getHealthStatus(): HealthStatus { // ============================================================================ function handleRequest(req: IncomingMessage, res: ServerResponse): void { - const url = req.url || '/'; - - // CORS headers for browser access - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Content-Type', 'application/json'); - - if (url === '/health' || url === '/healthz') { - const health = getHealthStatus(); - res.statusCode = health.status === 'healthy' ? 200 : 503; - res.end(JSON.stringify(health, null, 2)); - } else if (url === '/ready' || url === '/readyz') { - // Readiness check - are we ready to accept traffic? - const ready = getGroupCount() > 0; - res.statusCode = ready ? 200 : 503; - res.end(JSON.stringify({ - ready, - groups: getGroupCount(), - activeContainers: getActiveContainers(), - })); - } else if (url === '/metrics') { - // Prometheus-style metrics - const health = getHealthStatus(); - res.setHeader('Content-Type', 'text/plain'); - res.end(`# HELP nanogemclaw_uptime_seconds Application uptime + const url = req.url || '/'; + + // CORS headers for browser access + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Content-Type', 'application/json'); + + if (url === '/health' || url === '/healthz') { + const health = getHealthStatus(); + res.statusCode = health.status === 'healthy' ? 200 : 503; + res.end(JSON.stringify(health, null, 2)); + } else if (url === '/ready' || url === '/readyz') { + // Readiness check - are we ready to accept traffic? + const ready = getGroupCount() > 0; + res.statusCode = ready ? 200 : 503; + res.end( + JSON.stringify({ + ready, + groups: getGroupCount(), + activeContainers: getActiveContainers(), + }), + ); + } else if (url === '/metrics') { + // Prometheus-style metrics + const health = getHealthStatus(); + res.setHeader('Content-Type', 'text/plain'); + res.end(`# HELP nanogemclaw_uptime_seconds Application uptime # TYPE nanogemclaw_uptime_seconds gauge nanogemclaw_uptime_seconds ${health.uptime.toFixed(0)} @@ -114,43 +116,40 @@ nanogemclaw_memory_heap_bytes ${health.memory.heapUsed * 1024 * 1024} # TYPE nanogemclaw_groups_total gauge nanogemclaw_groups_total ${health.groups} `); - } else { - res.statusCode = 404; - res.end(JSON.stringify({ error: 'Not found' })); - } + } else { + res.statusCode = 404; + res.end(JSON.stringify({ error: 'Not found' })); + } } let server: ReturnType | null = null; export function startHealthCheckServer(): void { - if (!HEALTH_CHECK.ENABLED) { - logger.info('Health check server disabled'); - return; - } + if (!HEALTH_CHECK.ENABLED) { + logger.info('Health check server disabled'); + return; + } - server = createServer(handleRequest); + server = createServer(handleRequest); - server.listen(HEALTH_CHECK.PORT, () => { - logger.info( - { port: HEALTH_CHECK.PORT }, - 'Health check server started', - ); - }); + server.listen(HEALTH_CHECK.PORT, () => { + logger.info({ port: HEALTH_CHECK.PORT }, 'Health check server started'); + }); - server.on('error', (err) => { - logger.error({ err }, 'Health check server error'); - }); + server.on('error', (err) => { + logger.error({ err }, 'Health check server error'); + }); } export function stopHealthCheckServer(): Promise { - return new Promise((resolve) => { - if (server) { - server.close(() => { - logger.info('Health check server stopped'); - resolve(); - }); - } else { - resolve(); - } - }); + return new Promise((resolve) => { + if (server) { + server.close(() => { + logger.info('Health check server stopped'); + resolve(); + }); + } else { + resolve(); + } + }); } diff --git a/src/i18n.ts b/src/i18n.ts index 716711b8f1a..e601b801bcf 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -1,6 +1,6 @@ /** * Internationalization (i18n) Module - * + * * Provides multi-language support for admin commands and system messages. */ @@ -11,40 +11,40 @@ export type Language = 'zh-TW' | 'en'; interface Translations { - // System messages - rateLimited: string; - retryIn: (minutes: number) => string; - noErrors: string; - noActiveErrors: string; - groupsWithErrors: string; - adminCommandsTitle: string; - adminOnlyNote: string; - - // Admin commands - statsTitle: string; - registeredGroups: string; - uptime: string; - memory: string; - usageAnalytics: string; - totalRequests: string; - avgResponseTime: string; - totalTokens: string; - - // Feedback - confirmed: string; - cancelled: string; - retrying: string; - thanksFeedback: string; - willImprove: string; - - // UI Phase 1 - processing: string; - downloadingMedia: string; - transcribing: string; - thinking: string; - retry: string; - feedback: string; - errorOccurred: string; + // System messages + rateLimited: string; + retryIn: (minutes: number) => string; + noErrors: string; + noActiveErrors: string; + groupsWithErrors: string; + adminCommandsTitle: string; + adminOnlyNote: string; + + // Admin commands + statsTitle: string; + registeredGroups: string; + uptime: string; + memory: string; + usageAnalytics: string; + totalRequests: string; + avgResponseTime: string; + totalTokens: string; + + // Feedback + confirmed: string; + cancelled: string; + retrying: string; + thanksFeedback: string; + willImprove: string; + + // UI Phase 1 + processing: string; + downloadingMedia: string; + transcribing: string; + thinking: string; + retry: string; + feedback: string; + errorOccurred: string; } // ============================================================================ @@ -52,70 +52,70 @@ interface Translations { // ============================================================================ const translations: Record = { - 'zh-TW': { - rateLimited: '⏳ 請求過於頻繁,請稍後再試。', - retryIn: (min) => `(${min} 分鐘後重試)`, - noErrors: '✅ **無錯誤**\n\n所有群組運作正常。', - noActiveErrors: '✅ **目前無錯誤**', - groupsWithErrors: '⚠️ **有錯誤的群組**', - adminCommandsTitle: '🛠️ **管理員指令**', - adminOnlyNote: '_管理員指令僅限主群組使用。_', - - statsTitle: '📊 **NanoGemClaw 統計**', - registeredGroups: '已註冊群組', - uptime: '運行時間', - memory: '記憶體', - usageAnalytics: '📈 **使用分析**', - totalRequests: '總請求數', - avgResponseTime: '平均回應時間', - totalTokens: 'Token 使用量', - - confirmed: '✅ 已確認', - cancelled: '❌ 已取消', - retrying: '🔄 重試中...', - thanksFeedback: '👍 感謝反饋!', - willImprove: '👎 收到,我會改進的!', - - processing: '處理中', - downloadingMedia: '下載媒體中', - transcribing: '轉錄語音中', - thinking: '思考中', - retry: '重試', - feedback: '反饋', - errorOccurred: '發生錯誤,請稍後再試。', - }, - 'en': { - rateLimited: '⏳ Too many requests, please try again later.', - retryIn: (min) => `(Retry in ${min} minutes)`, - noErrors: '✅ **No Errors**\n\nAll groups running smoothly.', - noActiveErrors: '✅ **No Active Errors**', - groupsWithErrors: '⚠️ **Groups with Errors**', - adminCommandsTitle: '🛠️ **Admin Commands**', - adminOnlyNote: '_Admin commands are only available in the main group._', - - statsTitle: '📊 **NanoGemClaw Stats**', - registeredGroups: 'Registered Groups', - uptime: 'Uptime', - memory: 'Memory', - usageAnalytics: '📈 **Usage Analytics**', - totalRequests: 'Total Requests', - avgResponseTime: 'Avg Response Time', - totalTokens: 'Total Tokens', - - confirmed: '✅ Confirmed', - cancelled: '❌ Cancelled', - retrying: '🔄 Retrying...', - thanksFeedback: '👍 Thanks for the feedback!', - willImprove: '👎 Got it, I\'ll improve!', - - processing: 'Processing', - downloadingMedia: 'Downloading media', - transcribing: 'Transcribing audio', - thinking: 'Thinking', - retry: 'Retry', - feedback: 'Feedback', - errorOccurred: 'An error occurred. Please try again.', - }, + 'zh-TW': { + rateLimited: '⏳ 請求過於頻繁,請稍後再試。', + retryIn: (min) => `(${min} 分鐘後重試)`, + noErrors: '✅ **無錯誤**\n\n所有群組運作正常。', + noActiveErrors: '✅ **目前無錯誤**', + groupsWithErrors: '⚠️ **有錯誤的群組**', + adminCommandsTitle: '🛠️ **管理員指令**', + adminOnlyNote: '_管理員指令僅限主群組使用。_', + + statsTitle: '📊 **NanoGemClaw 統計**', + registeredGroups: '已註冊群組', + uptime: '運行時間', + memory: '記憶體', + usageAnalytics: '📈 **使用分析**', + totalRequests: '總請求數', + avgResponseTime: '平均回應時間', + totalTokens: 'Token 使用量', + + confirmed: '✅ 已確認', + cancelled: '❌ 已取消', + retrying: '🔄 重試中...', + thanksFeedback: '👍 感謝反饋!', + willImprove: '👎 收到,我會改進的!', + + processing: '處理中', + downloadingMedia: '下載媒體中', + transcribing: '轉錄語音中', + thinking: '思考中', + retry: '重試', + feedback: '反饋', + errorOccurred: '發生錯誤,請稍後再試。', + }, + en: { + rateLimited: '⏳ Too many requests, please try again later.', + retryIn: (min) => `(Retry in ${min} minutes)`, + noErrors: '✅ **No Errors**\n\nAll groups running smoothly.', + noActiveErrors: '✅ **No Active Errors**', + groupsWithErrors: '⚠️ **Groups with Errors**', + adminCommandsTitle: '🛠️ **Admin Commands**', + adminOnlyNote: '_Admin commands are only available in the main group._', + + statsTitle: '📊 **NanoGemClaw Stats**', + registeredGroups: 'Registered Groups', + uptime: 'Uptime', + memory: 'Memory', + usageAnalytics: '📈 **Usage Analytics**', + totalRequests: 'Total Requests', + avgResponseTime: 'Avg Response Time', + totalTokens: 'Total Tokens', + + confirmed: '✅ Confirmed', + cancelled: '❌ Cancelled', + retrying: '🔄 Retrying...', + thanksFeedback: '👍 Thanks for the feedback!', + willImprove: "👎 Got it, I'll improve!", + + processing: 'Processing', + downloadingMedia: 'Downloading media', + transcribing: 'Transcribing audio', + thinking: 'Thinking', + retry: 'Retry', + feedback: 'Feedback', + errorOccurred: 'An error occurred. Please try again.', + }, }; // ============================================================================ @@ -129,19 +129,19 @@ let currentLanguage: Language = 'zh-TW'; // ============================================================================ export function setLanguage(lang: Language): void { - currentLanguage = lang; + currentLanguage = lang; } export function getLanguage(): Language { - return currentLanguage; + return currentLanguage; } export function t(): Translations { - return translations[currentLanguage]; + return translations[currentLanguage]; } export function getTranslation(lang: Language): Translations { - return translations[lang]; + return translations[lang]; } export const availableLanguages: Language[] = ['zh-TW', 'en']; diff --git a/src/image-gen.ts b/src/image-gen.ts index a1ce6f8e8d5..b3e42216cd2 100644 --- a/src/image-gen.ts +++ b/src/image-gen.ts @@ -19,201 +19,208 @@ const API_BASE = 'https://generativelanguage.googleapis.com/v1beta'; const OAUTH_CREDS_PATH = path.join(os.homedir(), '.gemini', 'oauth_creds.json'); interface ImageGenerationResult { - success: boolean; - imagePath?: string; - error?: string; + success: boolean; + imagePath?: string; + error?: string; } interface OAuthCreds { - access_token: string; - refresh_token?: string; - expires_at?: number; + access_token: string; + refresh_token?: string; + expires_at?: number; } interface GenerateContentResponse { - candidates?: Array<{ - content: { - parts: Array<{ - text?: string; - inlineData?: { - mimeType: string; - data: string; - }; - }>; + candidates?: Array<{ + content: { + parts: Array<{ + text?: string; + inlineData?: { + mimeType: string; + data: string; }; - }>; - error?: { - message: string; + }>; }; + }>; + error?: { + message: string; + }; } function readOAuthCreds(): OAuthCreds | null { - try { - if (!fs.existsSync(OAUTH_CREDS_PATH)) return null; - const raw = fs.readFileSync(OAUTH_CREDS_PATH, 'utf-8'); - const creds = JSON.parse(raw) as OAuthCreds; - if (!creds.access_token) return null; - return creds; - } catch { - return null; - } + try { + if (!fs.existsSync(OAUTH_CREDS_PATH)) return null; + const raw = fs.readFileSync(OAUTH_CREDS_PATH, 'utf-8'); + const creds = JSON.parse(raw) as OAuthCreds; + if (!creds.access_token) return null; + return creds; + } catch { + return null; + } } function isTokenExpired(creds: OAuthCreds): boolean { - if (!creds.expires_at) return false; - // Consider expired if less than 60s remaining - return Date.now() >= (creds.expires_at * 1000) - 60_000; + if (!creds.expires_at) return false; + // Consider expired if less than 60s remaining + return Date.now() >= creds.expires_at * 1000 - 60_000; } function refreshTokenViaCli(): Promise { - return new Promise((resolve) => { - const proc = spawn('gemini', ['-p', '.', '--output-format', 'text'], { - stdio: 'pipe', - timeout: 15_000, - }); - proc.on('close', () => resolve(true)); - proc.on('error', () => resolve(false)); + return new Promise((resolve) => { + const proc = spawn('gemini', ['-p', '.', '--output-format', 'text'], { + stdio: 'pipe', + timeout: 15_000, }); + proc.on('close', () => resolve(true)); + proc.on('error', () => resolve(false)); + }); } async function getAuth(): Promise<{ header: string; key: string } | null> { - // Priority 1: API key from environment - if (GEMINI_API_KEY) { - return { header: `x-goog-api-key`, key: GEMINI_API_KEY }; + // Priority 1: API key from environment + if (GEMINI_API_KEY) { + return { header: `x-goog-api-key`, key: GEMINI_API_KEY }; + } + + // Priority 2: OAuth token + let creds = readOAuthCreds(); + if (!creds) return null; + + if (isTokenExpired(creds)) { + logger.info('OAuth token expired, refreshing via Gemini CLI'); + const ok = await refreshTokenViaCli(); + if (!ok) { + logger.warn('Failed to refresh OAuth token via CLI'); + return null; } - - // Priority 2: OAuth token - let creds = readOAuthCreds(); + creds = readOAuthCreds(); if (!creds) return null; + } - if (isTokenExpired(creds)) { - logger.info('OAuth token expired, refreshing via Gemini CLI'); - const ok = await refreshTokenViaCli(); - if (!ok) { - logger.warn('Failed to refresh OAuth token via CLI'); - return null; - } - creds = readOAuthCreds(); - if (!creds) return null; - } - - return { header: 'Authorization', key: `Bearer ${creds.access_token}` }; + return { header: 'Authorization', key: `Bearer ${creds.access_token}` }; } /** * Generate an image using Gemini generateContent API */ export async function generateImage( - prompt: string, - outputDir: string, - _options: { - aspectRatio?: '1:1' | '16:9' | '9:16' | '4:3' | '3:4'; - numberOfImages?: number; - } = {}, + prompt: string, + outputDir: string, + _options: { + aspectRatio?: '1:1' | '16:9' | '9:16' | '4:3' | '3:4'; + numberOfImages?: number; + } = {}, ): Promise { - const auth = await getAuth(); - if (!auth) { - return { - success: false, - error: 'No authentication available (set GEMINI_API_KEY or login with gemini CLI)', - }; - } + const auth = await getAuth(); + if (!auth) { + return { + success: false, + error: + 'No authentication available (set GEMINI_API_KEY or login with gemini CLI)', + }; + } - const startTime = Date.now(); - const url = `${API_BASE}/models/${MODEL}:generateContent`; + const startTime = Date.now(); + const url = `${API_BASE}/models/${MODEL}:generateContent`; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 60_000); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 60_000); - try { - const headers: Record = { - 'Content-Type': 'application/json', - [auth.header]: auth.key, - }; + try { + const headers: Record = { + 'Content-Type': 'application/json', + [auth.header]: auth.key, + }; - const body = { - contents: [{ parts: [{ text: `Generate an image: ${prompt}` }] }], - generationConfig: { - responseModalities: ['IMAGE', 'TEXT'], - }, - }; + const body = { + contents: [{ parts: [{ text: `Generate an image: ${prompt}` }] }], + generationConfig: { + responseModalities: ['IMAGE', 'TEXT'], + }, + }; - const response = await fetch(url, { - method: 'POST', - headers, - signal: controller.signal, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`API error: ${response.status} - ${errorText.slice(0, 200)}`); - } - - const data = (await response.json()) as GenerateContentResponse; - - if (data.error) { - throw new Error(data.error.message); - } - - // Find the image part in response - const parts = data.candidates?.[0]?.content?.parts; - if (!parts) { - throw new Error('No content in response'); - } - - const imagePart = parts.find((p) => p.inlineData?.data); - if (!imagePart?.inlineData) { - throw new Error('No image generated in response'); - } - - const imageBuffer = Buffer.from(imagePart.inlineData.data, 'base64'); - const mimeType = imagePart.inlineData.mimeType || 'image/png'; - const ext = mimeType.includes('jpeg') || mimeType.includes('jpg') ? 'jpg' : 'png'; - - // Create output directory if needed - fs.mkdirSync(outputDir, { recursive: true }); - - // Generate unique filename - const timestamp = Date.now(); - const safePrompt = prompt.slice(0, 30).replace(/[^a-zA-Z0-9]/g, '_'); - const fileName = `gen_${timestamp}_${safePrompt}.${ext}`; - const filePath = path.join(outputDir, fileName); - - fs.writeFileSync(filePath, imageBuffer); - - logger.info( - { - duration: Date.now() - startTime, - prompt: prompt.slice(0, 50), - path: filePath, - authType: auth.header === 'Authorization' ? 'oauth' : 'apikey', - }, - 'Image generated', - ); - - return { - success: true, - imagePath: filePath, - }; - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - logger.error({ err, prompt: prompt.slice(0, 50) }, 'Failed to generate image'); + const response = await fetch(url, { + method: 'POST', + headers, + signal: controller.signal, + body: JSON.stringify(body), + }); - return { - success: false, - error: errorMessage, - }; - } finally { - clearTimeout(timeoutId); + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `API error: ${response.status} - ${errorText.slice(0, 200)}`, + ); + } + + const data = (await response.json()) as GenerateContentResponse; + + if (data.error) { + throw new Error(data.error.message); + } + + // Find the image part in response + const parts = data.candidates?.[0]?.content?.parts; + if (!parts) { + throw new Error('No content in response'); + } + + const imagePart = parts.find((p) => p.inlineData?.data); + if (!imagePart?.inlineData) { + throw new Error('No image generated in response'); } + + const imageBuffer = Buffer.from(imagePart.inlineData.data, 'base64'); + const mimeType = imagePart.inlineData.mimeType || 'image/png'; + const ext = + mimeType.includes('jpeg') || mimeType.includes('jpg') ? 'jpg' : 'png'; + + // Create output directory if needed + fs.mkdirSync(outputDir, { recursive: true }); + + // Generate unique filename + const timestamp = Date.now(); + const safePrompt = prompt.slice(0, 30).replace(/[^a-zA-Z0-9]/g, '_'); + const fileName = `gen_${timestamp}_${safePrompt}.${ext}`; + const filePath = path.join(outputDir, fileName); + + fs.writeFileSync(filePath, imageBuffer); + + logger.info( + { + duration: Date.now() - startTime, + prompt: prompt.slice(0, 50), + path: filePath, + authType: auth.header === 'Authorization' ? 'oauth' : 'apikey', + }, + 'Image generated', + ); + + return { + success: true, + imagePath: filePath, + }; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + logger.error( + { err, prompt: prompt.slice(0, 50) }, + 'Failed to generate image', + ); + + return { + success: false, + error: errorMessage, + }; + } finally { + clearTimeout(timeoutId); + } } /** * Check if image generation is available */ export function isImageGenAvailable(): boolean { - if (GEMINI_API_KEY) return true; - const creds = readOAuthCreds(); - return creds !== null; + if (GEMINI_API_KEY) return true; + const creds = readOAuthCreds(); + return creds !== null; } diff --git a/src/logger.ts b/src/logger.ts index 4a81620ca05..e4078c7d2f4 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -38,7 +38,8 @@ function maskSensitiveData(obj: unknown): unknown { if (Array.isArray(obj)) return obj.map(maskSensitiveData); const masked: Record = {}; for (const [k, v] of Object.entries(obj as Record)) { - masked[k] = SENSITIVE_KEYS.test(k) && typeof v === 'string' ? '[REDACTED]' : v; + masked[k] = + SENSITIVE_KEYS.test(k) && typeof v === 'string' ? '[REDACTED]' : v; } return masked; } diff --git a/src/memory-summarizer.ts b/src/memory-summarizer.ts index 90e941d36e9..df2780076ab 100644 --- a/src/memory-summarizer.ts +++ b/src/memory-summarizer.ts @@ -1,17 +1,17 @@ /** * Memory Summarizer Module - * + * * Handles automatic conversation summarization when context grows too large. * Uses Gemini to generate summaries and manages message archival. */ import { spawn } from 'child_process'; import { - getGroupMessageStats, - getMemorySummary, - getMessagesForSummary, - deleteOldMessages, - upsertMemorySummary, + getGroupMessageStats, + getMemorySummary, + getMessagesForSummary, + deleteOldMessages, + upsertMemorySummary, } from './db.js'; import { MEMORY } from './config.js'; import { logger } from './logger.js'; @@ -22,9 +22,9 @@ import type { RegisteredGroup } from './types.js'; // ============================================================================ interface SummaryResult { - summary: string; - messagesProcessed: number; - charsProcessed: number; + summary: string; + messagesProcessed: number; + charsProcessed: number; } // ============================================================================ @@ -35,36 +35,39 @@ interface SummaryResult { * Check if a group's conversation needs summarization */ export function needsSummarization(chatJid: string): boolean { - const stats = getGroupMessageStats(chatJid); - if (!stats) return false; + const stats = getGroupMessageStats(chatJid); + if (!stats) return false; - return ( - stats.total_chars >= MEMORY.SUMMARIZE_THRESHOLD_CHARS || - stats.message_count >= MEMORY.MAX_CONTEXT_MESSAGES - ); + return ( + stats.total_chars >= MEMORY.SUMMARIZE_THRESHOLD_CHARS || + stats.message_count >= MEMORY.MAX_CONTEXT_MESSAGES + ); } /** * Generate a summary of the conversation using Gemini CLI */ async function generateSummary( - messages: { sender_name: string; content: string; timestamp: string }[], + messages: { sender_name: string; content: string; timestamp: string }[], ): Promise { - // C7: Sanitize sender names to prevent control character injection - const conversationText = messages - .map((m) => { - const safeSenderName = m.sender_name.replace(/[\n\r\0\x08]/g, '').slice(0, 50); - return `[${safeSenderName}]: ${m.content}`; - }) - .join('\n'); - - // C7: Truncate prompt to prevent ARG_MAX issues (safe CLI arg limit ~100KB) - const MAX_PROMPT_LENGTH = 100000; - const truncatedConversation = conversationText.length > MAX_PROMPT_LENGTH - ? conversationText.slice(0, MAX_PROMPT_LENGTH) + '\n[...truncated...]' - : conversationText; - - const prompt = `${MEMORY.SUMMARY_PROMPT} + // C7: Sanitize sender names to prevent control character injection + const conversationText = messages + .map((m) => { + const safeSenderName = m.sender_name + .replace(/[\n\r\0\x08]/g, '') + .slice(0, 50); + return `[${safeSenderName}]: ${m.content}`; + }) + .join('\n'); + + // C7: Truncate prompt to prevent ARG_MAX issues (safe CLI arg limit ~100KB) + const MAX_PROMPT_LENGTH = 100000; + const truncatedConversation = + conversationText.length > MAX_PROMPT_LENGTH + ? conversationText.slice(0, MAX_PROMPT_LENGTH) + '\n[...truncated...]' + : conversationText; + + const prompt = `${MEMORY.SUMMARY_PROMPT} --- Conversation: @@ -73,131 +76,153 @@ ${truncatedConversation} Summary:`; - return new Promise((resolve, reject) => { - // H9: Use settled flag to prevent double-resolve - let settled = false; - let stdout = ''; - let stderr = ''; - - const model = process.env.GEMINI_MODEL || 'gemini-3-flash-preview'; - const gemini = spawn('gemini', ['--model', model, '-p', prompt, '--output-format', 'text'], { - env: { - ...process.env, - HOME: process.env.HOME, - }, - }); - - // H9: Store timeout ID for proper cleanup - const timeoutId = setTimeout(() => { - if (settled) return; - settled = true; - gemini.kill('SIGKILL'); - reject(new Error('Summary generation timed out')); - }, 60000); - - gemini.stdout.on('data', (data) => { - stdout += data.toString(); - }); - - gemini.stderr.on('data', (data) => { - stderr += data.toString(); - }); - - gemini.on('close', (code) => { - if (settled) return; - settled = true; - clearTimeout(timeoutId); - if (code === 0) { - resolve(stdout.trim()); - } else { - reject(new Error(`Gemini CLI failed (code ${code}): ${stderr || stdout}`)); - } - }); - - gemini.on('error', (err) => { - if (settled) return; - settled = true; - clearTimeout(timeoutId); - reject(new Error(`Gemini CLI spawn error: ${err.message}`)); - }); + return new Promise((resolve, reject) => { + // H9: Use settled flag to prevent double-resolve + let settled = false; + let stdout = ''; + let stderr = ''; + + const model = process.env.GEMINI_MODEL || 'gemini-3-flash-preview'; + const gemini = spawn( + 'gemini', + ['--model', model, '-p', prompt, '--output-format', 'text'], + { + env: { + ...process.env, + HOME: process.env.HOME, + }, + }, + ); + + // H9: Store timeout ID for proper cleanup + const timeoutId = setTimeout(() => { + if (settled) return; + settled = true; + gemini.kill('SIGKILL'); + reject(new Error('Summary generation timed out')); + }, 60000); + + gemini.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + gemini.stderr.on('data', (data) => { + stderr += data.toString(); }); + + gemini.on('close', (code) => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); + if (code === 0) { + resolve(stdout.trim()); + } else { + reject( + new Error(`Gemini CLI failed (code ${code}): ${stderr || stdout}`), + ); + } + }); + + gemini.on('error', (err) => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); + reject(new Error(`Gemini CLI spawn error: ${err.message}`)); + }); + }); } /** * Summarize a group's conversation and archive old messages */ export async function summarizeConversation( - group: RegisteredGroup, - chatJid: string, + group: RegisteredGroup, + chatJid: string, ): Promise { - const stats = getGroupMessageStats(chatJid); - if (!stats) { - logger.debug({ group: group.name }, 'No messages to summarize'); - return null; + const stats = getGroupMessageStats(chatJid); + if (!stats) { + logger.debug({ group: group.name }, 'No messages to summarize'); + return null; + } + + const messages = getMessagesForSummary(chatJid, MEMORY.MAX_CONTEXT_MESSAGES); + if (messages.length === 0) { + return null; + } + + const charsToArchive = messages.reduce((sum, m) => sum + m.content.length, 0); + + logger.info( + { group: group.name, messageCount: messages.length, chars: charsToArchive }, + 'Generating conversation summary', + ); + + try { + // Get existing summary to merge with + const existingSummary = getMemorySummary(group.folder); + + // Prepare context including previous summary + let contextMessages = messages; + if (existingSummary) { + // Prepend existing summary as context + contextMessages = [ + { + sender_name: 'PREVIOUS_SUMMARY', + content: existingSummary.summary, + timestamp: '', + }, + ...messages, + ]; } - const messages = getMessagesForSummary(chatJid, MEMORY.MAX_CONTEXT_MESSAGES); - if (messages.length === 0) { - return null; - } + const newSummary = await generateSummary(contextMessages); - const charsToArchive = messages.reduce((sum, m) => sum + m.content.length, 0); + // Get the timestamp of the last message to archive + const lastMessage = messages[messages.length - 1]; - logger.info( - { group: group.name, messageCount: messages.length, chars: charsToArchive }, - 'Generating conversation summary', + // Archive: delete old messages and save summary + const deletedCount = deleteOldMessages(chatJid, lastMessage.timestamp); + upsertMemorySummary( + group.folder, + newSummary, + messages.length, + charsToArchive, ); - try { - // Get existing summary to merge with - const existingSummary = getMemorySummary(group.folder); - - // Prepare context including previous summary - let contextMessages = messages; - if (existingSummary) { - // Prepend existing summary as context - contextMessages = [ - { sender_name: 'PREVIOUS_SUMMARY', content: existingSummary.summary, timestamp: '' }, - ...messages, - ]; - } - - const newSummary = await generateSummary(contextMessages); - - // Get the timestamp of the last message to archive - const lastMessage = messages[messages.length - 1]; - - // Archive: delete old messages and save summary - const deletedCount = deleteOldMessages(chatJid, lastMessage.timestamp); - upsertMemorySummary(group.folder, newSummary, messages.length, charsToArchive); - - logger.info( - { group: group.name, deletedMessages: deletedCount, summaryLength: newSummary.length }, - 'Conversation summarized and archived', - ); + logger.info( + { + group: group.name, + deletedMessages: deletedCount, + summaryLength: newSummary.length, + }, + 'Conversation summarized and archived', + ); - return { - summary: newSummary, - messagesProcessed: messages.length, - charsProcessed: charsToArchive, - }; - } catch (err) { - logger.error( - { group: group.name, err: err instanceof Error ? err.message : String(err) }, - 'Failed to summarize conversation', - ); - return null; - } + return { + summary: newSummary, + messagesProcessed: messages.length, + charsProcessed: charsToArchive, + }; + } catch (err) { + logger.error( + { + group: group.name, + err: err instanceof Error ? err.message : String(err), + }, + 'Failed to summarize conversation', + ); + return null; + } } /** * Get the memory context for a group (summary + stats) */ export function getMemoryContext(groupFolder: string): string | null { - const summary = getMemorySummary(groupFolder); - if (!summary) return null; + const summary = getMemorySummary(groupFolder); + if (!summary) return null; - return `[CONVERSATION HISTORY SUMMARY] + return `[CONVERSATION HISTORY SUMMARY] Last updated: ${summary.updated_at} Messages archived: ${summary.messages_archived} diff --git a/src/mount-security.ts b/src/mount-security.ts index c61be25913b..efab2d169a5 100644 --- a/src/mount-security.ts +++ b/src/mount-security.ts @@ -61,7 +61,7 @@ export function loadMountAllowlist(): MountAllowlist | null { logger.warn( { path: MOUNT_ALLOWLIST_PATH }, 'Mount allowlist not found - additional mounts will be BLOCKED. ' + - 'Create the file to enable additional mounts.', + 'Create the file to enable additional mounts.', ); return null; } diff --git a/src/notion.ts b/src/notion.ts index b0fd37a4691..295b69704d3 100644 --- a/src/notion.ts +++ b/src/notion.ts @@ -1,6 +1,6 @@ /** * Notion Integration (Stub) - * + * * Future implementation will handle: * - Syncing conversation history to Notion pages * - Creating task items in Notion databases @@ -10,37 +10,46 @@ import { logger } from './logger.js'; export class NotionService { - private static instance: NotionService; - private apiKey: string = ''; + private static instance: NotionService; + private apiKey: string = ''; - private constructor() { - this.apiKey = process.env.NOTION_API_KEY || ''; - } - - public static getInstance(): NotionService { - if (!NotionService.instance) { - NotionService.instance = new NotionService(); - } - return NotionService.instance; - } - - public isConfigured(): boolean { - return !!this.apiKey; - } - - public async syncConversation(chatId: string, summary: string): Promise { - if (!this.isConfigured()) return false; - - logger.warn({ chatId }, 'Notion sync not fully implemented'); - return false; - } - - public async createTask(title: string, status: string = 'To Do'): Promise { - if (!this.isConfigured()) return null; + private constructor() { + this.apiKey = process.env.NOTION_API_KEY || ''; + } - logger.warn({ title, status }, 'Notion task creation not fully implemented'); - return null; + public static getInstance(): NotionService { + if (!NotionService.instance) { + NotionService.instance = new NotionService(); } + return NotionService.instance; + } + + public isConfigured(): boolean { + return !!this.apiKey; + } + + public async syncConversation( + chatId: string, + summary: string, + ): Promise { + if (!this.isConfigured()) return false; + + logger.warn({ chatId }, 'Notion sync not fully implemented'); + return false; + } + + public async createTask( + title: string, + status: string = 'To Do', + ): Promise { + if (!this.isConfigured()) return null; + + logger.warn( + { title, status }, + 'Notion task creation not fully implemented', + ); + return null; + } } export const notionService = NotionService.getInstance(); diff --git a/src/personas.ts b/src/personas.ts index a90bfec6729..7be5bcd1b20 100644 --- a/src/personas.ts +++ b/src/personas.ts @@ -1,41 +1,46 @@ /** * Agent Persona Definitions - * + * * Provides pre-defined system prompts for different agent personalities. */ export interface Persona { - name: string; - description: string; - systemPrompt: string; + name: string; + description: string; + systemPrompt: string; } export const PERSONAS: Record = { - default: { - name: 'General Assistant', - description: 'Helpful and concise assistant (Default)', - systemPrompt: 'You are a helpful AI assistant. Answer concisely and accurately.', - }, - coder: { - name: 'Software Engineer', - description: 'Expert developer, focuses on code quality and patterns', - systemPrompt: 'You are an expert software engineer. Focus on clean code, best practices, and efficient algorithms. Provide code blocks for solutions.', - }, - translator: { - name: 'Translator', - description: 'Professional translator (EN/ZH)', - systemPrompt: 'You are a professional translator. Translate user input between English and Traditional Chinese (Taiwan). maintain nuance and tone.', - }, - writer: { - name: 'Creative Writer', - description: 'Creative writing aide for blogs and stories', - systemPrompt: 'You are a creative writer. Help draft engaging content, refine tone, and improve clarity. Use evocative language.', - }, - analyst: { - name: 'Data Analyst', - description: 'Logical thinker, breaks down complex problems', - systemPrompt: 'You are a data analyst. Approach problems logically. Break down complex issues into smaller steps. Focus on facts and data.', - } + default: { + name: 'General Assistant', + description: 'Helpful and concise assistant (Default)', + systemPrompt: + 'You are a helpful AI assistant. Answer concisely and accurately.', + }, + coder: { + name: 'Software Engineer', + description: 'Expert developer, focuses on code quality and patterns', + systemPrompt: + 'You are an expert software engineer. Focus on clean code, best practices, and efficient algorithms. Provide code blocks for solutions.', + }, + translator: { + name: 'Translator', + description: 'Professional translator (EN/ZH)', + systemPrompt: + 'You are a professional translator. Translate user input between English and Traditional Chinese (Taiwan). maintain nuance and tone.', + }, + writer: { + name: 'Creative Writer', + description: 'Creative writing aide for blogs and stories', + systemPrompt: + 'You are a creative writer. Help draft engaging content, refine tone, and improve clarity. Use evocative language.', + }, + analyst: { + name: 'Data Analyst', + description: 'Logical thinker, breaks down complex problems', + systemPrompt: + 'You are a data analyst. Approach problems logically. Break down complex issues into smaller steps. Focus on facts and data.', + }, }; /** @@ -43,16 +48,16 @@ export const PERSONAS: Record = { * Priority: Group Custom Prompt > Persona Prompt > Default Prompt */ export function getEffectiveSystemPrompt( - groupCustomPrompt?: string, - personaKey?: string + groupCustomPrompt?: string, + personaKey?: string, ): string { - if (groupCustomPrompt) { - return groupCustomPrompt; - } + if (groupCustomPrompt) { + return groupCustomPrompt; + } - if (personaKey && PERSONAS[personaKey]) { - return PERSONAS[personaKey].systemPrompt; - } + if (personaKey && PERSONAS[personaKey]) { + return PERSONAS[personaKey].systemPrompt; + } - return PERSONAS.default.systemPrompt; + return PERSONAS.default.systemPrompt; } diff --git a/src/server.ts b/src/server.ts index f06991c7631..74f8a906a25 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,7 +10,12 @@ import { GROUPS_DIR } from './config.js'; // Configuration const DASHBOARD_PORT = 3000; -const ALLOWED_ORIGINS = (process.env.DASHBOARD_ORIGINS || `http://localhost:${DASHBOARD_PORT},http://127.0.0.1:${DASHBOARD_PORT},http://localhost:5173,http://localhost:3001`).split(',').map(s => s.trim()); +const ALLOWED_ORIGINS = ( + process.env.DASHBOARD_ORIGINS || + `http://localhost:${DASHBOARD_PORT},http://127.0.0.1:${DASHBOARD_PORT},http://localhost:5173,http://localhost:3001` +) + .split(',') + .map((s) => s.trim()); const DASHBOARD_HOST = process.env.DASHBOARD_HOST || '127.0.0.1'; const DASHBOARD_API_KEY = process.env.DASHBOARD_API_KEY; @@ -19,788 +24,827 @@ let io: Server; let httpServer: ReturnType | null = null; let groupsProvider: () => any[] = () => []; let groupRegistrar: ((chatId: string, name: string) => any) | null = null; -let groupUpdater: ((folder: string, updates: Record) => any) | null = null; +let groupUpdater: + | ((folder: string, updates: Record) => any) + | null = null; // Path traversal protection const SAFE_FOLDER_RE = /^[a-zA-Z0-9_-]+$/; const SAFE_FILE_RE = /^[a-zA-Z0-9_.-]+$/; function validateFolder(folder: string): boolean { - return SAFE_FOLDER_RE.test(folder); + return SAFE_FOLDER_RE.test(folder); } /** * Detect LAN IP for 0.0.0.0 binds */ function getLanIp(): string | null { - const interfaces = os.networkInterfaces(); - for (const iface of Object.values(interfaces)) { - if (!iface) continue; - for (const addr of iface) { - if (addr.family === 'IPv4' && !addr.internal) { - return addr.address; - } - } + const interfaces = os.networkInterfaces(); + for (const iface of Object.values(interfaces)) { + if (!iface) continue; + for (const addr of iface) { + if (addr.family === 'IPv4' && !addr.internal) { + return addr.address; + } } - return null; + } + return null; } /** * Initialize the Web Dashboard Server */ export function startDashboardServer() { - const app = express(); - const server = createServer(app); - httpServer = server; - - // Middleware - app.use(cors({ - origin: (origin, callback) => { - if (!origin || ALLOWED_ORIGINS.includes(origin)) { - callback(null, true); - } else { - callback(new Error('Not allowed by CORS')); - } - } - })); - app.use(express.json()); - - // Optional API key authentication - if (DASHBOARD_API_KEY) { - app.use((req, res, next) => { - const apiKey = req.headers['x-api-key'] || req.query.apiKey; - if (apiKey !== DASHBOARD_API_KEY) { - res.status(401).json({ error: 'Unauthorized' }); - return; - } - next(); - }); + const app = express(); + const server = createServer(app); + httpServer = server; + + // Middleware + app.use( + cors({ + origin: (origin, callback) => { + if (!origin || ALLOWED_ORIGINS.includes(origin)) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS')); + } + }, + }), + ); + app.use(express.json()); + + // Optional API key authentication + if (DASHBOARD_API_KEY) { + app.use((req, res, next) => { + const apiKey = req.headers['x-api-key'] || req.query.apiKey; + if (apiKey !== DASHBOARD_API_KEY) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + next(); + }); + } + + // Socket.io Setup + io = new Server(server, { + cors: { + origin: ALLOWED_ORIGINS, + methods: ['GET', 'POST'], + }, + }); + + // Optional Socket.io API key authentication + if (DASHBOARD_API_KEY) { + io.use((socket, next) => { + const token = + socket.handshake.auth?.token || socket.handshake.query?.apiKey; + if (token !== DASHBOARD_API_KEY) { + next(new Error('Authentication required')); + return; + } + next(); + }); + } + + // ================================================================ + // Socket.io: Real-time connections + Log streaming + // ================================================================ + io.on('connection', (socket) => { + logger.info({ socketId: socket.id }, 'Dashboard client connected'); + + // Send initial state + socket.emit('groups:update', groupsProvider()); + + // Send log history + socket.emit('logs:history', getLogBuffer()); + + // Stream new log entries + const onLog = (entry: any) => { + socket.emit('logs:entry', entry); + }; + logEmitter.on('log', onLog); + + socket.on('disconnect', () => { + logEmitter.removeListener('log', onLog); + logger.info({ socketId: socket.id }, 'Dashboard client disconnected'); + }); + }); + + // ================================================================ + // REST API: Health + // ================================================================ + app.get('/api/health', (_req, res) => { + res.json({ status: 'ok', uptime: process.uptime() }); + }); + + // ================================================================ + // REST API: Groups + // ================================================================ + app.get('/api/groups', (_req, res) => { + const groups = groupsProvider ? groupsProvider() : []; + res.json({ groups }); + }); + + app.get('/api/groups/discover', async (_req, res) => { + try { + const { getAllChats } = await import('./db.js'); + const chats = getAllChats(); + res.json({ data: chats }); + } catch (err) { + res.status(500).json({ error: 'Failed to discover groups' }); } - - // Socket.io Setup - io = new Server(server, { - cors: { - origin: ALLOWED_ORIGINS, - methods: ["GET", "POST"] - } - }); - - // Optional Socket.io API key authentication - if (DASHBOARD_API_KEY) { - io.use((socket, next) => { - const token = socket.handshake.auth?.token || socket.handshake.query?.apiKey; - if (token !== DASHBOARD_API_KEY) { - next(new Error('Authentication required')); - return; - } - next(); - }); + }); + + app.post('/api/groups/:chatId/register', async (req, res) => { + try { + const { chatId } = req.params; + const { name } = req.body; + if (!name || typeof name !== 'string') { + res.status(400).json({ error: 'Name is required' }); + return; + } + if (!groupRegistrar) { + res.status(503).json({ error: 'Group registration not available' }); + return; + } + const result = groupRegistrar(chatId, name); + // Broadcast updated groups to all dashboard clients + io.emit('groups:update', groupsProvider()); + res.json({ data: result }); + } catch (err) { + res.status(500).json({ error: 'Registration failed' }); + } + }); + + // Get group detail by folder + app.get('/api/groups/:folder/detail', async (req, res) => { + const { folder } = req.params; + if (!validateFolder(folder)) { + res.status(400).json({ error: 'Invalid folder' }); + return; + } + try { + const { getTasksForGroup, getUsageStats, getErrorState } = + await import('./db.js'); + const groups = groupsProvider(); + const group = groups.find( + (g: any) => g.id === folder || g.folder === folder, + ); + if (!group) { + res.status(404).json({ error: 'Group not found' }); + return; + } + const tasks = getTasksForGroup(folder); + const usage = getUsageStats(folder); + const errorState = getErrorState(folder); + + res.json({ + data: { + ...group, + tasks, + usage, + errorState, + }, + }); + } catch (err) { + res.status(500).json({ error: 'Failed to fetch group detail' }); + } + }); + + // Update group settings + app.put('/api/groups/:folder', async (req, res) => { + const { folder } = req.params; + if (!validateFolder(folder)) { + res.status(400).json({ error: 'Invalid folder' }); + return; } - // ================================================================ - // Socket.io: Real-time connections + Log streaming - // ================================================================ - io.on('connection', (socket) => { - logger.info({ socketId: socket.id }, 'Dashboard client connected'); + if (!groupUpdater) { + res.status(503).json({ error: 'Group updater not available' }); + return; + } - // Send initial state - socket.emit('groups:update', groupsProvider()); + const { persona, enableWebSearch, requireTrigger, name } = req.body; - // Send log history - socket.emit('logs:history', getLogBuffer()); + // Validate persona if provided + if (persona !== undefined) { + const { PERSONAS } = await import('./personas.js'); + if (!PERSONAS[persona]) { + res.status(400).json({ error: `Invalid persona: ${persona}` }); + return; + } + } - // Stream new log entries - const onLog = (entry: any) => { - socket.emit('logs:entry', entry); - }; - logEmitter.on('log', onLog); + const updates: Record = {}; + if (persona !== undefined) updates.persona = persona; + if (enableWebSearch !== undefined) + updates.enableWebSearch = enableWebSearch; + if (requireTrigger !== undefined) updates.requireTrigger = requireTrigger; + if (name !== undefined) updates.name = name; + + try { + const result = groupUpdater(folder, updates); + if (!result) { + res.status(404).json({ error: 'Group not found' }); + return; + } + + // Broadcast update to all dashboard clients + io.emit('groups:update', groupsProvider()); + res.json({ data: result }); + } catch (err) { + res.status(500).json({ error: 'Failed to update group' }); + } + }); + + // Get available personas + app.get('/api/personas', async (_req, res) => { + try { + const { PERSONAS } = await import('./personas.js'); + res.json({ data: PERSONAS }); + } catch { + res.status(500).json({ error: 'Failed to fetch personas' }); + } + }); + + // ================================================================ + // REST API: Tasks + // ================================================================ + app.get('/api/tasks', async (_req, res) => { + try { + const { getAllTasks } = await import('./db.js'); + res.json({ data: getAllTasks() }); + } catch (err) { + res.status(500).json({ error: 'Failed to fetch tasks' }); + } + }); - socket.on('disconnect', () => { - logEmitter.removeListener('log', onLog); - logger.info({ socketId: socket.id }, 'Dashboard client disconnected'); + app.get('/api/tasks/group/:groupFolder', async (req, res) => { + const { groupFolder } = req.params; + if (!validateFolder(groupFolder)) { + res.status(400).json({ error: 'Invalid group folder' }); + return; + } + try { + const { getTasksForGroup } = await import('./db.js'); + res.json({ data: getTasksForGroup(groupFolder) }); + } catch (err) { + res.status(500).json({ error: 'Failed to fetch tasks' }); + } + }); + + // Create a new task + app.post('/api/tasks', async (req, res) => { + try { + const { createTask } = await import('./db.js'); + const { CronExpressionParser } = await import('cron-parser'); + + const { + group_folder, + prompt, + schedule_type, + schedule_value, + context_mode, + } = req.body; + + if (!group_folder || !prompt || !schedule_type || !schedule_value) { + res.status(400).json({ + error: + 'Missing required fields: group_folder, prompt, schedule_type, schedule_value', }); - }); - - // ================================================================ - // REST API: Health - // ================================================================ - app.get('/api/health', (_req, res) => { - res.json({ status: 'ok', uptime: process.uptime() }); - }); - - // ================================================================ - // REST API: Groups - // ================================================================ - app.get('/api/groups', (_req, res) => { - const groups = groupsProvider ? groupsProvider() : []; - res.json({ groups }); - }); - - app.get('/api/groups/discover', async (_req, res) => { - try { - const { getAllChats } = await import('./db.js'); - const chats = getAllChats(); - res.json({ data: chats }); - } catch (err) { - res.status(500).json({ error: 'Failed to discover groups' }); - } - }); - - app.post('/api/groups/:chatId/register', async (req, res) => { - try { - const { chatId } = req.params; - const { name } = req.body; - if (!name || typeof name !== 'string') { - res.status(400).json({ error: 'Name is required' }); - return; - } - if (!groupRegistrar) { - res.status(503).json({ error: 'Group registration not available' }); - return; - } - const result = groupRegistrar(chatId, name); - // Broadcast updated groups to all dashboard clients - io.emit('groups:update', groupsProvider()); - res.json({ data: result }); - } catch (err) { - res.status(500).json({ error: 'Registration failed' }); - } - }); - - // Get group detail by folder - app.get('/api/groups/:folder/detail', async (req, res) => { - const { folder } = req.params; - if (!validateFolder(folder)) { - res.status(400).json({ error: 'Invalid folder' }); - return; - } - try { - const { getTasksForGroup, getUsageStats, getErrorState } = await import('./db.js'); - const groups = groupsProvider(); - const group = groups.find((g: any) => g.id === folder || g.folder === folder); - if (!group) { - res.status(404).json({ error: 'Group not found' }); - return; - } - const tasks = getTasksForGroup(folder); - const usage = getUsageStats(folder); - const errorState = getErrorState(folder); - - res.json({ - data: { - ...group, - tasks, - usage, - errorState, - } - }); - } catch (err) { - res.status(500).json({ error: 'Failed to fetch group detail' }); - } - }); - - // Update group settings - app.put('/api/groups/:folder', async (req, res) => { - const { folder } = req.params; - if (!validateFolder(folder)) { - res.status(400).json({ error: 'Invalid folder' }); - return; - } - - if (!groupUpdater) { - res.status(503).json({ error: 'Group updater not available' }); - return; - } - - const { persona, enableWebSearch, requireTrigger, name } = req.body; - - // Validate persona if provided - if (persona !== undefined) { - const { PERSONAS } = await import('./personas.js'); - if (!PERSONAS[persona]) { - res.status(400).json({ error: `Invalid persona: ${persona}` }); - return; - } - } - - const updates: Record = {}; - if (persona !== undefined) updates.persona = persona; - if (enableWebSearch !== undefined) updates.enableWebSearch = enableWebSearch; - if (requireTrigger !== undefined) updates.requireTrigger = requireTrigger; - if (name !== undefined) updates.name = name; - - try { - const result = groupUpdater(folder, updates); - if (!result) { - res.status(404).json({ error: 'Group not found' }); - return; - } - - // Broadcast update to all dashboard clients - io.emit('groups:update', groupsProvider()); - res.json({ data: result }); - } catch (err) { - res.status(500).json({ error: 'Failed to update group' }); - } - }); - - // Get available personas - app.get('/api/personas', async (_req, res) => { - try { - const { PERSONAS } = await import('./personas.js'); - res.json({ data: PERSONAS }); - } catch { - res.status(500).json({ error: 'Failed to fetch personas' }); - } - }); - - // ================================================================ - // REST API: Tasks - // ================================================================ - app.get('/api/tasks', async (_req, res) => { - try { - const { getAllTasks } = await import('./db.js'); - res.json({ data: getAllTasks() }); - } catch (err) { - res.status(500).json({ error: 'Failed to fetch tasks' }); - } - }); - - app.get('/api/tasks/group/:groupFolder', async (req, res) => { - const { groupFolder } = req.params; - if (!validateFolder(groupFolder)) { - res.status(400).json({ error: 'Invalid group folder' }); - return; - } - try { - const { getTasksForGroup } = await import('./db.js'); - res.json({ data: getTasksForGroup(groupFolder) }); - } catch (err) { - res.status(500).json({ error: 'Failed to fetch tasks' }); - } - }); - - // Create a new task - app.post('/api/tasks', async (req, res) => { - try { - const { createTask } = await import('./db.js'); - const { CronExpressionParser } = await import('cron-parser'); - - const { group_folder, prompt, schedule_type, schedule_value, context_mode } = req.body; - - if (!group_folder || !prompt || !schedule_type || !schedule_value) { - res.status(400).json({ error: 'Missing required fields: group_folder, prompt, schedule_type, schedule_value' }); - return; - } - - if (!validateFolder(group_folder)) { - res.status(400).json({ error: 'Invalid group folder' }); - return; - } - - // Calculate next_run - let next_run: string | null = null; - if (schedule_type === 'cron') { - try { - const interval = CronExpressionParser.parse(schedule_value); - next_run = interval.next().toISOString(); - } catch { - res.status(400).json({ error: 'Invalid cron expression' }); - return; - } - } else if (schedule_type === 'interval') { - const ms = parseInt(schedule_value, 10); - if (isNaN(ms) || ms <= 0) { - res.status(400).json({ error: 'Invalid interval value' }); - return; - } - next_run = new Date(Date.now() + ms).toISOString(); - } else if (schedule_type === 'once') { - const scheduled = new Date(schedule_value); - if (isNaN(scheduled.getTime())) { - res.status(400).json({ error: 'Invalid date' }); - return; - } - next_run = scheduled.toISOString(); - } else { - res.status(400).json({ error: 'Invalid schedule_type. Must be: cron, interval, or once' }); - return; - } - - const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - createTask({ - id: taskId, - group_folder, - chat_jid: '', // Will be resolved by scheduler - prompt, - schedule_type, - schedule_value, - context_mode: context_mode || 'isolated', - next_run, - status: 'active', - created_at: new Date().toISOString(), - }); - - res.json({ data: { id: taskId } }); - } catch (err) { - res.status(500).json({ error: 'Failed to create task' }); - } - }); - - // Update a task - app.put('/api/tasks/:taskId', async (req, res) => { - try { - const { updateTask, getTaskById } = await import('./db.js'); - const { taskId } = req.params; - - const task = getTaskById(taskId); - if (!task) { - res.status(404).json({ error: 'Task not found' }); - return; - } - - const { prompt, schedule_type, schedule_value, status } = req.body; - const updates: Record = {}; - if (prompt !== undefined) updates.prompt = prompt; - if (schedule_type !== undefined) updates.schedule_type = schedule_type; - if (schedule_value !== undefined) updates.schedule_value = schedule_value; - if (status !== undefined) updates.status = status; - - // Recalculate next_run if schedule changed - if (schedule_type || schedule_value) { - const type = schedule_type || task.schedule_type; - const value = schedule_value || task.schedule_value; - - if (type === 'cron') { - const { CronExpressionParser } = await import('cron-parser'); - try { - const interval = CronExpressionParser.parse(value); - updates.next_run = interval.next().toISOString(); - } catch { - res.status(400).json({ error: 'Invalid cron expression' }); - return; - } - } else if (type === 'interval') { - const ms = parseInt(value, 10); - if (!isNaN(ms) && ms > 0) { - updates.next_run = new Date(Date.now() + ms).toISOString(); - } - } - } - - updateTask(taskId, updates); - res.json({ data: { success: true } }); - } catch (err) { - res.status(500).json({ error: 'Failed to update task' }); - } - }); - - // Delete a task - app.delete('/api/tasks/:taskId', async (req, res) => { - try { - const { deleteTask, getTaskById } = await import('./db.js'); - const { taskId } = req.params; - - const task = getTaskById(taskId); - if (!task) { - res.status(404).json({ error: 'Task not found' }); - return; - } - - deleteTask(taskId); - res.json({ data: { success: true } }); - } catch (err) { - res.status(500).json({ error: 'Failed to delete task' }); - } - }); - - // Update task status (pause/resume) - app.put('/api/tasks/:taskId/status', async (req, res) => { - try { - const { updateTask, getTaskById } = await import('./db.js'); - const { taskId } = req.params; - const { status } = req.body; - - if (!['active', 'paused'].includes(status)) { - res.status(400).json({ error: 'Status must be: active or paused' }); - return; - } - - const task = getTaskById(taskId); - if (!task) { - res.status(404).json({ error: 'Task not found' }); - return; - } - - updateTask(taskId, { status }); - res.json({ data: { success: true } }); - } catch (err) { - res.status(500).json({ error: 'Failed to update task status' }); - } - }); - - // Get task run logs - app.get('/api/tasks/:taskId/runs', async (req, res) => { - try { - const { getTaskRunLogs } = await import('./db.js'); - const { taskId } = req.params; - const limit = parseInt(req.query.limit as string) || 10; - - res.json({ data: getTaskRunLogs(taskId, limit) }); - } catch (err) { - res.status(500).json({ error: 'Failed to fetch task runs' }); - } - }); - - // ================================================================ - // REST API: Logs - // ================================================================ - app.get('/api/logs', (_req, res) => { - res.json({ data: getLogBuffer() }); - }); - - app.get('/api/logs/container/:group', (req, res) => { - const { group } = req.params; - if (!validateFolder(group)) { - res.status(400).json({ error: 'Invalid group folder' }); - return; - } - const logsDir = path.join(GROUPS_DIR, group, 'logs'); - try { - if (!fs.existsSync(logsDir)) { - res.json({ data: [] }); - return; - } - const files = fs.readdirSync(logsDir) - .filter(f => f.endsWith('.log')) - .sort() - .reverse(); - res.json({ data: files }); - } catch { - res.status(500).json({ error: 'Failed to list container logs' }); - } - }); - - app.get('/api/logs/container/:group/:file', (req, res) => { - const { group, file } = req.params; - if (!validateFolder(group) || !SAFE_FILE_RE.test(file)) { - res.status(400).json({ error: 'Invalid parameters' }); - return; - } - const filePath = path.join(GROUPS_DIR, group, 'logs', file); - // Double-check path is within expected directory - if (!path.resolve(filePath).startsWith(path.resolve(path.join(GROUPS_DIR, group, 'logs')))) { - res.status(403).json({ error: 'Access denied' }); - return; - } - try { - if (!fs.existsSync(filePath)) { - res.status(404).json({ error: 'Log file not found' }); - return; - } - const content = fs.readFileSync(filePath, 'utf-8'); - res.json({ data: { content } }); - } catch { - res.status(500).json({ error: 'Failed to read log file' }); - } - }); + return; + } - // ================================================================ - // REST API: Prompt & Memory - // ================================================================ - app.get('/api/prompt/:groupFolder', (req, res) => { - const { groupFolder } = req.params; - if (!validateFolder(groupFolder)) { - res.status(400).json({ error: 'Invalid group folder' }); - return; - } - const filePath = path.join(GROUPS_DIR, groupFolder, 'GEMINI.md'); - try { - if (!fs.existsSync(filePath)) { - res.json({ data: { content: '', mtime: 0 } }); - return; - } - const content = fs.readFileSync(filePath, 'utf-8'); - const stat = fs.statSync(filePath); - res.json({ data: { content, mtime: stat.mtimeMs } }); - } catch { - res.status(500).json({ error: 'Failed to read prompt' }); - } - }); + if (!validateFolder(group_folder)) { + res.status(400).json({ error: 'Invalid group folder' }); + return; + } - app.put('/api/prompt/:groupFolder', (req, res) => { - const { groupFolder } = req.params; - if (!validateFolder(groupFolder)) { - res.status(400).json({ error: 'Invalid group folder' }); - return; - } - const { content, expectedMtime } = req.body; - if (typeof content !== 'string') { - res.status(400).json({ error: 'Content is required' }); - return; - } - const filePath = path.join(GROUPS_DIR, groupFolder, 'GEMINI.md'); - const groupDir = path.join(GROUPS_DIR, groupFolder); - try { - // Optimistic locking: check mtime - if (expectedMtime && fs.existsSync(filePath)) { - const currentMtime = fs.statSync(filePath).mtimeMs; - if (Math.abs(currentMtime - expectedMtime) > 1) { - res.status(409).json({ error: 'File was modified by another process. Please reload and try again.' }); - return; - } - } - fs.mkdirSync(groupDir, { recursive: true }); - fs.writeFileSync(filePath, content, 'utf-8'); - const newStat = fs.statSync(filePath); - res.json({ data: { mtime: newStat.mtimeMs } }); - } catch { - res.status(500).json({ error: 'Failed to save prompt' }); - } - }); - - app.get('/api/memory/:groupFolder', async (req, res) => { - const { groupFolder } = req.params; - if (!validateFolder(groupFolder)) { - res.status(400).json({ error: 'Invalid group folder' }); - return; - } + // Calculate next_run + let next_run: string | null = null; + if (schedule_type === 'cron') { try { - const { getMemorySummary } = await import('./db.js'); - const summary = getMemorySummary(groupFolder); - res.json({ data: summary ?? null }); + const interval = CronExpressionParser.parse(schedule_value); + next_run = interval.next().toISOString(); } catch { - res.status(500).json({ error: 'Failed to fetch memory' }); - } - }); - - // ================================================================ - // REST API: Config - // ================================================================ - app.get('/api/config', async (_req, res) => { - try { - const { isMaintenanceMode } = await import('./maintenance.js'); - const currentLogLevel = process.env.LOG_LEVEL || 'info'; - - res.json({ - data: { - maintenanceMode: isMaintenanceMode(), - logLevel: currentLogLevel, - dashboardHost: DASHBOARD_HOST, - dashboardPort: DASHBOARD_PORT, - uptime: process.uptime(), - connectedClients: io ? io.engine.clientsCount : 0, - } - }); - } catch { - res.status(500).json({ error: 'Failed to fetch config' }); - } - }); - - app.put('/api/config', async (req, res) => { - try { - const { maintenanceMode, logLevel } = req.body; - const { setMaintenanceMode, isMaintenanceMode } = await import('./maintenance.js'); - - if (typeof maintenanceMode === 'boolean') { - setMaintenanceMode(maintenanceMode); - logger.info({ maintenanceMode }, 'Maintenance mode updated via dashboard'); - } - - if (typeof logLevel === 'string') { - setLogLevel(logLevel); - // Update process.env so GET /api/config reflects the change - process.env.LOG_LEVEL = logLevel; - logger.info({ logLevel }, 'Log level updated via dashboard'); - } - - res.json({ - data: { - maintenanceMode: isMaintenanceMode(), - logLevel: process.env.LOG_LEVEL || 'info', - } - }); - } catch { - res.status(500).json({ error: 'Failed to update config' }); - } - }); - - app.get('/api/config/secrets', (_req, res) => { - const secretKeys = [ - 'GEMINI_API_KEY', - 'TELEGRAM_BOT_TOKEN', - 'WEBHOOK_URL', - 'DASHBOARD_API_KEY', - ]; - - const secrets = secretKeys.map(key => { - const value = process.env[key]; - return { - key, - configured: !!value, - masked: value ? '***' + value.slice(-4) : null, - }; + res.status(400).json({ error: 'Invalid cron expression' }); + return; + } + } else if (schedule_type === 'interval') { + const ms = parseInt(schedule_value, 10); + if (isNaN(ms) || ms <= 0) { + res.status(400).json({ error: 'Invalid interval value' }); + return; + } + next_run = new Date(Date.now() + ms).toISOString(); + } else if (schedule_type === 'once') { + const scheduled = new Date(schedule_value); + if (isNaN(scheduled.getTime())) { + res.status(400).json({ error: 'Invalid date' }); + return; + } + next_run = scheduled.toISOString(); + } else { + res.status(400).json({ + error: 'Invalid schedule_type. Must be: cron, interval, or once', }); + return; + } + + const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + createTask({ + id: taskId, + group_folder, + chat_jid: '', // Will be resolved by scheduler + prompt, + schedule_type, + schedule_value, + context_mode: context_mode || 'isolated', + next_run, + status: 'active', + created_at: new Date().toISOString(), + }); + + res.json({ data: { id: taskId } }); + } catch (err) { + res.status(500).json({ error: 'Failed to create task' }); + } + }); + + // Update a task + app.put('/api/tasks/:taskId', async (req, res) => { + try { + const { updateTask, getTaskById } = await import('./db.js'); + const { taskId } = req.params; + + const task = getTaskById(taskId); + if (!task) { + res.status(404).json({ error: 'Task not found' }); + return; + } + + const { prompt, schedule_type, schedule_value, status } = req.body; + const updates: Record = {}; + if (prompt !== undefined) updates.prompt = prompt; + if (schedule_type !== undefined) updates.schedule_type = schedule_type; + if (schedule_value !== undefined) updates.schedule_value = schedule_value; + if (status !== undefined) updates.status = status; + + // Recalculate next_run if schedule changed + if (schedule_type || schedule_value) { + const type = schedule_type || task.schedule_type; + const value = schedule_value || task.schedule_value; + + if (type === 'cron') { + const { CronExpressionParser } = await import('cron-parser'); + try { + const interval = CronExpressionParser.parse(value); + updates.next_run = interval.next().toISOString(); + } catch { + res.status(400).json({ error: 'Invalid cron expression' }); + return; + } + } else if (type === 'interval') { + const ms = parseInt(value, 10); + if (!isNaN(ms) && ms > 0) { + updates.next_run = new Date(Date.now() + ms).toISOString(); + } + } + } + + updateTask(taskId, updates); + res.json({ data: { success: true } }); + } catch (err) { + res.status(500).json({ error: 'Failed to update task' }); + } + }); + + // Delete a task + app.delete('/api/tasks/:taskId', async (req, res) => { + try { + const { deleteTask, getTaskById } = await import('./db.js'); + const { taskId } = req.params; + + const task = getTaskById(taskId); + if (!task) { + res.status(404).json({ error: 'Task not found' }); + return; + } + + deleteTask(taskId); + res.json({ data: { success: true } }); + } catch (err) { + res.status(500).json({ error: 'Failed to delete task' }); + } + }); + + // Update task status (pause/resume) + app.put('/api/tasks/:taskId/status', async (req, res) => { + try { + const { updateTask, getTaskById } = await import('./db.js'); + const { taskId } = req.params; + const { status } = req.body; + + if (!['active', 'paused'].includes(status)) { + res.status(400).json({ error: 'Status must be: active or paused' }); + return; + } + + const task = getTaskById(taskId); + if (!task) { + res.status(404).json({ error: 'Task not found' }); + return; + } + + updateTask(taskId, { status }); + res.json({ data: { success: true } }); + } catch (err) { + res.status(500).json({ error: 'Failed to update task status' }); + } + }); + + // Get task run logs + app.get('/api/tasks/:taskId/runs', async (req, res) => { + try { + const { getTaskRunLogs } = await import('./db.js'); + const { taskId } = req.params; + const limit = parseInt(req.query.limit as string) || 10; + + res.json({ data: getTaskRunLogs(taskId, limit) }); + } catch (err) { + res.status(500).json({ error: 'Failed to fetch task runs' }); + } + }); + + // ================================================================ + // REST API: Logs + // ================================================================ + app.get('/api/logs', (_req, res) => { + res.json({ data: getLogBuffer() }); + }); + + app.get('/api/logs/container/:group', (req, res) => { + const { group } = req.params; + if (!validateFolder(group)) { + res.status(400).json({ error: 'Invalid group folder' }); + return; + } + const logsDir = path.join(GROUPS_DIR, group, 'logs'); + try { + if (!fs.existsSync(logsDir)) { + res.json({ data: [] }); + return; + } + const files = fs + .readdirSync(logsDir) + .filter((f) => f.endsWith('.log')) + .sort() + .reverse(); + res.json({ data: files }); + } catch { + res.status(500).json({ error: 'Failed to list container logs' }); + } + }); - res.json({ data: secrets }); - }); - - // ================================================================ - // REST API: Errors - // ================================================================ - app.get('/api/errors', async (_req, res) => { - try { - const { getAllErrorStates } = await import('./db.js'); - res.json({ data: getAllErrorStates() }); - } catch { - res.status(500).json({ error: 'Failed to fetch errors' }); - } - }); - - app.post('/api/errors/clear', async (_req, res) => { - try { - const { getAllErrorStates, resetErrors } = await import('./db.js'); - const errors = getAllErrorStates(); - for (const e of errors) { - resetErrors(e.group); - } - logger.info('All error states cleared via dashboard'); - res.json({ data: { cleared: errors.length } }); - } catch { - res.status(500).json({ error: 'Failed to clear errors' }); - } - }); - - // ================================================================ - // REST API: Usage - // ================================================================ - app.get('/api/usage', async (_req, res) => { - try { - const { getUsageStats } = await import('./db.js'); - res.json({ data: getUsageStats() }); - } catch { - res.status(500).json({ error: 'Failed to fetch usage' }); - } - }); - - app.get('/api/usage/recent', async (_req, res) => { - try { - const { getRecentUsage } = await import('./db.js'); - res.json({ data: getRecentUsage() }); - } catch { - res.status(500).json({ error: 'Failed to fetch recent usage' }); - } - }); - - // Usage timeseries - app.get('/api/usage/timeseries', async (req, res) => { - try { - const { getUsageTimeseries } = await import('./db.js'); - const period = (req.query.period as string) || '7d'; - const granularity = (req.query.granularity as string) || 'day'; - const groupFolder = req.query.groupFolder as string | undefined; - - if (groupFolder && !validateFolder(groupFolder)) { - res.status(400).json({ error: 'Invalid group folder' }); - return; - } - - res.json({ data: getUsageTimeseries(period, granularity, groupFolder) }); - } catch (err) { - res.status(500).json({ error: 'Failed to fetch usage timeseries' }); - } - }); - - // Usage by group - app.get('/api/usage/groups', async (req, res) => { - try { - const { getUsageByGroup } = await import('./db.js'); - const since = req.query.since as string | undefined; + app.get('/api/logs/container/:group/:file', (req, res) => { + const { group, file } = req.params; + if (!validateFolder(group) || !SAFE_FILE_RE.test(file)) { + res.status(400).json({ error: 'Invalid parameters' }); + return; + } + const filePath = path.join(GROUPS_DIR, group, 'logs', file); + // Double-check path is within expected directory + if ( + !path + .resolve(filePath) + .startsWith(path.resolve(path.join(GROUPS_DIR, group, 'logs'))) + ) { + res.status(403).json({ error: 'Access denied' }); + return; + } + try { + if (!fs.existsSync(filePath)) { + res.status(404).json({ error: 'Log file not found' }); + return; + } + const content = fs.readFileSync(filePath, 'utf-8'); + res.json({ data: { content } }); + } catch { + res.status(500).json({ error: 'Failed to read log file' }); + } + }); + + // ================================================================ + // REST API: Prompt & Memory + // ================================================================ + app.get('/api/prompt/:groupFolder', (req, res) => { + const { groupFolder } = req.params; + if (!validateFolder(groupFolder)) { + res.status(400).json({ error: 'Invalid group folder' }); + return; + } + const filePath = path.join(GROUPS_DIR, groupFolder, 'GEMINI.md'); + try { + if (!fs.existsSync(filePath)) { + res.json({ data: { content: '', mtime: 0 } }); + return; + } + const content = fs.readFileSync(filePath, 'utf-8'); + const stat = fs.statSync(filePath); + res.json({ data: { content, mtime: stat.mtimeMs } }); + } catch { + res.status(500).json({ error: 'Failed to read prompt' }); + } + }); - res.json({ data: getUsageByGroup(since) }); - } catch (err) { - res.status(500).json({ error: 'Failed to fetch usage by group' }); - } - }); + app.put('/api/prompt/:groupFolder', (req, res) => { + const { groupFolder } = req.params; + if (!validateFolder(groupFolder)) { + res.status(400).json({ error: 'Invalid group folder' }); + return; + } + const { content, expectedMtime } = req.body; + if (typeof content !== 'string') { + res.status(400).json({ error: 'Content is required' }); + return; + } + const filePath = path.join(GROUPS_DIR, groupFolder, 'GEMINI.md'); + const groupDir = path.join(GROUPS_DIR, groupFolder); + try { + // Optimistic locking: check mtime + if (expectedMtime && fs.existsSync(filePath)) { + const currentMtime = fs.statSync(filePath).mtimeMs; + if (Math.abs(currentMtime - expectedMtime) > 1) { + res.status(409).json({ + error: + 'File was modified by another process. Please reload and try again.', + }); + return; + } + } + fs.mkdirSync(groupDir, { recursive: true }); + fs.writeFileSync(filePath, content, 'utf-8'); + const newStat = fs.statSync(filePath); + res.json({ data: { mtime: newStat.mtimeMs } }); + } catch { + res.status(500).json({ error: 'Failed to save prompt' }); + } + }); - // ================================================================ - // Static file serving (production dashboard) - // ================================================================ - const dashboardDist = path.resolve(process.cwd(), 'dashboard', 'dist'); - if (fs.existsSync(dashboardDist)) { - app.use(express.static(dashboardDist)); - // SPA fallback: serve index.html for all non-API routes - app.get('{*path}', (_req, res) => { - res.sendFile(path.join(dashboardDist, 'index.html')); - }); - logger.info({ path: dashboardDist }, 'Serving dashboard static files'); - } - - // ================================================================ - // Start Listener - // ================================================================ - - // LAN access: auto-detect IP and add to allowed origins - if (DASHBOARD_HOST === '0.0.0.0') { - const lanIp = getLanIp(); - if (lanIp) { - const lanOrigin = `http://${lanIp}:${DASHBOARD_PORT}`; - if (!ALLOWED_ORIGINS.includes(lanOrigin)) { - ALLOWED_ORIGINS.push(lanOrigin); - } - console.log(`\n🌐 LAN URL: ${lanOrigin}`); - } + app.get('/api/memory/:groupFolder', async (req, res) => { + const { groupFolder } = req.params; + if (!validateFolder(groupFolder)) { + res.status(400).json({ error: 'Invalid group folder' }); + return; + } + try { + const { getMemorySummary } = await import('./db.js'); + const summary = getMemorySummary(groupFolder); + res.json({ data: summary ?? null }); + } catch { + res.status(500).json({ error: 'Failed to fetch memory' }); + } + }); + + // ================================================================ + // REST API: Config + // ================================================================ + app.get('/api/config', async (_req, res) => { + try { + const { isMaintenanceMode } = await import('./maintenance.js'); + const currentLogLevel = process.env.LOG_LEVEL || 'info'; + + res.json({ + data: { + maintenanceMode: isMaintenanceMode(), + logLevel: currentLogLevel, + dashboardHost: DASHBOARD_HOST, + dashboardPort: DASHBOARD_PORT, + uptime: process.uptime(), + connectedClients: io ? io.engine.clientsCount : 0, + }, + }); + } catch { + res.status(500).json({ error: 'Failed to fetch config' }); } + }); + + app.put('/api/config', async (req, res) => { + try { + const { maintenanceMode, logLevel } = req.body; + const { setMaintenanceMode, isMaintenanceMode } = + await import('./maintenance.js'); + + if (typeof maintenanceMode === 'boolean') { + setMaintenanceMode(maintenanceMode); + logger.info( + { maintenanceMode }, + 'Maintenance mode updated via dashboard', + ); + } + + if (typeof logLevel === 'string') { + setLogLevel(logLevel); + // Update process.env so GET /api/config reflects the change + process.env.LOG_LEVEL = logLevel; + logger.info({ logLevel }, 'Log level updated via dashboard'); + } + + res.json({ + data: { + maintenanceMode: isMaintenanceMode(), + logLevel: process.env.LOG_LEVEL || 'info', + }, + }); + } catch { + res.status(500).json({ error: 'Failed to update config' }); + } + }); + + app.get('/api/config/secrets', (_req, res) => { + const secretKeys = [ + 'GEMINI_API_KEY', + 'TELEGRAM_BOT_TOKEN', + 'WEBHOOK_URL', + 'DASHBOARD_API_KEY', + ]; + + const secrets = secretKeys.map((key) => { + const value = process.env[key]; + return { + key, + configured: !!value, + masked: value ? '***' + value.slice(-4) : null, + }; + }); + + res.json({ data: secrets }); + }); + + // ================================================================ + // REST API: Errors + // ================================================================ + app.get('/api/errors', async (_req, res) => { + try { + const { getAllErrorStates } = await import('./db.js'); + res.json({ data: getAllErrorStates() }); + } catch { + res.status(500).json({ error: 'Failed to fetch errors' }); + } + }); + + app.post('/api/errors/clear', async (_req, res) => { + try { + const { getAllErrorStates, resetErrors } = await import('./db.js'); + const errors = getAllErrorStates(); + for (const e of errors) { + resetErrors(e.group); + } + logger.info('All error states cleared via dashboard'); + res.json({ data: { cleared: errors.length } }); + } catch { + res.status(500).json({ error: 'Failed to clear errors' }); + } + }); + + // ================================================================ + // REST API: Usage + // ================================================================ + app.get('/api/usage', async (_req, res) => { + try { + const { getUsageStats } = await import('./db.js'); + res.json({ data: getUsageStats() }); + } catch { + res.status(500).json({ error: 'Failed to fetch usage' }); + } + }); + + app.get('/api/usage/recent', async (_req, res) => { + try { + const { getRecentUsage } = await import('./db.js'); + res.json({ data: getRecentUsage() }); + } catch { + res.status(500).json({ error: 'Failed to fetch recent usage' }); + } + }); + + // Usage timeseries + app.get('/api/usage/timeseries', async (req, res) => { + try { + const { getUsageTimeseries } = await import('./db.js'); + const period = (req.query.period as string) || '7d'; + const granularity = (req.query.granularity as string) || 'day'; + const groupFolder = req.query.groupFolder as string | undefined; + + if (groupFolder && !validateFolder(groupFolder)) { + res.status(400).json({ error: 'Invalid group folder' }); + return; + } + + res.json({ data: getUsageTimeseries(period, granularity, groupFolder) }); + } catch (err) { + res.status(500).json({ error: 'Failed to fetch usage timeseries' }); + } + }); - server.listen(DASHBOARD_PORT, DASHBOARD_HOST, () => { - console.log(`\n🌐 Dashboard Server running at http://${DASHBOARD_HOST}:${DASHBOARD_PORT}`); - logger.info({ port: DASHBOARD_PORT, host: DASHBOARD_HOST }, 'Dashboard server started'); - }); + // Usage by group + app.get('/api/usage/groups', async (req, res) => { + try { + const { getUsageByGroup } = await import('./db.js'); + const since = req.query.since as string | undefined; - return { app, io }; + res.json({ data: getUsageByGroup(since) }); + } catch (err) { + res.status(500).json({ error: 'Failed to fetch usage by group' }); + } + }); + + // ================================================================ + // Static file serving (production dashboard) + // ================================================================ + const dashboardDist = path.resolve(process.cwd(), 'dashboard', 'dist'); + if (fs.existsSync(dashboardDist)) { + app.use(express.static(dashboardDist)); + // SPA fallback: serve index.html for all non-API routes + app.get('{*path}', (_req, res) => { + res.sendFile(path.join(dashboardDist, 'index.html')); + }); + logger.info({ path: dashboardDist }, 'Serving dashboard static files'); + } + + // ================================================================ + // Start Listener + // ================================================================ + + // LAN access: auto-detect IP and add to allowed origins + if (DASHBOARD_HOST === '0.0.0.0') { + const lanIp = getLanIp(); + if (lanIp) { + const lanOrigin = `http://${lanIp}:${DASHBOARD_PORT}`; + if (!ALLOWED_ORIGINS.includes(lanOrigin)) { + ALLOWED_ORIGINS.push(lanOrigin); + } + console.log(`\n🌐 LAN URL: ${lanOrigin}`); + } + } + + server.listen(DASHBOARD_PORT, DASHBOARD_HOST, () => { + console.log( + `\n🌐 Dashboard Server running at http://${DASHBOARD_HOST}:${DASHBOARD_PORT}`, + ); + logger.info( + { port: DASHBOARD_PORT, host: DASHBOARD_HOST }, + 'Dashboard server started', + ); + }); + + return { app, io }; } /** * Stop the dashboard server gracefully */ export function stopDashboardServer(): void { - if (io) { - io.close(); - } - if (httpServer) { - httpServer.close(); - httpServer = null; - } - logger.info('Dashboard server stopped'); + if (io) { + io.close(); + } + if (httpServer) { + httpServer.close(); + httpServer = null; + } + logger.info('Dashboard server stopped'); } /** * Inject the data source for groups */ export function setGroupsProvider(provider: () => any[]) { - groupsProvider = provider; + groupsProvider = provider; } /** * Inject the group registration function */ export function setGroupRegistrar(fn: (chatId: string, name: string) => any) { - groupRegistrar = fn; + groupRegistrar = fn; } /** * Inject the group update function */ -export function setGroupUpdater(fn: (folder: string, updates: Record) => any) { - groupUpdater = fn; +export function setGroupUpdater( + fn: (folder: string, updates: Record) => any, +) { + groupUpdater = fn; } /** * Emit a real-time event to the dashboard */ export function emitDashboardEvent(event: string, data: any) { - if (io) { - io.emit(event, data); - } + if (io) { + io.emit(event, data); + } } diff --git a/src/stt.ts b/src/stt.ts index ae64625e621..e5ed51bd3b2 100644 --- a/src/stt.ts +++ b/src/stt.ts @@ -18,84 +18,92 @@ const GCP_CREDENTIALS_PATH = process.env.GOOGLE_APPLICATION_CREDENTIALS || ''; * Convert OGG/Opus to linear16 WAV for GCP Speech API */ async function convertToWav(inputPath: string): Promise { - const outputPath = inputPath.replace(/\.[^.]+$/, '.wav'); - - return new Promise((resolve, reject) => { - const ffmpeg = spawn('ffmpeg', [ - '-i', inputPath, - '-ar', '16000', // 16kHz sample rate - '-ac', '1', // Mono - '-f', 'wav', - '-y', // Overwrite - outputPath, - ]); - - let stderr = ''; - ffmpeg.stderr.on('data', (data) => { - stderr += data.toString(); - }); - - ffmpeg.on('close', (code) => { - if (code !== 0) { - reject(new Error(`ffmpeg exited with code ${code}: ${stderr.slice(-200)}`)); - } else { - resolve(outputPath); - } - }); - - ffmpeg.on('error', reject); + const outputPath = inputPath.replace(/\.[^.]+$/, '.wav'); + + return new Promise((resolve, reject) => { + const ffmpeg = spawn('ffmpeg', [ + '-i', + inputPath, + '-ar', + '16000', // 16kHz sample rate + '-ac', + '1', // Mono + '-f', + 'wav', + '-y', // Overwrite + outputPath, + ]); + + let stderr = ''; + ffmpeg.stderr.on('data', (data) => { + stderr += data.toString(); }); + + ffmpeg.on('close', (code) => { + if (code !== 0) { + reject( + new Error(`ffmpeg exited with code ${code}: ${stderr.slice(-200)}`), + ); + } else { + resolve(outputPath); + } + }); + + ffmpeg.on('error', reject); + }); } /** * Transcribe audio using Google Cloud Speech-to-Text V2 */ async function transcribeWithGCP(audioPath: string): Promise { - // Dynamic import to avoid requiring the package if not used - const { SpeechClient } = await import('@google-cloud/speech'); + // Dynamic import to avoid requiring the package if not used + const { SpeechClient } = await import('@google-cloud/speech'); + + const client = new SpeechClient(); + + // Convert to WAV if needed + let wavPath = audioPath; + if (!audioPath.endsWith('.wav')) { + wavPath = await convertToWav(audioPath); + } + + try { + // Check file size before reading into memory + const MAX_AUDIO_SIZE = 25 * 1024 * 1024; // 25MB limit + const fileStats = fs.statSync(wavPath); + if (fileStats.size > MAX_AUDIO_SIZE) { + throw new Error( + `Audio file too large (${Math.round(fileStats.size / 1024 / 1024)}MB). Maximum supported size is ${MAX_AUDIO_SIZE / 1024 / 1024}MB.`, + ); + } - const client = new SpeechClient(); + const audioBytes = fs.readFileSync(wavPath).toString('base64'); + + const [response] = await client.recognize({ + config: { + encoding: 'LINEAR16' as const, + sampleRateHertz: 16000, + languageCode: 'zh-TW', + alternativeLanguageCodes: ['en-US', 'ja-JP'], + }, + audio: { + content: audioBytes, + }, + }); - // Convert to WAV if needed - let wavPath = audioPath; - if (!audioPath.endsWith('.wav')) { - wavPath = await convertToWav(audioPath); - } + const transcription = response.results + ?.map((result) => result.alternatives?.[0]?.transcript) + .filter(Boolean) + .join(' '); - try { - // Check file size before reading into memory - const MAX_AUDIO_SIZE = 25 * 1024 * 1024; // 25MB limit - const fileStats = fs.statSync(wavPath); - if (fileStats.size > MAX_AUDIO_SIZE) { - throw new Error(`Audio file too large (${Math.round(fileStats.size / 1024 / 1024)}MB). Maximum supported size is ${MAX_AUDIO_SIZE / 1024 / 1024}MB.`); - } - - const audioBytes = fs.readFileSync(wavPath).toString('base64'); - - const [response] = await client.recognize({ - config: { - encoding: 'LINEAR16' as const, - sampleRateHertz: 16000, - languageCode: 'zh-TW', - alternativeLanguageCodes: ['en-US', 'ja-JP'], - }, - audio: { - content: audioBytes, - }, - }); - - const transcription = response.results - ?.map((result) => result.alternatives?.[0]?.transcript) - .filter(Boolean) - .join(' '); - - return transcription || ''; - } finally { - // Always clean up temp WAV file - if (wavPath !== audioPath && fs.existsSync(wavPath)) { - fs.unlinkSync(wavPath); - } + return transcription || ''; + } finally { + // Always clean up temp WAV file + if (wavPath !== audioPath && fs.existsSync(wavPath)) { + fs.unlinkSync(wavPath); } + } } /** @@ -106,63 +114,73 @@ async function transcribeWithGCP(audioPath: string): Promise { * as part of the multimodal prompt. */ async function transcribeWithGemini(audioPath: string): Promise { - logger.info({ audioPath }, 'Gemini multimodal transcription (pass-through mode)'); - - // In pass-through mode, we don't transcribe here. - // Instead, the audio file path is passed to the container, - // and Gemini handles it natively with multimodal input. - return `[Voice message: ${path.basename(audioPath)}]`; + logger.info( + { audioPath }, + 'Gemini multimodal transcription (pass-through mode)', + ); + + // In pass-through mode, we don't transcribe here. + // Instead, the audio file path is passed to the container, + // and Gemini handles it natively with multimodal input. + return `[Voice message: ${path.basename(audioPath)}]`; } /** * Main transcription function */ export async function transcribeAudio(audioPath: string): Promise { - if (!fs.existsSync(audioPath)) { - throw new Error(`Audio file not found: ${audioPath}`); + if (!fs.existsSync(audioPath)) { + throw new Error(`Audio file not found: ${audioPath}`); + } + + const startTime = Date.now(); + + try { + let transcription: string; + + if (STT_PROVIDER === 'gcp' && GCP_CREDENTIALS_PATH) { + transcription = await transcribeWithGCP(audioPath); + logger.info( + { + duration: Date.now() - startTime, + provider: 'gcp', + length: transcription.length, + }, + 'Audio transcribed', + ); + } else { + // Default: pass-through to Gemini multimodal + transcription = await transcribeWithGemini(audioPath); + logger.info( + { duration: Date.now() - startTime, provider: 'gemini' }, + 'Audio transcription (pass-through)', + ); } - const startTime = Date.now(); - - try { - let transcription: string; - - if (STT_PROVIDER === 'gcp' && GCP_CREDENTIALS_PATH) { - transcription = await transcribeWithGCP(audioPath); - logger.info( - { duration: Date.now() - startTime, provider: 'gcp', length: transcription.length }, - 'Audio transcribed', - ); - } else { - // Default: pass-through to Gemini multimodal - transcription = await transcribeWithGemini(audioPath); - logger.info( - { duration: Date.now() - startTime, provider: 'gemini' }, - 'Audio transcription (pass-through)', - ); - } - - return transcription; - } catch (err) { - logger.error({ err, audioPath }, 'Failed to transcribe audio'); - return '[Voice message - transcription failed]'; - } + return transcription; + } catch (err) { + logger.error({ err, audioPath }, 'Failed to transcribe audio'); + return '[Voice message - transcription failed]'; + } } /** * Check if ffmpeg is available on the system */ export async function checkFFmpegAvailability(): Promise { - return new Promise((resolve) => { - const check = spawn('ffmpeg', ['-version']); - check.on('error', () => resolve(false)); - check.on('close', (code) => resolve(code === 0)); - }); + return new Promise((resolve) => { + const check = spawn('ffmpeg', ['-version']); + check.on('error', () => resolve(false)); + check.on('close', (code) => resolve(code === 0)); + }); } /** * Check if STT is available */ export function isSTTAvailable(): boolean { - return STT_PROVIDER === 'gemini' || (STT_PROVIDER === 'gcp' && !!GCP_CREDENTIALS_PATH); + return ( + STT_PROVIDER === 'gemini' || + (STT_PROVIDER === 'gcp' && !!GCP_CREDENTIALS_PATH) + ); } diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index fe8a0c922ff..56de0c94072 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -151,7 +151,9 @@ async function runTask( updateTaskAfterRun(task.id, nextRun, resultSummary); } -export function startSchedulerLoop(deps: SchedulerDependencies): { stop: () => void } { +export function startSchedulerLoop(deps: SchedulerDependencies): { + stop: () => void; +} { logger.info('Scheduler loop started'); let stopped = false; @@ -189,7 +191,10 @@ export function startSchedulerLoop(deps: SchedulerDependencies): { stop: () => v await runTask(currentTask, deps); } catch (taskErr) { logger.error( - { taskId: task.id, err: taskErr instanceof Error ? taskErr.message : String(taskErr) }, + { + taskId: task.id, + err: taskErr instanceof Error ? taskErr.message : String(taskErr), + }, 'Task execution failed (isolated)', ); } diff --git a/src/task-tracker.ts b/src/task-tracker.ts index a21cc5231b2..6504abe1d2d 100644 --- a/src/task-tracker.ts +++ b/src/task-tracker.ts @@ -1,6 +1,6 @@ /** * Task Tracking Module - * + * * Manages multi-turn tasks where the agent needs to perform multiple steps * to complete a user request. Tracks state, turns, and context. */ @@ -9,15 +9,15 @@ import { TASK_TRACKING } from './config.js'; import { logger } from './logger.js'; export interface TaskState { - id: string; - chatId: string; - description: string; - status: 'active' | 'completed' | 'failed' | 'cancelled'; - turnCount: number; - maxTurns: number; - history: string[]; - createdAt: string; - updatedAt: string; + id: string; + chatId: string; + description: string; + status: 'active' | 'completed' | 'failed' | 'cancelled'; + turnCount: number; + maxTurns: number; + history: string[]; + createdAt: string; + updatedAt: string; } const activeTasks = new Map(); @@ -26,70 +26,78 @@ const activeTasks = new Map(); * Create a new specific task tracking session */ export function createTask(chatId: string, description: string): TaskState { - // Check for existing active task - const existing = activeTasks.get(chatId); - if (existing && existing.status === 'active') { - // Fail the existing task before creating a new one - existing.status = 'failed'; - existing.history.push(`${new Date().toISOString()}: Superseded by new task`); - existing.updatedAt = new Date().toISOString(); - logger.warn({ chatId, oldTaskId: existing.id }, 'Existing task superseded by new task'); - } - - const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; - - const task: TaskState = { - id, - chatId, - description, - status: 'active', - turnCount: 0, - maxTurns: TASK_TRACKING.MAX_TURNS, - history: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - activeTasks.set(chatId, task); - logger.info({ chatId, taskId: id }, 'New multi-turn task created'); - return task; + // Check for existing active task + const existing = activeTasks.get(chatId); + if (existing && existing.status === 'active') { + // Fail the existing task before creating a new one + existing.status = 'failed'; + existing.history.push( + `${new Date().toISOString()}: Superseded by new task`, + ); + existing.updatedAt = new Date().toISOString(); + logger.warn( + { chatId, oldTaskId: existing.id }, + 'Existing task superseded by new task', + ); + } + + const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; + + const task: TaskState = { + id, + chatId, + description, + status: 'active', + turnCount: 0, + maxTurns: TASK_TRACKING.MAX_TURNS, + history: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + activeTasks.set(chatId, task); + logger.info({ chatId, taskId: id }, 'New multi-turn task created'); + return task; } /** * Get active task for a chat */ export function getActiveTask(chatId: string): TaskState | undefined { - return activeTasks.get(chatId); + return activeTasks.get(chatId); } /** * Update task progress */ -export function updateTask(chatId: string, action: string): TaskState | undefined { - const task = activeTasks.get(chatId); - if (!task) return undefined; - - task.turnCount++; - task.history.push(`${new Date().toISOString()}: ${action}`); - task.updatedAt = new Date().toISOString(); - - if (task.turnCount >= task.maxTurns) { - logger.warn({ chatId, taskId: task.id }, 'Task reached max turns'); - } - - return task; +export function updateTask( + chatId: string, + action: string, +): TaskState | undefined { + const task = activeTasks.get(chatId); + if (!task) return undefined; + + task.turnCount++; + task.history.push(`${new Date().toISOString()}: ${action}`); + task.updatedAt = new Date().toISOString(); + + if (task.turnCount >= task.maxTurns) { + logger.warn({ chatId, taskId: task.id }, 'Task reached max turns'); + } + + return task; } /** * Complete a task */ export function completeTask(chatId: string): void { - const task = activeTasks.get(chatId); - if (task) { - task.status = 'completed'; - logger.info({ chatId, taskId: task.id }, 'Task completed'); - activeTasks.delete(chatId); - } + const task = activeTasks.get(chatId); + if (task) { + task.status = 'completed'; + logger.info({ chatId, taskId: task.id }, 'Task completed'); + activeTasks.delete(chatId); + } } /** @@ -99,42 +107,42 @@ export function completeTask(chatId: string): void { * Cancel/Fail a task */ export function failTask(chatId: string, reason: string): void { - const task = activeTasks.get(chatId); - if (task) { - task.status = 'failed'; - logger.info({ chatId, taskId: task.id, reason }, 'Task failed'); - activeTasks.delete(chatId); - } + const task = activeTasks.get(chatId); + if (task) { + task.status = 'failed'; + logger.info({ chatId, taskId: task.id, reason }, 'Task failed'); + activeTasks.delete(chatId); + } } /** * Cleanup stale tasks */ export function cleanupStaleTasks(): void { - const now = Date.now(); - const STALE_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes - let cleanupCount = 0; - - for (const [chatId, task] of activeTasks.entries()) { - const lastUpdate = new Date(task.updatedAt).getTime(); - if (now - lastUpdate > STALE_THRESHOLD_MS) { - activeTasks.delete(chatId); - cleanupCount++; - logger.info({ chatId, taskId: task.id }, 'Stale task cleaned up'); - } + const now = Date.now(); + const STALE_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes + let cleanupCount = 0; + + for (const [chatId, task] of activeTasks.entries()) { + const lastUpdate = new Date(task.updatedAt).getTime(); + if (now - lastUpdate > STALE_THRESHOLD_MS) { + activeTasks.delete(chatId); + cleanupCount++; + logger.info({ chatId, taskId: task.id }, 'Stale task cleaned up'); } + } - if (cleanupCount > 0) { - logger.info({ cleanupCount }, 'Task cleanup completed'); - } + if (cleanupCount > 0) { + logger.info({ cleanupCount }, 'Task cleanup completed'); + } } /** * Start task cleanup scheduler */ export function startTaskCleanupScheduler(): NodeJS.Timeout { - // Check every 10 minutes - const intervalId = setInterval(cleanupStaleTasks, 10 * 60 * 1000); - logger.info({}, 'Task cleanup scheduler started'); - return intervalId; + // Check every 10 minutes + const intervalId = setInterval(cleanupStaleTasks, 10 * 60 * 1000); + logger.info({}, 'Task cleanup scheduler started'); + return intervalId; } diff --git a/src/telegram-setup.ts b/src/telegram-setup.ts index e5799e0af0e..b81fbac80d8 100644 --- a/src/telegram-setup.ts +++ b/src/telegram-setup.ts @@ -15,47 +15,102 @@ import TelegramBot from 'node-telegram-bot-api'; const token = process.env.TELEGRAM_BOT_TOKEN; if (!token) { - console.error('╔══════════════════════════════════════════════════════════════╗'); - console.error('║ ERROR: TELEGRAM_BOT_TOKEN environment variable not set ║'); - console.error('╟──────────────────────────────────────────────────────────────╢'); - console.error('║ To fix: ║'); - console.error('║ 1. Open Telegram and message @BotFather ║'); - console.error('║ 2. Send /newbot and follow the prompts ║'); - console.error('║ 3. Copy the token and create .env file: ║'); - console.error('║ echo "TELEGRAM_BOT_TOKEN=your_token_here" > .env ║'); - console.error('║ 4. Run this script again ║'); - console.error('╚══════════════════════════════════════════════════════════════╝'); - process.exit(1); + console.error( + '╔══════════════════════════════════════════════════════════════╗', + ); + console.error( + '║ ERROR: TELEGRAM_BOT_TOKEN environment variable not set ║', + ); + console.error( + '╟──────────────────────────────────────────────────────────────╢', + ); + console.error( + '║ To fix: ║', + ); + console.error( + '║ 1. Open Telegram and message @BotFather ║', + ); + console.error( + '║ 2. Send /newbot and follow the prompts ║', + ); + console.error( + '║ 3. Copy the token and create .env file: ║', + ); + console.error( + '║ echo "TELEGRAM_BOT_TOKEN=your_token_here" > .env ║', + ); + console.error( + '║ 4. Run this script again ║', + ); + console.error( + '╚══════════════════════════════════════════════════════════════╝', + ); + process.exit(1); } console.log('Verifying Telegram bot token...\n'); const bot = new TelegramBot(token, { polling: false }); -bot.getMe() - .then((me) => { - console.log('╔══════════════════════════════════════════════════════════════╗'); - console.log('║ ✓ Bot token is valid! ║'); - console.log('╟──────────────────────────────────────────────────────────────╢'); - console.log(`║ Bot Username: @${me.username?.padEnd(43)}║`); - console.log(`║ Bot ID: ${me.id.toString().padEnd(50)}║`); - console.log('╟──────────────────────────────────────────────────────────────╢'); - console.log('║ Next steps: ║'); - console.log('║ 1. Add the bot to your Telegram group ║'); - console.log('║ 2. Make it an admin (so it can read messages) ║'); - console.log('║ 3. Run: npm run dev ║'); - console.log('║ 4. Send @Andy hello in your group ║'); - console.log('╚══════════════════════════════════════════════════════════════╝'); - process.exit(0); - }) - .catch((err) => { - console.error('╔══════════════════════════════════════════════════════════════╗'); - console.error('║ ✗ Invalid bot token ║'); - console.error('╟──────────────────────────────────────────────────────────────╢'); - console.error(`║ Error: ${err.message.slice(0, 50).padEnd(50)}║`); - console.error('╟──────────────────────────────────────────────────────────────╢'); - console.error('║ Please check your token and try again. ║'); - console.error('║ Get a new token from @BotFather if needed. ║'); - console.error('╚══════════════════════════════════════════════════════════════╝'); - process.exit(1); - }); +bot + .getMe() + .then((me) => { + console.log( + '╔══════════════════════════════════════════════════════════════╗', + ); + console.log( + '║ ✓ Bot token is valid! ║', + ); + console.log( + '╟──────────────────────────────────────────────────────────────╢', + ); + console.log(`║ Bot Username: @${me.username?.padEnd(43)}║`); + console.log(`║ Bot ID: ${me.id.toString().padEnd(50)}║`); + console.log( + '╟──────────────────────────────────────────────────────────────╢', + ); + console.log( + '║ Next steps: ║', + ); + console.log( + '║ 1. Add the bot to your Telegram group ║', + ); + console.log( + '║ 2. Make it an admin (so it can read messages) ║', + ); + console.log( + '║ 3. Run: npm run dev ║', + ); + console.log( + '║ 4. Send @Andy hello in your group ║', + ); + console.log( + '╚══════════════════════════════════════════════════════════════╝', + ); + process.exit(0); + }) + .catch((err) => { + console.error( + '╔══════════════════════════════════════════════════════════════╗', + ); + console.error( + '║ ✗ Invalid bot token ║', + ); + console.error( + '╟──────────────────────────────────────────────────────────────╢', + ); + console.error(`║ Error: ${err.message.slice(0, 50).padEnd(50)}║`); + console.error( + '╟──────────────────────────────────────────────────────────────╢', + ); + console.error( + '║ Please check your token and try again. ║', + ); + console.error( + '║ Get a new token from @BotFather if needed. ║', + ); + console.error( + '╚══════════════════════════════════════════════════════════════╝', + ); + process.exit(1); + }); diff --git a/src/test-features.ts b/src/test-features.ts index c05c4c048c7..be5287990eb 100644 --- a/src/test-features.ts +++ b/src/test-features.ts @@ -1,6 +1,6 @@ /** * Feature Test Script - * + * * Tests the STT and Image Generation modules to verify they work correctly. * Run with: npx tsx src/test-features.ts */ @@ -11,134 +11,145 @@ import path from 'path'; const TEST_OUTPUT_DIR = './test-output'; async function testImageGeneration(): Promise { - console.log('\n🎨 Testing Image Generation...'); - - try { - const { generateImage, isImageGenAvailable } = await import('./image-gen.js'); - - if (!isImageGenAvailable()) { - console.log('⚠️ Image generation not available (GEMINI_API_KEY not set)'); - return false; - } - - console.log(' API Key: Configured ✓'); - - // Create test output directory - fs.mkdirSync(TEST_OUTPUT_DIR, { recursive: true }); - - const testPrompt = 'A cute orange cat sitting on a windowsill'; - console.log(` Prompt: "${testPrompt}"`); - console.log(' Generating image...'); - - const result = await generateImage(testPrompt, TEST_OUTPUT_DIR); - - if (result.success && result.imagePath) { - const stats = fs.statSync(result.imagePath); - console.log(` ✅ Image generated: ${result.imagePath}`); - console.log(` 📦 Size: ${(stats.size / 1024).toFixed(2)} KB`); - return true; - } else { - console.log(` ❌ Failed: ${result.error}`); - return false; - } - } catch (err) { - console.log(` ❌ Error: ${err instanceof Error ? err.message : String(err)}`); - return false; + console.log('\n🎨 Testing Image Generation...'); + + try { + const { generateImage, isImageGenAvailable } = + await import('./image-gen.js'); + + if (!isImageGenAvailable()) { + console.log( + '⚠️ Image generation not available (GEMINI_API_KEY not set)', + ); + return false; } + + console.log(' API Key: Configured ✓'); + + // Create test output directory + fs.mkdirSync(TEST_OUTPUT_DIR, { recursive: true }); + + const testPrompt = 'A cute orange cat sitting on a windowsill'; + console.log(` Prompt: "${testPrompt}"`); + console.log(' Generating image...'); + + const result = await generateImage(testPrompt, TEST_OUTPUT_DIR); + + if (result.success && result.imagePath) { + const stats = fs.statSync(result.imagePath); + console.log(` ✅ Image generated: ${result.imagePath}`); + console.log(` 📦 Size: ${(stats.size / 1024).toFixed(2)} KB`); + return true; + } else { + console.log(` ❌ Failed: ${result.error}`); + return false; + } + } catch (err) { + console.log( + ` ❌ Error: ${err instanceof Error ? err.message : String(err)}`, + ); + return false; + } } async function testSTT(): Promise { - console.log('\n🎤 Testing Speech-to-Text...'); - - try { - const { isSTTAvailable } = await import('./stt.js'); - - const available = isSTTAvailable(); - console.log(` STT Available: ${available ? 'Yes ✓' : 'No'}`); - - const provider = process.env.STT_PROVIDER || 'gemini'; - console.log(` Provider: ${provider}`); - - if (provider === 'gcp') { - const credPath = process.env.GOOGLE_APPLICATION_CREDENTIALS; - if (credPath && fs.existsSync(credPath)) { - console.log(` GCP Credentials: Found ✓`); - } else { - console.log(` GCP Credentials: Not found (will use Gemini pass-through)`); - } - } - - // Note: We can't fully test STT without an actual audio file - console.log(' ⚠️ Full STT test requires an audio file'); - console.log(' Tip: Send a voice message to the bot to test'); - - return available; - } catch (err) { - console.log(` ❌ Error: ${err instanceof Error ? err.message : String(err)}`); - return false; + console.log('\n🎤 Testing Speech-to-Text...'); + + try { + const { isSTTAvailable } = await import('./stt.js'); + + const available = isSTTAvailable(); + console.log(` STT Available: ${available ? 'Yes ✓' : 'No'}`); + + const provider = process.env.STT_PROVIDER || 'gemini'; + console.log(` Provider: ${provider}`); + + if (provider === 'gcp') { + const credPath = process.env.GOOGLE_APPLICATION_CREDENTIALS; + if (credPath && fs.existsSync(credPath)) { + console.log(` GCP Credentials: Found ✓`); + } else { + console.log( + ` GCP Credentials: Not found (will use Gemini pass-through)`, + ); + } } + + // Note: We can't fully test STT without an actual audio file + console.log(' ⚠️ Full STT test requires an audio file'); + console.log(' Tip: Send a voice message to the bot to test'); + + return available; + } catch (err) { + console.log( + ` ❌ Error: ${err instanceof Error ? err.message : String(err)}`, + ); + return false; + } } async function testContainerAgentContext(): Promise { - console.log('\n📦 Testing Container Agent Context...'); + console.log('\n📦 Testing Container Agent Context...'); - try { - const agentRunnerPath = './container/agent-runner/src/index.ts'; - if (!fs.existsSync(agentRunnerPath)) { - console.log(' ⚠️ Agent runner not found at expected path'); - return false; - } + try { + const agentRunnerPath = './container/agent-runner/src/index.ts'; + if (!fs.existsSync(agentRunnerPath)) { + console.log(' ⚠️ Agent runner not found at expected path'); + return false; + } - const content = fs.readFileSync(agentRunnerPath, 'utf-8'); + const content = fs.readFileSync(agentRunnerPath, 'utf-8'); - const hasImageGenDoc = content.includes('generate_image'); - const hasBrowserDoc = content.includes('agent-browser'); + const hasImageGenDoc = content.includes('generate_image'); + const hasBrowserDoc = content.includes('agent-browser'); - console.log(` Image Gen in context: ${hasImageGenDoc ? '✓' : '✗'}`); - console.log(` Browser in context: ${hasBrowserDoc ? '✓' : '✗'}`); + console.log(` Image Gen in context: ${hasImageGenDoc ? '✓' : '✗'}`); + console.log(` Browser in context: ${hasBrowserDoc ? '✓' : '✗'}`); - return hasImageGenDoc && hasBrowserDoc; - } catch (err) { - console.log(` ❌ Error: ${err instanceof Error ? err.message : String(err)}`); - return false; - } + return hasImageGenDoc && hasBrowserDoc; + } catch (err) { + console.log( + ` ❌ Error: ${err instanceof Error ? err.message : String(err)}`, + ); + return false; + } } async function main() { - console.log('╔═══════════════════════════════════════════╗'); - console.log('║ NanoGemClaw Feature Test Suite ║'); - console.log('╚═══════════════════════════════════════════╝'); + console.log('╔═══════════════════════════════════════════╗'); + console.log('║ NanoGemClaw Feature Test Suite ║'); + console.log('╚═══════════════════════════════════════════╝'); - // Load environment - await import('dotenv/config'); + // Load environment + await import('dotenv/config'); - const results: Record = {}; + const results: Record = {}; - results['STT Module'] = await testSTT(); - results['Image Generation'] = await testImageGeneration(); - results['Agent Context'] = await testContainerAgentContext(); + results['STT Module'] = await testSTT(); + results['Image Generation'] = await testImageGeneration(); + results['Agent Context'] = await testContainerAgentContext(); - console.log('\n═══════════════════════════════════════════'); - console.log('📊 Test Summary:'); - console.log('───────────────────────────────────────────'); + console.log('\n═══════════════════════════════════════════'); + console.log('📊 Test Summary:'); + console.log('───────────────────────────────────────────'); - let passed = 0; - let total = 0; + let passed = 0; + let total = 0; - for (const [name, result] of Object.entries(results)) { - console.log(` ${result ? '✅' : '❌'} ${name}`); - if (result) passed++; - total++; - } + for (const [name, result] of Object.entries(results)) { + console.log(` ${result ? '✅' : '❌'} ${name}`); + if (result) passed++; + total++; + } - console.log('───────────────────────────────────────────'); - console.log(` Result: ${passed}/${total} tests passed`); - console.log('═══════════════════════════════════════════\n'); + console.log('───────────────────────────────────────────'); + console.log(` Result: ${passed}/${total} tests passed`); + console.log('═══════════════════════════════════════════\n'); - // Cleanup test output - if (fs.existsSync(TEST_OUTPUT_DIR)) { - console.log(`📁 Test images saved to: ${TEST_OUTPUT_DIR}/`); - } + // Cleanup test output + if (fs.existsSync(TEST_OUTPUT_DIR)) { + console.log(`📁 Test images saved to: ${TEST_OUTPUT_DIR}/`); + } } main().catch(console.error); diff --git a/src/tts.ts b/src/tts.ts index ad59c70beb9..4a2d9cff914 100644 --- a/src/tts.ts +++ b/src/tts.ts @@ -1,6 +1,6 @@ /** * Text-to-Speech (TTS) Service (Stub) - * + * * Future implementation will: * - Convert text responses to audio (MP3/OGG) * - Use Google TTS, OpenAI TTS, or ElevenLabs @@ -10,21 +10,27 @@ import { logger } from './logger.js'; export class TTSService { - private static instance: TTSService; + private static instance: TTSService; - private constructor() { } + private constructor() {} - public static getInstance(): TTSService { - if (!TTSService.instance) { - TTSService.instance = new TTSService(); - } - return TTSService.instance; + public static getInstance(): TTSService { + if (!TTSService.instance) { + TTSService.instance = new TTSService(); } + return TTSService.instance; + } - public async textToSpeech(text: string, lang: string = 'zh-TW'): Promise { - logger.info({ length: text.length, lang }, 'TTS generation not implemented yet'); - return null; - } + public async textToSpeech( + text: string, + lang: string = 'zh-TW', + ): Promise { + logger.info( + { length: text.length, lang }, + 'TTS generation not implemented yet', + ); + return null; + } } export const ttsService = TTSService.getInstance(); diff --git a/src/utils.ts b/src/utils.ts index 09c50f55a69..ff497118030 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,7 +7,10 @@ export function loadJson(filePath: string, defaultValue: T): T { return JSON.parse(fs.readFileSync(filePath, 'utf-8')); } } catch (err) { - console.warn(`[utils] Failed to load JSON from ${filePath}:`, err instanceof Error ? err.message : err); + console.warn( + `[utils] Failed to load JSON from ${filePath}:`, + err instanceof Error ? err.message : err, + ); } return defaultValue; } @@ -23,7 +26,11 @@ export function saveJson(filePath: string, data: unknown): void { * Format an error for structured logging. * Extracts message, stack, and name from Error objects. */ -export function formatError(err: unknown): { message: string; stack?: string; name?: string } { +export function formatError(err: unknown): { + message: string; + stack?: string; + name?: string; +} { if (err instanceof Error) { return { message: err.message, diff --git a/src/webhook.ts b/src/webhook.ts index 4559b93fd8c..244824d05a2 100644 --- a/src/webhook.ts +++ b/src/webhook.ts @@ -1,6 +1,6 @@ /** * Webhook Notification Service - * + * * Sends system events to external services (Slack, Discord, IFTTT, etc.) * via HTTP POST requests. */ @@ -9,63 +9,66 @@ import { WEBHOOK } from './config.js'; import { logger } from './logger.js'; interface WebhookPayload { - event: string; - message: string; - timestamp: string; - data?: any; + event: string; + message: string; + timestamp: string; + data?: any; } /** * Send a notification to the configured webhook URL. */ export async function sendWebhookNotification( - event: string, - message: string, - data?: any, + event: string, + message: string, + data?: any, ): Promise { - if (!WEBHOOK.ENABLED || !WEBHOOK.URL) { - return; - } + if (!WEBHOOK.ENABLED || !WEBHOOK.URL) { + return; + } - // Validate webhook URL format - if (!WEBHOOK.URL.startsWith('http://') && !WEBHOOK.URL.startsWith('https://')) { - logger.warn({ url: WEBHOOK.URL }, 'Invalid webhook URL scheme, skipping'); - return; - } + // Validate webhook URL format + if ( + !WEBHOOK.URL.startsWith('http://') && + !WEBHOOK.URL.startsWith('https://') + ) { + logger.warn({ url: WEBHOOK.URL }, 'Invalid webhook URL scheme, skipping'); + return; + } - // Check if event is enabled - if (!WEBHOOK.EVENTS.includes(event) && !WEBHOOK.EVENTS.includes('*')) { - return; - } + // Check if event is enabled + if (!WEBHOOK.EVENTS.includes(event) && !WEBHOOK.EVENTS.includes('*')) { + return; + } - const payload: WebhookPayload = { - event, - message, - timestamp: new Date().toISOString(), - data, - }; + const payload: WebhookPayload = { + event, + message, + timestamp: new Date().toISOString(), + data, + }; - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout - const response = await fetch(WEBHOOK.URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - signal: controller.signal, - }); + const response = await fetch(WEBHOOK.URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + signal: controller.signal, + }); - clearTimeout(timeoutId); + clearTimeout(timeoutId); - if (!response.ok) { - throw new Error(`Webhook failed with status ${response.status}`); - } - - logger.info({ event }, 'Webhook notification sent'); - } catch (err) { - logger.error({ err, event }, 'Failed to send webhook notification'); + if (!response.ok) { + throw new Error(`Webhook failed with status ${response.status}`); } + + logger.info({ event }, 'Webhook notification sent'); + } catch (err) { + logger.error({ err, event }, 'Failed to send webhook notification'); + } }