import fs from 'fs'; import path from 'path'; import { ASSISTANT_NAME, IDLE_TIMEOUT, POLL_INTERVAL, SERVICE_AGENT_TYPE, STATUS_CHANNEL_ID, STATUS_UPDATE_INTERVAL, TIMEZONE, TRIGGER_PATTERN, USAGE_UPDATE_INTERVAL, } from './config.js'; import './channels/index.js'; import { getChannelFactory, getRegisteredChannelNames, } from './channels/registry.js'; import { AvailableGroup, writeGroupsSnapshot } from './agent-runner.js'; import { purgeDashboardChannel, startStatusDashboard, startUsageDashboard, } from './dashboard.js'; import { deleteSession, getAllRegisteredGroups, getAllSessions, getRouterState, initDatabase, setRegisteredGroup, setRouterState, setSession, storeChatMetadata, storeMessage, } from './db.js'; import { GroupQueue } from './group-queue.js'; import { resolveGroupFolderPath } from './group-folder.js'; import { startIpcWatcher } from './ipc.js'; import { createMessageRuntime, getAvailableGroups as getAvailableGroupsForRegisteredGroups, } from './message-runtime.js'; import { findChannel, formatOutbound } from './router.js'; import { isSenderAllowed, loadSenderAllowlist, shouldDropMessage, } from './sender-allowlist.js'; import { startSchedulerLoop } from './task-scheduler.js'; import { Channel, NewMessage, RegisteredGroup } from './types.js'; import { logger } from './logger.js'; // Re-export for backwards compatibility during refactor export { escapeXml, formatMessages } from './router.js'; let lastTimestamp = ''; let sessions: Record = {}; let registeredGroups: Record = {}; let lastAgentTimestamp: Record = {}; const channels: Channel[] = []; const queue = new GroupQueue(); const messageRuntime = createMessageRuntime({ assistantName: ASSISTANT_NAME, idleTimeout: IDLE_TIMEOUT, pollInterval: POLL_INTERVAL, timezone: TIMEZONE, triggerPattern: TRIGGER_PATTERN, channels, queue, getRegisteredGroups: () => registeredGroups, getSessions: () => sessions, getLastTimestamp: () => lastTimestamp, setLastTimestamp: (timestamp) => { lastTimestamp = timestamp; }, getLastAgentTimestamps: () => lastAgentTimestamp, saveState, persistSession: (groupFolder, sessionId) => { sessions[groupFolder] = sessionId; setSession(groupFolder, sessionId); }, clearSession: (groupFolder) => { delete sessions[groupFolder]; deleteSession(groupFolder); }, }); function loadState(): void { lastTimestamp = getRouterState('last_timestamp') || ''; const agentTs = getRouterState('last_agent_timestamp'); try { lastAgentTimestamp = agentTs ? JSON.parse(agentTs) : {}; } catch { logger.warn('Corrupted last_agent_timestamp in DB, resetting'); lastAgentTimestamp = {}; } sessions = getAllSessions(); registeredGroups = getAllRegisteredGroups(SERVICE_AGENT_TYPE); logger.info( { groupCount: Object.keys(registeredGroups).length }, 'State loaded', ); } function saveState(): void { setRouterState('last_timestamp', lastTimestamp); setRouterState('last_agent_timestamp', JSON.stringify(lastAgentTimestamp)); } function registerGroup(jid: string, group: RegisteredGroup): void { let groupDir: string; try { groupDir = resolveGroupFolderPath(group.folder); } catch (err) { logger.warn( { jid, folder: group.folder, err }, 'Rejecting group registration with invalid folder', ); return; } registeredGroups[jid] = group; setRegisteredGroup(jid, group); // Create group folder fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true }); logger.info( { jid, name: group.name, folder: group.folder }, 'Group registered', ); } /** * Get available groups list for the agent. * Returns groups ordered by most recent activity. */ export function getAvailableGroups(): AvailableGroup[] { return getAvailableGroupsForRegisteredGroups(registeredGroups); } /** @internal - exported for testing */ export function _setRegisteredGroups( groups: Record, ): void { registeredGroups = groups; } async function main(): Promise { initDatabase(); logger.info('Database initialized'); loadState(); // Graceful shutdown handlers const shutdown = async (signal: string) => { logger.info({ signal }, 'Shutdown signal received'); await queue.shutdown(10000); for (const ch of channels) await ch.disconnect(); process.exit(0); }; process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); // Channel callbacks (shared by all channels) const channelOpts = { onMessage: (chatJid: string, msg: NewMessage) => { // Sender allowlist drop mode: discard messages from denied senders before storing if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) { const cfg = loadSenderAllowlist(); if ( shouldDropMessage(chatJid, cfg) && !isSenderAllowed(chatJid, msg.sender, cfg) ) { if (cfg.logDenied) { logger.debug( { chatJid, sender: msg.sender }, 'sender-allowlist: dropping message (drop mode)', ); } return; } } storeMessage(msg); }, onChatMetadata: ( chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean, ) => storeChatMetadata(chatJid, timestamp, name, channel, isGroup), registeredGroups: () => registeredGroups, }; // Create and connect all registered channels. // Each channel self-registers via the barrel import above. // Factories return null when credentials are missing, so unconfigured channels are skipped. for (const channelName of getRegisteredChannelNames()) { const factory = getChannelFactory(channelName)!; const channel = factory(channelOpts); if (!channel) { logger.warn( { channel: channelName }, 'Channel installed but credentials missing — skipping. Check .env or re-run the channel skill.', ); continue; } channels.push(channel); await channel.connect(); } if (channels.length === 0) { logger.fatal('No channels connected'); process.exit(1); } // Start subsystems (independently of connection handler) startSchedulerLoop({ registeredGroups: () => registeredGroups, getSessions: () => sessions, queue, onProcess: (groupJid, proc, processName, groupFolder) => queue.registerProcess(groupJid, proc, processName, groupFolder), sendMessage: async (jid, rawText) => { const channel = findChannel(channels, jid); if (!channel) { logger.warn({ jid }, 'No channel owns JID, cannot send message'); return; } const text = formatOutbound(rawText); if (text) await channel.sendMessage(jid, text); }, }); startIpcWatcher({ sendMessage: (jid, text) => { const channel = findChannel(channels, jid); if (!channel) throw new Error(`No channel for JID: ${jid}`); return channel.sendMessage(jid, text); }, registeredGroups: () => registeredGroups, registerGroup, syncGroups: async (force: boolean) => { await Promise.all( channels .filter((ch) => ch.syncGroups) .map((ch) => ch.syncGroups!(force)), ); }, getAvailableGroups, writeGroupsSnapshot: (gf, im, ag) => writeGroupsSnapshot(gf, im, ag), }); queue.setProcessMessagesFn(messageRuntime.processGroupMessages); messageRuntime.recoverPendingMessages(); const dashboardOpts = { assistantName: ASSISTANT_NAME, channels, getSessions: () => sessions, queue, registeredGroups: () => registeredGroups, statusChannelId: STATUS_CHANNEL_ID, statusUpdateInterval: STATUS_UPDATE_INTERVAL, usageUpdateInterval: USAGE_UPDATE_INTERVAL, }; await purgeDashboardChannel(dashboardOpts); await startStatusDashboard(dashboardOpts); await startUsageDashboard(dashboardOpts); messageRuntime.startMessageLoop().catch((err) => { logger.fatal({ err }, 'Message loop crashed unexpectedly'); process.exit(1); }); } // Guard: only run when executed directly, not when imported by tests const isDirectRun = process.argv[1] && new URL(import.meta.url).pathname === new URL(`file://${process.argv[1]}`).pathname; if (isDirectRun) { main().catch((err) => { logger.error({ err }, 'Failed to start NanoClaw'); process.exit(1); }); }