From 78426c764d7ca31fcacf324f4f9172510c7deb1f Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 31 Jan 2026 19:17:40 +0200 Subject: [PATCH] Extract config and types into separate files, clean up index.ts - src/config.ts: configuration constants - src/types.ts: TypeScript interfaces - src/index.ts: remove section comments, streamline code Co-Authored-By: Claude Opus 4.5 --- src/config.ts | 8 ++ src/index.ts | 267 +++++++++++--------------------------------------- src/types.ts | 18 ++++ 3 files changed, 84 insertions(+), 209 deletions(-) create mode 100644 src/config.ts create mode 100644 src/types.ts diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..49dc384 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,8 @@ +export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || 'Andy'; +export const POLL_INTERVAL = 2000; +export const STORE_DIR = './store'; +export const GROUPS_DIR = './groups'; +export const DATA_DIR = './data'; + +export const TRIGGER_PATTERN = new RegExp(`^@${ASSISTANT_NAME}\\b`, 'i'); +export const CLEAR_COMMAND = '/clear'; diff --git a/src/index.ts b/src/index.ts index c367839..5abeb84 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,3 @@ -/** - * NanoClaw - Unified Node.js Implementation - * - * Single process that handles: - * - WhatsApp connection (baileys) - * - Message routing - * - Claude Agent SDK queries - * - Response sending - */ - import makeWASocket, { useMultiFileAuthState, DisconnectReason, @@ -22,49 +12,32 @@ import { exec } from 'child_process'; import fs from 'fs'; import path from 'path'; -// === CONFIGURATION === - -const CONFIG = { - assistantName: process.env.ASSISTANT_NAME || 'Andy', - pollInterval: 2000, // ms - storeDir: './store', - groupsDir: './groups', - dataDir: './data', -}; - -const TRIGGER_PATTERN = new RegExp(`^@${CONFIG.assistantName}\\b`, 'i'); -const CLEAR_COMMAND = '/clear'; - -// === TYPES === - -interface RegisteredGroup { - name: string; - folder: string; - trigger: string; - added_at: string; -} - -interface Session { - [folder: string]: string; // folder -> session_id -} - -// === LOGGING === +import { + ASSISTANT_NAME, + POLL_INTERVAL, + STORE_DIR, + GROUPS_DIR, + DATA_DIR, + TRIGGER_PATTERN, + CLEAR_COMMAND +} from './config.js'; +import { RegisteredGroup, Session, NewMessage } from './types.js'; const logger = pino({ level: process.env.LOG_LEVEL || 'info', - transport: { - target: 'pino-pretty', - options: { colorize: true } - } + transport: { target: 'pino-pretty', options: { colorize: true } } }); -// === DATABASE === +let db: Database.Database; +let sock: WASocket; +let lastTimestamp = ''; +let sessions: Session = {}; +let registeredGroups: Record = {}; function initDatabase(dbPath: string): Database.Database { fs.mkdirSync(path.dirname(dbPath), { recursive: true }); - - const db = new Database(dbPath); - db.exec(` + const database = new Database(dbPath); + database.exec(` CREATE TABLE IF NOT EXISTS chats ( jid TEXT PRIMARY KEY, name TEXT, @@ -82,12 +55,9 @@ function initDatabase(dbPath: string): Database.Database { ); CREATE INDEX IF NOT EXISTS idx_timestamp ON messages(timestamp); `); - - return db; + return database; } -// === FILE HELPERS === - function loadJson(filePath: string, defaultValue: T): T { try { if (fs.existsSync(filePath)) { @@ -104,40 +74,21 @@ function saveJson(filePath: string, data: unknown): void { fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); } -// === STATE === - -let db: Database.Database; -let sock: WASocket; -let lastTimestamp = ''; -let sessions: Session = {}; -let registeredGroups: Record = {}; - function loadState(): void { - const statePath = path.join(CONFIG.dataDir, 'router_state.json'); + const statePath = path.join(DATA_DIR, 'router_state.json'); const state = loadJson<{ last_timestamp?: string }>(statePath, {}); lastTimestamp = state.last_timestamp || ''; - - sessions = loadJson(path.join(CONFIG.dataDir, 'sessions.json'), {}); - registeredGroups = loadJson(path.join(CONFIG.dataDir, 'registered_groups.json'), {}); - - logger.info({ - groupCount: Object.keys(registeredGroups).length, - lastTimestamp: lastTimestamp || '(start)' - }, 'State loaded'); + sessions = loadJson(path.join(DATA_DIR, 'sessions.json'), {}); + registeredGroups = loadJson(path.join(DATA_DIR, 'registered_groups.json'), {}); + logger.info({ groupCount: Object.keys(registeredGroups).length }, 'State loaded'); } function saveState(): void { - saveJson(path.join(CONFIG.dataDir, 'router_state.json'), { last_timestamp: lastTimestamp }); - saveJson(path.join(CONFIG.dataDir, 'sessions.json'), sessions); + saveJson(path.join(DATA_DIR, 'router_state.json'), { last_timestamp: lastTimestamp }); + saveJson(path.join(DATA_DIR, 'sessions.json'), sessions); } -// === MESSAGE STORAGE === - -function storeMessage( - msg: proto.IWebMessageInfo, - chatJid: string, - isFromMe: boolean -): void { +function storeMessage(msg: proto.IWebMessageInfo, chatJid: string, isFromMe: boolean): void { if (!msg.key) return; const content = @@ -152,59 +103,30 @@ function storeMessage( const msgId = msg.key.id || ''; try { - // Ensure chat exists first - db.prepare(` - INSERT OR REPLACE INTO chats (jid, name, last_message_time) - VALUES (?, ?, ?) - `).run(chatJid, chatJid, timestamp); - - // Store message - db.prepare(` - INSERT OR REPLACE INTO messages (id, chat_jid, sender, content, timestamp, is_from_me) - VALUES (?, ?, ?, ?, ?, ?) - `).run(msgId, chatJid, sender, content, timestamp, isFromMe ? 1 : 0); - + db.prepare(`INSERT OR REPLACE INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`).run(chatJid, chatJid, timestamp); + db.prepare(`INSERT OR REPLACE INTO messages (id, chat_jid, sender, content, timestamp, is_from_me) VALUES (?, ?, ?, ?, ?, ?)`).run(msgId, chatJid, sender, content, timestamp, isFromMe ? 1 : 0); logger.debug({ chatJid, msgId }, 'Message stored'); } catch (err) { logger.error({ err, msgId }, 'Failed to store message'); } } -// === MESSAGE PROCESSING === - -interface NewMessage { - id: string; - chat_jid: string; - sender: string; - content: string; - timestamp: string; -} - function getNewMessages(): NewMessage[] { const jids = Object.keys(registeredGroups); - if (jids.length === 0) { - logger.debug('No registered groups'); - return []; - } + if (jids.length === 0) return []; const placeholders = jids.map(() => '?').join(','); - const query = ` + const sql = ` SELECT id, chat_jid, sender, content, timestamp FROM messages WHERE timestamp > ? AND chat_jid IN (${placeholders}) ORDER BY timestamp `; - logger.debug({ lastTimestamp, jids }, 'Querying messages'); - - const rows = db.prepare(query).all(lastTimestamp, ...jids) as NewMessage[]; - + const rows = db.prepare(sql).all(lastTimestamp, ...jids) as NewMessage[]; for (const row of rows) { - if (row.timestamp > lastTimestamp) { - lastTimestamp = row.timestamp; - } + if (row.timestamp > lastTimestamp) lastTimestamp = row.timestamp; } - return rows; } @@ -214,63 +136,41 @@ async function processMessage(msg: NewMessage): Promise { const content = msg.content.trim(); - // Handle /clear command if (content.toLowerCase() === CLEAR_COMMAND) { if (sessions[group.folder]) { - // Archive old session const archived = loadJson>>( - path.join(CONFIG.dataDir, 'archived_sessions.json'), - {} + path.join(DATA_DIR, 'archived_sessions.json'), {} ); if (!archived[group.folder]) archived[group.folder] = []; - archived[group.folder].push({ - session_id: sessions[group.folder], - cleared_at: new Date().toISOString() - }); - saveJson(path.join(CONFIG.dataDir, 'archived_sessions.json'), archived); - + archived[group.folder].push({ session_id: sessions[group.folder], cleared_at: new Date().toISOString() }); + saveJson(path.join(DATA_DIR, 'archived_sessions.json'), archived); delete sessions[group.folder]; - saveJson(path.join(CONFIG.dataDir, 'sessions.json'), sessions); + saveJson(path.join(DATA_DIR, 'sessions.json'), sessions); } - logger.info({ group: group.name }, 'Session cleared'); - await sendMessage(msg.chat_jid, `${CONFIG.assistantName}: Conversation cleared. Starting fresh!`); + await sendMessage(msg.chat_jid, `${ASSISTANT_NAME}: Conversation cleared. Starting fresh!`); return; } - // Check trigger pattern if (!TRIGGER_PATTERN.test(content)) return; - // Strip trigger from message const prompt = content.replace(TRIGGER_PATTERN, '').trim(); if (!prompt) return; logger.info({ group: group.name, prompt: prompt.slice(0, 50) }, 'Processing message'); - - // Run agent const response = await runAgent(group, prompt, msg.chat_jid); - - if (response) { - await sendMessage(msg.chat_jid, response); - } + if (response) await sendMessage(msg.chat_jid, response); } -async function runAgent( - group: RegisteredGroup, - prompt: string, - chatJid: string -): Promise { +async function runAgent(group: RegisteredGroup, prompt: string, chatJid: string): Promise { const isMain = group.folder === 'main'; - const groupDir = path.join(CONFIG.groupsDir, group.folder); - - // Ensure group directory exists + const groupDir = path.join(GROUPS_DIR, group.folder); fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true }); - // Build context const context = `[WhatsApp message from group: ${group.name}] [Reply to chat_jid: ${chatJid}] [Can write to global memory (../CLAUDE.md): ${isMain}] -[Prefix your responses with "${CONFIG.assistantName}:"] +[Prefix your responses with "${ASSISTANT_NAME}:"] User message: ${prompt}`; @@ -284,10 +184,7 @@ User message: ${prompt}`; options: { cwd: groupDir, resume: sessionId, - allowedTools: [ - 'Read', 'Write', 'Edit', 'Glob', 'Grep', - 'WebSearch', 'WebFetch' - ], + allowedTools: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'WebSearch', 'WebFetch'], permissionMode: 'bypassPermissions', settingSources: ['project'], mcpServers: { @@ -296,31 +193,24 @@ User message: ${prompt}`; } } })) { - // Capture session ID from init message if (message.type === 'system' && message.subtype === 'init') { newSessionId = message.session_id; } - - // Capture final result if ('result' in message && message.result) { result = message.result as string; } } } catch (err) { logger.error({ group: group.name, err }, 'Agent error'); - return `${CONFIG.assistantName}: Sorry, I encountered an error. Please try again.`; + return `${ASSISTANT_NAME}: Sorry, I encountered an error. Please try again.`; } - // Save session if (newSessionId) { sessions[group.folder] = newSessionId; - saveJson(path.join(CONFIG.dataDir, 'sessions.json'), sessions); - } - - if (result) { - logger.info({ group: group.name, result: result.slice(0, 100) }, 'Agent response'); + saveJson(path.join(DATA_DIR, 'sessions.json'), sessions); } + if (result) logger.info({ group: group.name, result: result.slice(0, 100) }, 'Agent response'); return result; } @@ -333,129 +223,88 @@ async function sendMessage(jid: string, text: string): Promise { } } -// === WHATSAPP CONNECTION === - async function connectWhatsApp(): Promise { - const authDir = path.join(CONFIG.storeDir, 'auth'); + const authDir = path.join(STORE_DIR, 'auth'); fs.mkdirSync(authDir, { recursive: true }); const { state, saveCreds } = await useMultiFileAuthState(authDir); sock = makeWASocket({ - auth: { - creds: state.creds, - keys: makeCacheableSignalKeyStore(state.keys, logger) - }, + auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys, logger) }, printQRInTerminal: false, logger, browser: ['NanoClaw', 'Chrome', '1.0.0'] }); - // Handle connection updates sock.ev.on('connection.update', (update) => { const { connection, lastDisconnect, qr } = update; if (qr) { - // Auth needed - notify user and exit - // This shouldn't happen during normal operation; auth is done during setup const msg = 'WhatsApp authentication required. Run /setup in Claude Code.'; logger.error(msg); - - // Send macOS notification so user sees it exec(`osascript -e 'display notification "${msg}" with title "NanoClaw" sound name "Basso"'`); - - // Give notification time to display, then exit setTimeout(() => process.exit(1), 1000); } if (connection === 'close') { const reason = (lastDisconnect?.error as any)?.output?.statusCode; const shouldReconnect = reason !== DisconnectReason.loggedOut; - logger.info({ reason, shouldReconnect }, 'Connection closed'); if (shouldReconnect) { logger.info('Reconnecting...'); connectWhatsApp(); } else { - logger.info('Logged out. Delete store/auth folder and restart to re-authenticate.'); + logger.info('Logged out. Run /setup to re-authenticate.'); process.exit(0); } } else if (connection === 'open') { - console.log('\n✓ Connected to WhatsApp!\n'); - logger.info('WhatsApp connection established'); + logger.info('Connected to WhatsApp'); startMessageLoop(); } }); - // Save credentials on update sock.ev.on('creds.update', saveCreds); - // Handle incoming messages (store them) sock.ev.on('messages.upsert', ({ messages }) => { for (const msg of messages) { if (!msg.message) continue; - const chatJid = msg.key.remoteJid; if (!chatJid || chatJid === 'status@broadcast') continue; - storeMessage(msg, chatJid, msg.key.fromMe || false); } }); } -// === MAIN LOOP === - async function startMessageLoop(): Promise { - logger.info(`NanoClaw running (trigger: @${CONFIG.assistantName})`); + logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`); while (true) { try { const messages = getNewMessages(); - - if (messages.length > 0) { - logger.info({ count: messages.length }, 'Found new messages'); - } - - for (const msg of messages) { - await processMessage(msg); - } - + if (messages.length > 0) logger.info({ count: messages.length }, 'New messages'); + for (const msg of messages) await processMessage(msg); saveState(); } catch (err) { logger.error({ err }, 'Error in message loop'); } - - await new Promise(resolve => setTimeout(resolve, CONFIG.pollInterval)); + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL)); } } -// === ENTRY POINT === - async function main(): Promise { - // Initialize database - const dbPath = path.join(CONFIG.storeDir, 'messages.db'); - db = initDatabase(dbPath); + db = initDatabase(path.join(STORE_DIR, 'messages.db')); logger.info('Database initialized'); - - // Load state loadState(); - - // Connect to WhatsApp await connectWhatsApp(); - // Handle graceful shutdown - process.on('SIGINT', () => { + const shutdown = () => { logger.info('Shutting down...'); db.close(); process.exit(0); - }); - - process.on('SIGTERM', () => { - logger.info('Shutting down...'); - db.close(); - process.exit(0); - }); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); } main().catch(err => { diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..212caaa --- /dev/null +++ b/src/types.ts @@ -0,0 +1,18 @@ +export interface RegisteredGroup { + name: string; + folder: string; + trigger: string; + added_at: string; +} + +export interface Session { + [folder: string]: string; +} + +export interface NewMessage { + id: string; + chat_jid: string; + sender: string; + content: string; + timestamp: string; +}