diff --git a/.prettierrc b/.prettierrc index 544138be4..0981b7cc0 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,3 +1,4 @@ { - "singleQuote": true + "singleQuote": true, + "printWidth": 120 } diff --git a/src/channels/registry.test.ts b/src/channels/registry.test.ts index e89f62bb5..501ae5c5b 100644 --- a/src/channels/registry.test.ts +++ b/src/channels/registry.test.ts @@ -1,10 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { - registerChannel, - getChannelFactory, - getRegisteredChannelNames, -} from './registry.js'; +import { registerChannel, getChannelFactory, getRegisteredChannelNames } from './registry.js'; // The registry is module-level state, so we need a fresh module per test. // We use dynamic import with cache-busting to isolate tests. diff --git a/src/channels/registry.ts b/src/channels/registry.ts index ab871c3d9..e70f85dca 100644 --- a/src/channels/registry.ts +++ b/src/channels/registry.ts @@ -1,9 +1,4 @@ -import { - Channel, - OnInboundMessage, - OnChatMetadata, - RegisteredGroup, -} from '../types.js'; +import { Channel, OnInboundMessage, OnChatMetadata, RegisteredGroup } from '../types.js'; export interface ChannelOpts { onMessage: OnInboundMessage; diff --git a/src/config.ts b/src/config.ts index 1d15b8d8b..ef1ba9e39 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,18 +5,11 @@ import { readEnvFile } from './env.js'; import { isValidTimezone } from './timezone.js'; // Read config values from .env (falls back to process.env). -const envConfig = readEnvFile([ - 'ASSISTANT_NAME', - 'ASSISTANT_HAS_OWN_NUMBER', - 'ONECLI_URL', - 'TZ', -]); +const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER', 'ONECLI_URL', 'TZ']); -export const ASSISTANT_NAME = - process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy'; +export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy'; export const ASSISTANT_HAS_OWN_NUMBER = - (process.env.ASSISTANT_HAS_OWN_NUMBER || - envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true'; + (process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true'; export const POLL_INTERVAL = 2000; export const SCHEDULER_POLL_INTERVAL = 60000; @@ -25,43 +18,20 @@ const PROJECT_ROOT = process.cwd(); const HOME_DIR = process.env.HOME || os.homedir(); // Mount security: allowlist stored OUTSIDE project root, never mounted into containers -export const MOUNT_ALLOWLIST_PATH = path.join( - HOME_DIR, - '.config', - 'nanoclaw', - 'mount-allowlist.json', -); -export const SENDER_ALLOWLIST_PATH = path.join( - HOME_DIR, - '.config', - 'nanoclaw', - 'sender-allowlist.json', -); +export const MOUNT_ALLOWLIST_PATH = path.join(HOME_DIR, '.config', 'nanoclaw', 'mount-allowlist.json'); +export const SENDER_ALLOWLIST_PATH = path.join(HOME_DIR, '.config', 'nanoclaw', 'sender-allowlist.json'); export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store'); export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups'); export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data'); -export const CONTAINER_IMAGE = - process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest'; -export const CONTAINER_TIMEOUT = parseInt( - process.env.CONTAINER_TIMEOUT || '1800000', - 10, -); -export const CONTAINER_MAX_OUTPUT_SIZE = parseInt( - process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760', - 10, -); // 10MB default +export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest'; +export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '1800000', 10); +export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760', 10); // 10MB default export const ONECLI_URL = process.env.ONECLI_URL || envConfig.ONECLI_URL; -export const MAX_MESSAGES_PER_PROMPT = Math.max( - 1, - parseInt(process.env.MAX_MESSAGES_PER_PROMPT || '10', 10) || 10, -); +export const MAX_MESSAGES_PER_PROMPT = Math.max(1, parseInt(process.env.MAX_MESSAGES_PER_PROMPT || '10', 10) || 10); export const IPC_POLL_INTERVAL = 1000; export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result -export const MAX_CONCURRENT_CONTAINERS = Math.max( - 1, - parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5, -); +export const MAX_CONCURRENT_CONTAINERS = Math.max(1, parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5); function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -83,11 +53,7 @@ export const TRIGGER_PATTERN = buildTriggerPattern(DEFAULT_TRIGGER); // Timezone for scheduled tasks, message formatting, etc. // Validates each candidate is a real IANA identifier before accepting. function resolveConfigTimezone(): string { - const candidates = [ - process.env.TZ, - envConfig.TZ, - Intl.DateTimeFormat().resolvedOptions().timeZone, - ]; + const candidates = [process.env.TZ, envConfig.TZ, Intl.DateTimeFormat().resolvedOptions().timeZone]; for (const tz of candidates) { if (tz && isValidTimezone(tz)) return tz; } diff --git a/src/container-runner.test.ts b/src/container-runner.test.ts index 36fca0ace..292deb2f0 100644 --- a/src/container-runner.test.ts +++ b/src/container-runner.test.ts @@ -64,9 +64,7 @@ vi.mock('@onecli-sh/sdk', () => ({ OneCLI: class { applyContainerConfig = vi.fn().mockResolvedValue(true); createAgent = vi.fn().mockResolvedValue({ id: 'test' }); - ensureAgent = vi - .fn() - .mockResolvedValue({ name: 'test', identifier: 'test', created: true }); + ensureAgent = vi.fn().mockResolvedValue({ name: 'test', identifier: 'test', created: true }); }, })); @@ -91,17 +89,14 @@ let fakeProc: ReturnType; // Mock child_process.spawn vi.mock('child_process', async () => { - const actual = - await vi.importActual('child_process'); + const actual = await vi.importActual('child_process'); return { ...actual, spawn: vi.fn(() => fakeProc), - exec: vi.fn( - (_cmd: string, _opts: unknown, cb?: (err: Error | null) => void) => { - if (cb) cb(null); - return new EventEmitter(); - }, - ), + exec: vi.fn((_cmd: string, _opts: unknown, cb?: (err: Error | null) => void) => { + if (cb) cb(null); + return new EventEmitter(); + }), }; }); @@ -122,10 +117,7 @@ const testInput = { isMain: false, }; -function emitOutputMarker( - proc: ReturnType, - output: ContainerOutput, -) { +function emitOutputMarker(proc: ReturnType, output: ContainerOutput) { const json = JSON.stringify(output); proc.stdout.push(`${OUTPUT_START_MARKER}\n${json}\n${OUTPUT_END_MARKER}\n`); } @@ -142,12 +134,7 @@ describe('container-runner timeout behavior', () => { it('timeout after output resolves as success', async () => { const onOutput = vi.fn(async () => {}); - const resultPromise = runContainerAgent( - testGroup, - testInput, - () => {}, - onOutput, - ); + const resultPromise = runContainerAgent(testGroup, testInput, () => {}, onOutput); // Emit output with a result emitOutputMarker(fakeProc, { @@ -171,19 +158,12 @@ describe('container-runner timeout behavior', () => { const result = await resultPromise; expect(result.status).toBe('success'); expect(result.newSessionId).toBe('session-123'); - expect(onOutput).toHaveBeenCalledWith( - expect.objectContaining({ result: 'Here is my response' }), - ); + expect(onOutput).toHaveBeenCalledWith(expect.objectContaining({ result: 'Here is my response' })); }); it('timeout with no output resolves as error', async () => { const onOutput = vi.fn(async () => {}); - const resultPromise = runContainerAgent( - testGroup, - testInput, - () => {}, - onOutput, - ); + const resultPromise = runContainerAgent(testGroup, testInput, () => {}, onOutput); // No output emitted — fire the hard timeout await vi.advanceTimersByTimeAsync(1830000); @@ -201,12 +181,7 @@ describe('container-runner timeout behavior', () => { it('normal exit after output resolves as success', async () => { const onOutput = vi.fn(async () => {}); - const resultPromise = runContainerAgent( - testGroup, - testInput, - () => {}, - onOutput, - ); + const resultPromise = runContainerAgent(testGroup, testInput, () => {}, onOutput); // Emit output emitOutputMarker(fakeProc, { diff --git a/src/container-runner.ts b/src/container-runner.ts index dafa14318..b04cc28e5 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -18,12 +18,7 @@ import { } from './config.js'; import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js'; import { logger } from './logger.js'; -import { - CONTAINER_RUNTIME_BIN, - hostGatewayArgs, - readonlyMountArgs, - stopContainer, -} from './container-runtime.js'; +import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js'; import { OneCLI } from '@onecli-sh/sdk'; import { validateAdditionalMounts } from './mount-security.js'; import { RegisteredGroup } from './types.js'; @@ -58,10 +53,7 @@ interface VolumeMount { readonly: boolean; } -function buildVolumeMounts( - group: RegisteredGroup, - isMain: boolean, -): VolumeMount[] { +function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount[] { const mounts: VolumeMount[] = []; const projectRoot = process.cwd(); const groupDir = resolveGroupFolderPath(group.folder); @@ -136,12 +128,7 @@ function buildVolumeMounts( // Per-group Claude sessions directory (isolated from other groups) // Each group gets their own .claude/ to prevent cross-group session access - const groupSessionsDir = path.join( - DATA_DIR, - 'sessions', - group.folder, - '.claude', - ); + const groupSessionsDir = path.join(DATA_DIR, 'sessions', group.folder, '.claude'); fs.mkdirSync(groupSessionsDir, { recursive: true }); const settingsFile = path.join(groupSessionsDir, 'settings.json'); if (!fs.existsSync(settingsFile)) { @@ -199,26 +186,15 @@ function buildVolumeMounts( // Copy agent-runner source into a per-group writable location so agents // can customize it (add tools, change behavior) without affecting other // groups. Recompiled on container startup via entrypoint.sh. - const agentRunnerSrc = path.join( - projectRoot, - 'container', - 'agent-runner', - 'src', - ); - const groupAgentRunnerDir = path.join( - DATA_DIR, - 'sessions', - group.folder, - 'agent-runner-src', - ); + const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src'); + const groupAgentRunnerDir = path.join(DATA_DIR, 'sessions', group.folder, 'agent-runner-src'); if (fs.existsSync(agentRunnerSrc)) { const srcIndex = path.join(agentRunnerSrc, 'index.ts'); const cachedIndex = path.join(groupAgentRunnerDir, 'index.ts'); const needsCopy = !fs.existsSync(groupAgentRunnerDir) || !fs.existsSync(cachedIndex) || - (fs.existsSync(srcIndex) && - fs.statSync(srcIndex).mtimeMs > fs.statSync(cachedIndex).mtimeMs); + (fs.existsSync(srcIndex) && fs.statSync(srcIndex).mtimeMs > fs.statSync(cachedIndex).mtimeMs); if (needsCopy) { fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true }); } @@ -231,11 +207,7 @@ function buildVolumeMounts( // Additional mounts validated against external allowlist (tamper-proof from containers) if (group.containerConfig?.additionalMounts) { - const validatedMounts = validateAdditionalMounts( - group.containerConfig.additionalMounts, - group.name, - isMain, - ); + const validatedMounts = validateAdditionalMounts(group.containerConfig.additionalMounts, group.name, isMain); mounts.push(...validatedMounts); } @@ -261,10 +233,7 @@ async function buildContainerArgs( if (onecliApplied) { logger.info({ containerName }, 'OneCLI gateway config applied'); } else { - logger.warn( - { containerName }, - 'OneCLI gateway not reachable — container will have no credentials', - ); + logger.warn({ containerName }, 'OneCLI gateway not reachable — container will have no credentials'); } // Runtime-specific args for host gateway resolution @@ -308,23 +277,14 @@ export async function runContainerAgent( const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-'); const containerName = `nanoclaw-${safeName}-${Date.now()}`; // Main group uses the default OneCLI agent; others use their own agent. - const agentIdentifier = input.isMain - ? undefined - : group.folder.toLowerCase().replace(/_/g, '-'); - const containerArgs = await buildContainerArgs( - mounts, - containerName, - agentIdentifier, - ); + const agentIdentifier = input.isMain ? undefined : group.folder.toLowerCase().replace(/_/g, '-'); + const containerArgs = await buildContainerArgs(mounts, containerName, agentIdentifier); logger.debug( { group: group.name, containerName, - mounts: mounts.map( - (m) => - `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`, - ), + mounts: mounts.map((m) => `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`), containerArgs: containerArgs.join(' '), }, 'Container mount configuration', @@ -372,10 +332,7 @@ export async function runContainerAgent( if (chunk.length > remaining) { stdout += chunk.slice(0, remaining); stdoutTruncated = true; - logger.warn( - { group: group.name, size: stdout.length }, - 'Container stdout truncated due to size limit', - ); + logger.warn({ group: group.name, size: stdout.length }, 'Container stdout truncated due to size limit'); } else { stdout += chunk; } @@ -389,9 +346,7 @@ export async function runContainerAgent( const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx); if (endIdx === -1) break; // Incomplete pair, wait for more data - const jsonStr = parseBuffer - .slice(startIdx + OUTPUT_START_MARKER.length, endIdx) - .trim(); + const jsonStr = parseBuffer.slice(startIdx + OUTPUT_START_MARKER.length, endIdx).trim(); parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length); try { @@ -406,10 +361,7 @@ export async function runContainerAgent( // so idle timers start even for "silent" query completions. outputChain = outputChain.then(() => onOutput(parsed)); } catch (err) { - logger.warn( - { group: group.name, error: err }, - 'Failed to parse streamed output chunk', - ); + logger.warn({ group: group.name, error: err }, 'Failed to parse streamed output chunk'); } } } @@ -428,10 +380,7 @@ export async function runContainerAgent( if (chunk.length > remaining) { stderr += chunk.slice(0, remaining); stderrTruncated = true; - logger.warn( - { group: group.name, size: stderr.length }, - 'Container stderr truncated due to size limit', - ); + logger.warn({ group: group.name, size: stderr.length }, 'Container stderr truncated due to size limit'); } else { stderr += chunk; } @@ -446,17 +395,11 @@ export async function runContainerAgent( const killOnTimeout = () => { timedOut = true; - logger.error( - { group: group.name, containerName }, - 'Container timeout, stopping gracefully', - ); + logger.error({ group: group.name, containerName }, 'Container timeout, stopping gracefully'); try { stopContainer(containerName); } catch (err) { - logger.warn( - { group: group.name, containerName, err }, - 'Graceful stop failed, force killing', - ); + logger.warn({ group: group.name, containerName, err }, 'Graceful stop failed, force killing'); container.kill('SIGKILL'); } }; @@ -507,10 +450,7 @@ export async function runContainerAgent( return; } - logger.error( - { group: group.name, containerName, duration, code }, - 'Container timed out with no output', - ); + logger.error({ group: group.name, containerName, duration, code }, 'Container timed out with no output'); resolve({ status: 'error', @@ -522,8 +462,7 @@ export async function runContainerAgent( const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const logFile = path.join(logsDir, `container-${timestamp}.log`); - const isVerbose = - process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace'; + const isVerbose = process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace'; const logLines = [ `=== Container Run Log ===`, @@ -558,12 +497,7 @@ export async function runContainerAgent( containerArgs.join(' '), ``, `=== Mounts ===`, - mounts - .map( - (m) => - `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`, - ) - .join('\n'), + mounts.map((m) => `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`).join('\n'), ``, `=== Stderr${stderrTruncated ? ' (TRUNCATED)' : ''} ===`, stderr, @@ -578,9 +512,7 @@ export async function runContainerAgent( `Session ID: ${input.sessionId || 'new'}`, ``, `=== Mounts ===`, - mounts - .map((m) => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`) - .join('\n'), + mounts.map((m) => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`).join('\n'), ``, ); } @@ -612,10 +544,7 @@ export async function runContainerAgent( // Streaming mode: wait for output chain to settle, return completion marker if (onOutput) { outputChain.then(() => { - logger.info( - { group: group.name, duration, newSessionId }, - 'Container completed (streaming mode)', - ); + logger.info({ group: group.name, duration, newSessionId }, 'Container completed (streaming mode)'); resolve({ status: 'success', result: null, @@ -633,9 +562,7 @@ export async function runContainerAgent( let jsonLine: string; if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { - jsonLine = stdout - .slice(startIdx + OUTPUT_START_MARKER.length, endIdx) - .trim(); + jsonLine = stdout.slice(startIdx + OUTPUT_START_MARKER.length, endIdx).trim(); } else { // Fallback: last non-empty line (backwards compatibility) const lines = stdout.trim().split('\n'); @@ -676,10 +603,7 @@ export async function runContainerAgent( container.on('error', (err) => { clearTimeout(timeout); - logger.error( - { group: group.name, containerName, error: err }, - 'Container spawn error', - ); + logger.error({ group: group.name, containerName, error: err }, 'Container spawn error'); resolve({ status: 'error', result: null, @@ -708,9 +632,7 @@ export function writeTasksSnapshot( fs.mkdirSync(groupIpcDir, { recursive: true }); // Main sees all tasks, others only see their own - const filteredTasks = isMain - ? tasks - : tasks.filter((t) => t.groupFolder === groupFolder); + const filteredTasks = isMain ? tasks : tasks.filter((t) => t.groupFolder === groupFolder); const tasksFile = path.join(groupIpcDir, 'current_tasks.json'); fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2)); diff --git a/src/container-runtime.test.ts b/src/container-runtime.test.ts index dbb2bbc6e..94e14e902 100644 --- a/src/container-runtime.test.ts +++ b/src/container-runtime.test.ts @@ -41,19 +41,14 @@ describe('readonlyMountArgs', () => { describe('stopContainer', () => { it('calls docker stop for valid container names', () => { stopContainer('nanoclaw-test-123'); - expect(mockExecSync).toHaveBeenCalledWith( - `${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-test-123`, - { stdio: 'pipe' }, - ); + expect(mockExecSync).toHaveBeenCalledWith(`${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-test-123`, { + stdio: 'pipe', + }); }); it('rejects names with shell metacharacters', () => { - expect(() => stopContainer('foo; rm -rf /')).toThrow( - 'Invalid container name', - ); - expect(() => stopContainer('foo$(whoami)')).toThrow( - 'Invalid container name', - ); + expect(() => stopContainer('foo; rm -rf /')).toThrow('Invalid container name'); + expect(() => stopContainer('foo$(whoami)')).toThrow('Invalid container name'); expect(() => stopContainer('foo`id`')).toThrow('Invalid container name'); expect(mockExecSync).not.toHaveBeenCalled(); }); @@ -72,9 +67,7 @@ describe('ensureContainerRuntimeRunning', () => { stdio: 'pipe', timeout: 10000, }); - expect(logger.debug).toHaveBeenCalledWith( - 'Container runtime already running', - ); + expect(logger.debug).toHaveBeenCalledWith('Container runtime already running'); }); it('throws when docker info fails', () => { @@ -82,9 +75,7 @@ describe('ensureContainerRuntimeRunning', () => { throw new Error('Cannot connect to the Docker daemon'); }); - expect(() => ensureContainerRuntimeRunning()).toThrow( - 'Container runtime is required but failed to start', - ); + expect(() => ensureContainerRuntimeRunning()).toThrow('Container runtime is required but failed to start'); expect(logger.error).toHaveBeenCalled(); }); }); @@ -94,9 +85,7 @@ describe('ensureContainerRuntimeRunning', () => { describe('cleanupOrphans', () => { it('stops orphaned nanoclaw containers', () => { // docker ps returns container names, one per line - mockExecSync.mockReturnValueOnce( - 'nanoclaw-group1-111\nnanoclaw-group2-222\n', - ); + mockExecSync.mockReturnValueOnce('nanoclaw-group1-111\nnanoclaw-group2-222\n'); // stop calls succeed mockExecSync.mockReturnValue(''); @@ -104,16 +93,12 @@ describe('cleanupOrphans', () => { // ps + 2 stop calls expect(mockExecSync).toHaveBeenCalledTimes(3); - expect(mockExecSync).toHaveBeenNthCalledWith( - 2, - `${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-group1-111`, - { stdio: 'pipe' }, - ); - expect(mockExecSync).toHaveBeenNthCalledWith( - 3, - `${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-group2-222`, - { stdio: 'pipe' }, - ); + expect(mockExecSync).toHaveBeenNthCalledWith(2, `${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-group1-111`, { + stdio: 'pipe', + }); + expect(mockExecSync).toHaveBeenNthCalledWith(3, `${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-group2-222`, { + stdio: 'pipe', + }); expect(logger.info).toHaveBeenCalledWith( { count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group2-222'] }, 'Stopped orphaned containers', diff --git a/src/container-runtime.ts b/src/container-runtime.ts index beaedfad1..678a708e0 100644 --- a/src/container-runtime.ts +++ b/src/container-runtime.ts @@ -20,10 +20,7 @@ export function hostGatewayArgs(): string[] { } /** Returns CLI args for a readonly bind mount. */ -export function readonlyMountArgs( - hostPath: string, - containerPath: string, -): string[] { +export function readonlyMountArgs(hostPath: string, containerPath: string): string[] { return ['-v', `${hostPath}:${containerPath}:ro`]; } @@ -45,30 +42,14 @@ export function ensureContainerRuntimeRunning(): void { logger.debug('Container runtime already running'); } catch (err) { logger.error({ err }, 'Failed to reach container runtime'); - console.error( - '\n╔════════════════════════════════════════════════════════════════╗', - ); - console.error( - '║ FATAL: Container runtime failed to start ║', - ); - console.error( - '║ ║', - ); - console.error( - '║ Agents cannot run without a container runtime. To fix: ║', - ); - console.error( - '║ 1. Ensure Docker is installed and running ║', - ); - console.error( - '║ 2. Run: docker info ║', - ); - console.error( - '║ 3. Restart NanoClaw ║', - ); - console.error( - '╚════════════════════════════════════════════════════════════════╝\n', - ); + console.error('\n╔════════════════════════════════════════════════════════════════╗'); + console.error('║ FATAL: Container runtime failed to start ║'); + console.error('║ ║'); + console.error('║ Agents cannot run without a container runtime. To fix: ║'); + console.error('║ 1. Ensure Docker is installed and running ║'); + console.error('║ 2. Run: docker info ║'); + console.error('║ 3. Restart NanoClaw ║'); + console.error('╚════════════════════════════════════════════════════════════════╝\n'); throw new Error('Container runtime is required but failed to start', { cause: err, }); @@ -78,10 +59,10 @@ export function ensureContainerRuntimeRunning(): void { /** Kill orphaned NanoClaw containers from previous runs. */ export function cleanupOrphans(): void { try { - const output = execSync( - `${CONTAINER_RUNTIME_BIN} ps --filter name=nanoclaw- --format '{{.Names}}'`, - { stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' }, - ); + const output = execSync(`${CONTAINER_RUNTIME_BIN} ps --filter name=nanoclaw- --format '{{.Names}}'`, { + stdio: ['pipe', 'pipe', 'pipe'], + encoding: 'utf-8', + }); const orphans = output.trim().split('\n').filter(Boolean); for (const name of orphans) { try { @@ -91,10 +72,7 @@ export function cleanupOrphans(): void { } } if (orphans.length > 0) { - logger.info( - { count: orphans.length, names: orphans }, - 'Stopped orphaned containers', - ); + logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers'); } } catch (err) { logger.warn({ err }, 'Failed to clean up orphaned containers'); diff --git a/src/db-migration.test.ts b/src/db-migration.test.ts index e26873d3a..d15ba85e4 100644 --- a/src/db-migration.test.ts +++ b/src/db-migration.test.ts @@ -23,25 +23,18 @@ describe('database migrations', () => { ); `); legacyDb - .prepare( - `INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`, - ) + .prepare(`INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`) .run('tg:12345', 'Telegram DM', '2024-01-01T00:00:00.000Z'); legacyDb - .prepare( - `INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`, - ) + .prepare(`INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`) .run('tg:-10012345', 'Telegram Group', '2024-01-01T00:00:01.000Z'); legacyDb - .prepare( - `INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`, - ) + .prepare(`INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`) .run('room@g.us', 'WhatsApp Group', '2024-01-01T00:00:02.000Z'); legacyDb.close(); vi.resetModules(); - const { initDatabase, getAllChats, _closeDatabase } = - await import('./db.js'); + const { initDatabase, getAllChats, _closeDatabase } = await import('./db.js'); initDatabase(); diff --git a/src/db.test.ts b/src/db.test.ts index e10db2079..74d009390 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -57,11 +57,7 @@ describe('storeMessage', () => { timestamp: '2024-01-01T00:00:01.000Z', }); - const messages = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:00.000Z', - 'Andy', - ); + const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy'); expect(messages).toHaveLength(1); expect(messages[0].id).toBe('msg-1'); expect(messages[0].sender).toBe('123@s.whatsapp.net'); @@ -81,11 +77,7 @@ describe('storeMessage', () => { timestamp: '2024-01-01T00:00:04.000Z', }); - const messages = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:00.000Z', - 'Andy', - ); + const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy'); expect(messages).toHaveLength(0); }); @@ -103,11 +95,7 @@ describe('storeMessage', () => { }); // Message is stored (we can retrieve it — is_from_me doesn't affect retrieval) - const messages = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:00.000Z', - 'Andy', - ); + const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy'); expect(messages).toHaveLength(1); }); @@ -132,11 +120,7 @@ describe('storeMessage', () => { timestamp: '2024-01-01T00:00:01.000Z', }); - const messages = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:00.000Z', - 'Andy', - ); + const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy'); expect(messages).toHaveLength(1); expect(messages[0].content).toBe('updated'); }); @@ -160,16 +144,10 @@ describe('reply context', () => { reply_to_sender_name: 'Bob', }); - const messages = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:00.000Z', - 'Andy', - ); + const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy'); expect(messages).toHaveLength(1); expect(messages[0].reply_to_message_id).toBe('42'); - expect(messages[0].reply_to_message_content).toBe( - 'Are you coming tonight?', - ); + expect(messages[0].reply_to_message_content).toBe('Are you coming tonight?'); expect(messages[0].reply_to_sender_name).toBe('Bob'); }); @@ -185,11 +163,7 @@ describe('reply context', () => { timestamp: '2024-01-01T00:00:01.000Z', }); - const messages = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:00.000Z', - 'Andy', - ); + const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy'); expect(messages).toHaveLength(1); expect(messages[0].reply_to_message_id).toBeNull(); expect(messages[0].reply_to_message_content).toBeNull(); @@ -211,11 +185,7 @@ describe('reply context', () => { reply_to_sender_name: 'Dave', }); - const { messages } = getNewMessages( - ['group@g.us'], - '2024-01-01T00:00:00.000Z', - 'Andy', - ); + const { messages } = getNewMessages(['group@g.us'], '2024-01-01T00:00:00.000Z', 'Andy'); expect(messages).toHaveLength(1); expect(messages[0].reply_to_message_id).toBe('99'); expect(messages[0].reply_to_sender_name).toBe('Dave'); @@ -264,22 +234,14 @@ describe('getMessagesSince', () => { }); it('returns messages after the given timestamp', () => { - const msgs = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:02.000Z', - 'Andy', - ); + const msgs = getMessagesSince('group@g.us', '2024-01-01T00:00:02.000Z', 'Andy'); // Should exclude m1, m2 (before/at timestamp), m3 (bot message) expect(msgs).toHaveLength(1); expect(msgs[0].content).toBe('third'); }); it('excludes bot messages via is_bot_message flag', () => { - const msgs = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:00.000Z', - 'Andy', - ); + const msgs = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy'); const botMsgs = msgs.filter((m) => m.content === 'bot reply'); expect(botMsgs).toHaveLength(0); }); @@ -386,11 +348,7 @@ describe('getMessagesSince', () => { content: 'Andy: old bot reply', timestamp: '2024-01-01T00:00:05.000Z', }); - const msgs = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:04.000Z', - 'Andy', - ); + const msgs = getMessagesSince('group@g.us', '2024-01-01T00:00:04.000Z', 'Andy'); expect(msgs).toHaveLength(0); }); }); @@ -449,11 +407,7 @@ describe('getNewMessages', () => { }); it('filters by timestamp', () => { - const { messages } = getNewMessages( - ['group1@g.us', 'group2@g.us'], - '2024-01-01T00:00:02.000Z', - 'Andy', - ); + const { messages } = getNewMessages(['group1@g.us', 'group2@g.us'], '2024-01-01T00:00:02.000Z', 'Andy'); // Only g1 msg2 (after ts, not bot) expect(messages).toHaveLength(1); expect(messages[0].content).toBe('g1 msg2'); @@ -578,12 +532,7 @@ describe('message query LIMIT', () => { }); it('getNewMessages caps to limit and returns most recent in chronological order', () => { - const { messages, newTimestamp } = getNewMessages( - ['group@g.us'], - '2024-01-01T00:00:00.000Z', - 'Andy', - 3, - ); + const { messages, newTimestamp } = getNewMessages(['group@g.us'], '2024-01-01T00:00:00.000Z', 'Andy', 3); expect(messages).toHaveLength(3); expect(messages[0].content).toBe('message 8'); expect(messages[2].content).toBe('message 10'); @@ -594,12 +543,7 @@ describe('message query LIMIT', () => { }); it('getMessagesSince caps to limit and returns most recent in chronological order', () => { - const messages = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:00.000Z', - 'Andy', - 3, - ); + const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy', 3); expect(messages).toHaveLength(3); expect(messages[0].content).toBe('message 8'); expect(messages[2].content).toBe('message 10'); @@ -607,12 +551,7 @@ describe('message query LIMIT', () => { }); it('returns all messages when count is under the limit', () => { - const { messages } = getNewMessages( - ['group@g.us'], - '2024-01-01T00:00:00.000Z', - 'Andy', - 50, - ); + const { messages } = getNewMessages(['group@g.us'], '2024-01-01T00:00:00.000Z', 'Andy', 50); expect(messages).toHaveLength(10); }); }); diff --git a/src/db.ts b/src/db.ts index 591f2a8f5..d1484c78a 100644 --- a/src/db.ts +++ b/src/db.ts @@ -5,12 +5,7 @@ import path from 'path'; import { ASSISTANT_NAME, DATA_DIR, STORE_DIR } from './config.js'; import { isValidGroupFolder } from './group-folder.js'; import { logger } from './logger.js'; -import { - NewMessage, - RegisteredGroup, - ScheduledTask, - TaskRunLog, -} from './types.js'; +import { NewMessage, RegisteredGroup, ScheduledTask, TaskRunLog } from './types.js'; let db: Database.Database; @@ -86,9 +81,7 @@ function createSchema(database: Database.Database): void { // Add context_mode column if it doesn't exist (migration for existing DBs) try { - database.exec( - `ALTER TABLE scheduled_tasks ADD COLUMN context_mode TEXT DEFAULT 'isolated'`, - ); + database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN context_mode TEXT DEFAULT 'isolated'`); } catch { /* column already exists */ } @@ -102,26 +95,18 @@ function createSchema(database: Database.Database): void { // Add is_bot_message column if it doesn't exist (migration for existing DBs) try { - database.exec( - `ALTER TABLE messages ADD COLUMN is_bot_message INTEGER DEFAULT 0`, - ); + database.exec(`ALTER TABLE messages ADD COLUMN is_bot_message INTEGER DEFAULT 0`); // Backfill: mark existing bot messages that used the content prefix pattern - database - .prepare(`UPDATE messages SET is_bot_message = 1 WHERE content LIKE ?`) - .run(`${ASSISTANT_NAME}:%`); + database.prepare(`UPDATE messages SET is_bot_message = 1 WHERE content LIKE ?`).run(`${ASSISTANT_NAME}:%`); } catch { /* column already exists */ } // Add is_main column if it doesn't exist (migration for existing DBs) try { - database.exec( - `ALTER TABLE registered_groups ADD COLUMN is_main INTEGER DEFAULT 0`, - ); + database.exec(`ALTER TABLE registered_groups ADD COLUMN is_main INTEGER DEFAULT 0`); // Backfill: existing rows with folder = 'main' are the main group - database.exec( - `UPDATE registered_groups SET is_main = 1 WHERE folder = 'main'`, - ); + database.exec(`UPDATE registered_groups SET is_main = 1 WHERE folder = 'main'`); } catch { /* column already exists */ } @@ -131,18 +116,10 @@ function createSchema(database: Database.Database): void { database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`); database.exec(`ALTER TABLE chats ADD COLUMN is_group INTEGER DEFAULT 0`); // Backfill from JID patterns - database.exec( - `UPDATE chats SET channel = 'whatsapp', is_group = 1 WHERE jid LIKE '%@g.us'`, - ); - database.exec( - `UPDATE chats SET channel = 'whatsapp', is_group = 0 WHERE jid LIKE '%@s.whatsapp.net'`, - ); - database.exec( - `UPDATE chats SET channel = 'discord', is_group = 1 WHERE jid LIKE 'dc:%'`, - ); - database.exec( - `UPDATE chats SET channel = 'telegram', is_group = 0 WHERE jid LIKE 'tg:%'`, - ); + database.exec(`UPDATE chats SET channel = 'whatsapp', is_group = 1 WHERE jid LIKE '%@g.us'`); + database.exec(`UPDATE chats SET channel = 'whatsapp', is_group = 0 WHERE jid LIKE '%@s.whatsapp.net'`); + database.exec(`UPDATE chats SET channel = 'discord', is_group = 1 WHERE jid LIKE 'dc:%'`); + database.exec(`UPDATE chats SET channel = 'telegram', is_group = 0 WHERE jid LIKE 'tg:%'`); } catch { /* columns already exist */ } @@ -150,9 +127,7 @@ function createSchema(database: Database.Database): void { // Add reply context columns if they don't exist (migration for existing DBs) try { database.exec(`ALTER TABLE messages ADD COLUMN reply_to_message_id TEXT`); - database.exec( - `ALTER TABLE messages ADD COLUMN reply_to_message_content TEXT`, - ); + database.exec(`ALTER TABLE messages ADD COLUMN reply_to_message_content TEXT`); database.exec(`ALTER TABLE messages ADD COLUMN reply_to_sender_name TEXT`); } catch { /* columns already exist */ @@ -263,9 +238,9 @@ export function getAllChats(): ChatInfo[] { */ export function getLastGroupSync(): string | null { // Store sync time in a special chat entry - const row = db - .prepare(`SELECT last_message_time FROM chats WHERE jid = '__group_sync__'`) - .get() as { last_message_time: string } | undefined; + const row = db.prepare(`SELECT last_message_time FROM chats WHERE jid = '__group_sync__'`).get() as + | { last_message_time: string } + | undefined; return row?.last_message_time || null; } @@ -353,9 +328,7 @@ export function getNewMessages( ) ORDER BY timestamp `; - const rows = db - .prepare(sql) - .all(lastTimestamp, ...jids, `${botPrefix}:%`, limit) as NewMessage[]; + const rows = db.prepare(sql).all(lastTimestamp, ...jids, `${botPrefix}:%`, limit) as NewMessage[]; let newTimestamp = lastTimestamp; for (const row of rows) { @@ -386,15 +359,10 @@ export function getMessagesSince( LIMIT ? ) ORDER BY timestamp `; - return db - .prepare(sql) - .all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit) as NewMessage[]; + return db.prepare(sql).all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit) as NewMessage[]; } -export function getLastBotMessageTimestamp( - chatJid: string, - botPrefix: string, -): string | undefined { +export function getLastBotMessageTimestamp(chatJid: string, botPrefix: string): string | undefined { const row = db .prepare( `SELECT MAX(timestamp) as ts FROM messages @@ -404,9 +372,7 @@ export function getLastBotMessageTimestamp( return row?.ts ?? undefined; } -export function createTask( - task: Omit, -): void { +export function createTask(task: Omit): void { db.prepare( ` INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, script, schedule_type, schedule_value, context_mode, next_run, status, created_at) @@ -428,37 +394,23 @@ export function createTask( } export function getTaskById(id: string): ScheduledTask | undefined { - return db.prepare('SELECT * FROM scheduled_tasks WHERE id = ?').get(id) as - | ScheduledTask - | undefined; + return db.prepare('SELECT * FROM scheduled_tasks WHERE id = ?').get(id) as ScheduledTask | undefined; } export function getTasksForGroup(groupFolder: string): ScheduledTask[] { return db - .prepare( - 'SELECT * FROM scheduled_tasks WHERE group_folder = ? ORDER BY created_at DESC', - ) + .prepare('SELECT * FROM scheduled_tasks WHERE group_folder = ? ORDER BY created_at DESC') .all(groupFolder) as ScheduledTask[]; } export function getAllTasks(): ScheduledTask[] { - return db - .prepare('SELECT * FROM scheduled_tasks ORDER BY created_at DESC') - .all() as ScheduledTask[]; + return db.prepare('SELECT * FROM scheduled_tasks ORDER BY created_at DESC').all() as ScheduledTask[]; } export function updateTask( id: string, updates: Partial< - Pick< - ScheduledTask, - | 'prompt' - | 'script' - | 'schedule_type' - | 'schedule_value' - | 'next_run' - | 'status' - > + Pick >, ): void { const fields: string[] = []; @@ -492,9 +444,7 @@ export function updateTask( if (fields.length === 0) return; values.push(id); - db.prepare( - `UPDATE scheduled_tasks SET ${fields.join(', ')} WHERE id = ?`, - ).run(...values); + db.prepare(`UPDATE scheduled_tasks SET ${fields.join(', ')} WHERE id = ?`).run(...values); } export function deleteTask(id: string): void { @@ -516,11 +466,7 @@ export function getDueTasks(): ScheduledTask[] { .all(now) as ScheduledTask[]; } -export function updateTaskAfterRun( - id: string, - nextRun: string | null, - lastResult: string, -): void { +export function updateTaskAfterRun(id: string, nextRun: string | null, lastResult: string): void { const now = new Date().toISOString(); db.prepare( ` @@ -537,44 +483,31 @@ export function logTaskRun(log: TaskRunLog): void { INSERT INTO task_run_logs (task_id, run_at, duration_ms, status, result, error) VALUES (?, ?, ?, ?, ?, ?) `, - ).run( - log.task_id, - log.run_at, - log.duration_ms, - log.status, - log.result, - log.error, - ); + ).run(log.task_id, log.run_at, log.duration_ms, log.status, log.result, log.error); } // --- Router state accessors --- export function getRouterState(key: string): string | undefined { - const row = db - .prepare('SELECT value FROM router_state WHERE key = ?') - .get(key) as { value: string } | undefined; + const row = db.prepare('SELECT value FROM router_state WHERE key = ?').get(key) as { value: string } | undefined; return row?.value; } export function setRouterState(key: string, value: string): void { - db.prepare( - 'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)', - ).run(key, value); + db.prepare('INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)').run(key, value); } // --- Session accessors --- export function getSession(groupFolder: string): string | undefined { - const row = db - .prepare('SELECT session_id FROM sessions WHERE group_folder = ?') - .get(groupFolder) as { session_id: string } | undefined; + const row = db.prepare('SELECT session_id FROM sessions WHERE group_folder = ?').get(groupFolder) as + | { session_id: string } + | undefined; return row?.session_id; } export function setSession(groupFolder: string, sessionId: string): void { - db.prepare( - 'INSERT OR REPLACE INTO sessions (group_folder, session_id) VALUES (?, ?)', - ).run(groupFolder, sessionId); + db.prepare('INSERT OR REPLACE INTO sessions (group_folder, session_id) VALUES (?, ?)').run(groupFolder, sessionId); } export function deleteSession(groupFolder: string): void { @@ -582,9 +515,10 @@ export function deleteSession(groupFolder: string): void { } export function getAllSessions(): Record { - const rows = db - .prepare('SELECT group_folder, session_id FROM sessions') - .all() as Array<{ group_folder: string; session_id: string }>; + const rows = db.prepare('SELECT group_folder, session_id FROM sessions').all() as Array<{ + group_folder: string; + session_id: string; + }>; const result: Record = {}; for (const row of rows) { result[row.group_folder] = row.session_id; @@ -594,12 +528,8 @@ export function getAllSessions(): Record { // --- Registered group accessors --- -export function getRegisteredGroup( - jid: string, -): (RegisteredGroup & { jid: string }) | undefined { - const row = db - .prepare('SELECT * FROM registered_groups WHERE jid = ?') - .get(jid) as +export function getRegisteredGroup(jid: string): (RegisteredGroup & { jid: string }) | undefined { + const row = db.prepare('SELECT * FROM registered_groups WHERE jid = ?').get(jid) as | { jid: string; name: string; @@ -613,10 +543,7 @@ export function getRegisteredGroup( | undefined; if (!row) return undefined; if (!isValidGroupFolder(row.folder)) { - logger.warn( - { jid: row.jid, folder: row.folder }, - 'Skipping registered group with invalid folder', - ); + logger.warn({ jid: row.jid, folder: row.folder }, 'Skipping registered group with invalid folder'); return undefined; } return { @@ -625,11 +552,8 @@ export function getRegisteredGroup( folder: row.folder, trigger: row.trigger_pattern, added_at: row.added_at, - containerConfig: row.container_config - ? JSON.parse(row.container_config) - : undefined, - requiresTrigger: - row.requires_trigger === null ? undefined : row.requires_trigger === 1, + containerConfig: row.container_config ? JSON.parse(row.container_config) : undefined, + requiresTrigger: row.requires_trigger === null ? undefined : row.requires_trigger === 1, isMain: row.is_main === 1 ? true : undefined, }; } @@ -667,10 +591,7 @@ export function getAllRegisteredGroups(): Record { const result: Record = {}; for (const row of rows) { if (!isValidGroupFolder(row.folder)) { - logger.warn( - { jid: row.jid, folder: row.folder }, - 'Skipping registered group with invalid folder', - ); + logger.warn({ jid: row.jid, folder: row.folder }, 'Skipping registered group with invalid folder'); continue; } result[row.jid] = { @@ -678,11 +599,8 @@ export function getAllRegisteredGroups(): Record { folder: row.folder, trigger: row.trigger_pattern, added_at: row.added_at, - containerConfig: row.container_config - ? JSON.parse(row.container_config) - : undefined, - requiresTrigger: - row.requires_trigger === null ? undefined : row.requires_trigger === 1, + containerConfig: row.container_config ? JSON.parse(row.container_config) : undefined, + requiresTrigger: row.requires_trigger === null ? undefined : row.requires_trigger === 1, isMain: row.is_main === 1 ? true : undefined, }; } @@ -714,18 +632,12 @@ function migrateJsonState(): void { setRouterState('last_timestamp', routerState.last_timestamp); } if (routerState.last_agent_timestamp) { - setRouterState( - 'last_agent_timestamp', - JSON.stringify(routerState.last_agent_timestamp), - ); + setRouterState('last_agent_timestamp', JSON.stringify(routerState.last_agent_timestamp)); } } // Migrate sessions.json - const sessions = migrateFile('sessions.json') as Record< - string, - string - > | null; + const sessions = migrateFile('sessions.json') as Record | null; if (sessions) { for (const [folder, sessionId] of Object.entries(sessions)) { setSession(folder, sessionId); @@ -733,19 +645,13 @@ function migrateJsonState(): void { } // Migrate registered_groups.json - const groups = migrateFile('registered_groups.json') as Record< - string, - RegisteredGroup - > | null; + const groups = migrateFile('registered_groups.json') as Record | null; if (groups) { for (const [jid, group] of Object.entries(groups)) { try { setRegisteredGroup(jid, group); } catch (err) { - logger.warn( - { jid, folder: group.folder, err }, - 'Skipping migrated registered group with invalid folder', - ); + logger.warn({ jid, folder: group.folder, err }, 'Skipping migrated registered group with invalid folder'); } } } diff --git a/src/env.ts b/src/env.ts index 82cd5c3da..064e6f8a8 100644 --- a/src/env.ts +++ b/src/env.ts @@ -31,8 +31,7 @@ export function readEnvFile(keys: string[]): Record { let value = trimmed.slice(eqIdx + 1).trim(); if ( value.length >= 2 && - ((value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'"))) + ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) ) { value = value.slice(1, -1); } diff --git a/src/formatting.test.ts b/src/formatting.test.ts index 256357673..d0b361af9 100644 --- a/src/formatting.test.ts +++ b/src/formatting.test.ts @@ -1,16 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { - ASSISTANT_NAME, - getTriggerPattern, - TRIGGER_PATTERN, -} from './config.js'; -import { - escapeXml, - formatMessages, - formatOutbound, - stripInternalTags, -} from './router.js'; +import { ASSISTANT_NAME, getTriggerPattern, TRIGGER_PATTERN } from './config.js'; +import { escapeXml, formatMessages, formatOutbound, stripInternalTags } from './router.js'; import { NewMessage } from './types.js'; function makeMsg(overrides: Partial = {}): NewMessage { @@ -45,9 +36,7 @@ describe('escapeXml', () => { }); it('handles multiple special characters together', () => { - expect(escapeXml('a & b < c > d "e"')).toBe( - 'a & b < c > d "e"', - ); + expect(escapeXml('a & b < c > d "e"')).toBe('a & b < c > d "e"'); }); it('passes through strings with no special chars', () => { @@ -100,13 +89,8 @@ describe('formatMessages', () => { }); it('escapes special characters in content', () => { - const result = formatMessages( - [makeMsg({ content: '' })], - TZ, - ); - expect(result).toContain( - '<script>alert("xss")</script>', - ); + const result = formatMessages([makeMsg({ content: '' })], TZ); + expect(result).toContain('<script>alert("xss")</script>'); }); it('handles empty array', () => { @@ -128,9 +112,7 @@ describe('formatMessages', () => { TZ, ); expect(result).toContain('reply_to="42"'); - expect(result).toContain( - 'Are you coming tonight?', - ); + expect(result).toContain('Are you coming tonight?'); expect(result).toContain('Yes, on my way!'); }); @@ -166,17 +148,12 @@ describe('formatMessages', () => { TZ, ); expect(result).toContain('from="A & B"'); - expect(result).toContain( - '<script>alert("xss")</script>', - ); + expect(result).toContain('<script>alert("xss")</script>'); }); it('converts timestamps to local time for given timezone', () => { // 2024-01-01T18:30:00Z in America/New_York (EST) = 1:30 PM - const result = formatMessages( - [makeMsg({ timestamp: '2024-01-01T18:30:00.000Z' })], - 'America/New_York', - ); + const result = formatMessages([makeMsg({ timestamp: '2024-01-01T18:30:00.000Z' })], 'America/New_York'); expect(result).toContain('1:30'); expect(result).toContain('PM'); expect(result).toContain(''); @@ -247,21 +224,15 @@ describe('getTriggerPattern', () => { describe('stripInternalTags', () => { it('strips single-line internal tags', () => { - expect(stripInternalTags('hello secret world')).toBe( - 'hello world', - ); + expect(stripInternalTags('hello secret world')).toBe('hello world'); }); it('strips multi-line internal tags', () => { - expect( - stripInternalTags('hello \nsecret\nstuff\n world'), - ).toBe('hello world'); + expect(stripInternalTags('hello \nsecret\nstuff\n world')).toBe('hello world'); }); it('strips multiple internal tag blocks', () => { - expect( - stripInternalTags('ahellob'), - ).toBe('hello'); + expect(stripInternalTags('ahellob')).toBe('hello'); }); it('returns empty string when text is only internal tags', () => { @@ -279,9 +250,7 @@ describe('formatOutbound', () => { }); it('strips internal tags from remaining text', () => { - expect( - formatOutbound('thinkingThe answer is 42'), - ).toBe('The answer is 42'); + expect(formatOutbound('thinkingThe answer is 42')).toBe('The answer is 42'); }); }); @@ -290,10 +259,7 @@ describe('formatOutbound', () => { describe('trigger gating (requiresTrigger interaction)', () => { // Replicates the exact logic from processGroupMessages and startMessageLoop: // if (!isMainGroup && group.requiresTrigger !== false) { check group.trigger } - function shouldRequireTrigger( - isMainGroup: boolean, - requiresTrigger: boolean | undefined, - ): boolean { + function shouldRequireTrigger(isMainGroup: boolean, requiresTrigger: boolean | undefined): boolean { return !isMainGroup && requiresTrigger !== false; } diff --git a/src/group-folder.test.ts b/src/group-folder.test.ts index b88d268cc..cc772109a 100644 --- a/src/group-folder.test.ts +++ b/src/group-folder.test.ts @@ -2,11 +2,7 @@ import path from 'path'; import { describe, expect, it } from 'vitest'; -import { - isValidGroupFolder, - resolveGroupFolderPath, - resolveGroupIpcPath, -} from './group-folder.js'; +import { isValidGroupFolder, resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js'; describe('group folder validation', () => { it('accepts normal group folder names', () => { @@ -24,16 +20,12 @@ describe('group folder validation', () => { it('resolves safe paths under groups directory', () => { const resolved = resolveGroupFolderPath('family-chat'); - expect(resolved.endsWith(`${path.sep}groups${path.sep}family-chat`)).toBe( - true, - ); + expect(resolved.endsWith(`${path.sep}groups${path.sep}family-chat`)).toBe(true); }); it('resolves safe paths under data ipc directory', () => { const resolved = resolveGroupIpcPath('family-chat'); - expect( - resolved.endsWith(`${path.sep}data${path.sep}ipc${path.sep}family-chat`), - ).toBe(true); + expect(resolved.endsWith(`${path.sep}data${path.sep}ipc${path.sep}family-chat`)).toBe(true); }); it('throws for unsafe folder names', () => { diff --git a/src/group-queue.test.ts b/src/group-queue.test.ts index d7de51705..a7aa286f5 100644 --- a/src/group-queue.test.ts +++ b/src/group-queue.test.ts @@ -298,12 +298,7 @@ describe('GroupQueue', () => { await vi.advanceTimersByTimeAsync(10); // Register a process so closeStdin has a groupFolder - queue.registerProcess( - 'group1@g.us', - {} as any, - 'container-1', - 'test-group', - ); + queue.registerProcess('group1@g.us', {} as any, 'container-1', 'test-group'); // Enqueue a task while container is active but NOT idle const taskFn = vi.fn(async () => {}); @@ -338,12 +333,7 @@ describe('GroupQueue', () => { await vi.advanceTimersByTimeAsync(10); // Register process and mark idle - queue.registerProcess( - 'group1@g.us', - {} as any, - 'container-1', - 'test-group', - ); + queue.registerProcess('group1@g.us', {} as any, 'container-1', 'test-group'); queue.notifyIdle('group1@g.us'); // Clear previous writes, then enqueue a task @@ -377,12 +367,7 @@ describe('GroupQueue', () => { queue.setProcessMessagesFn(processMessages); queue.enqueueMessageCheck('group1@g.us'); await vi.advanceTimersByTimeAsync(10); - queue.registerProcess( - 'group1@g.us', - {} as any, - 'container-1', - 'test-group', - ); + queue.registerProcess('group1@g.us', {} as any, 'container-1', 'test-group'); // Container becomes idle queue.notifyIdle('group1@g.us'); @@ -418,12 +403,7 @@ describe('GroupQueue', () => { // Start a task (sets isTaskContainer = true) queue.enqueueTask('group1@g.us', 'task-1', taskFn); await vi.advanceTimersByTimeAsync(10); - queue.registerProcess( - 'group1@g.us', - {} as any, - 'container-1', - 'test-group', - ); + queue.registerProcess('group1@g.us', {} as any, 'container-1', 'test-group'); // sendMessage should return false — user messages must not go to task containers const result = queue.sendMessage('group1@g.us', 'hello'); @@ -451,12 +431,7 @@ describe('GroupQueue', () => { await vi.advanceTimersByTimeAsync(10); // Register process and enqueue a task (no idle yet — no preemption) - queue.registerProcess( - 'group1@g.us', - {} as any, - 'container-1', - 'test-group', - ); + queue.registerProcess('group1@g.us', {} as any, 'container-1', 'test-group'); const writeFileSync = vi.mocked(fs.default.writeFileSync); writeFileSync.mockClear(); @@ -473,9 +448,7 @@ describe('GroupQueue', () => { writeFileSync.mockClear(); queue.notifyIdle('group1@g.us'); - closeWrites = writeFileSync.mock.calls.filter( - (call) => typeof call[0] === 'string' && call[0].endsWith('_close'), - ); + closeWrites = writeFileSync.mock.calls.filter((call) => typeof call[0] === 'string' && call[0].endsWith('_close')); expect(closeWrites).toHaveLength(1); resolveProcess!(); diff --git a/src/group-queue.ts b/src/group-queue.ts index a3b547dde..5b73e6a62 100644 --- a/src/group-queue.ts +++ b/src/group-queue.ts @@ -31,8 +31,7 @@ export class GroupQueue { private groups = new Map(); private activeCount = 0; private waitingGroups: string[] = []; - private processMessagesFn: ((groupJid: string) => Promise) | null = - null; + private processMessagesFn: ((groupJid: string) => Promise) | null = null; private shuttingDown = false; private getGroup(groupJid: string): GroupState { @@ -75,10 +74,7 @@ export class GroupQueue { if (!this.waitingGroups.includes(groupJid)) { this.waitingGroups.push(groupJid); } - logger.debug( - { groupJid, activeCount: this.activeCount }, - 'At concurrency limit, message queued', - ); + logger.debug({ groupJid, activeCount: this.activeCount }, 'At concurrency limit, message queued'); return; } @@ -116,10 +112,7 @@ export class GroupQueue { if (!this.waitingGroups.includes(groupJid)) { this.waitingGroups.push(groupJid); } - logger.debug( - { groupJid, taskId, activeCount: this.activeCount }, - 'At concurrency limit, task queued', - ); + logger.debug({ groupJid, taskId, activeCount: this.activeCount }, 'At concurrency limit, task queued'); return; } @@ -129,12 +122,7 @@ export class GroupQueue { ); } - registerProcess( - groupJid: string, - proc: ChildProcess, - containerName: string, - groupFolder?: string, - ): void { + registerProcess(groupJid: string, proc: ChildProcess, containerName: string, groupFolder?: string): void { const state = this.getGroup(groupJid); state.process = proc; state.containerName = containerName; @@ -159,8 +147,7 @@ export class GroupQueue { */ sendMessage(groupJid: string, text: string): boolean { const state = this.getGroup(groupJid); - if (!state.active || !state.groupFolder || state.isTaskContainer) - return false; + if (!state.active || !state.groupFolder || state.isTaskContainer) return false; state.idleWaiting = false; // Agent is about to receive work, no longer idle const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input'); @@ -193,10 +180,7 @@ export class GroupQueue { } } - private async runForGroup( - groupJid: string, - reason: 'messages' | 'drain', - ): Promise { + private async runForGroup(groupJid: string, reason: 'messages' | 'drain'): Promise { const state = this.getGroup(groupJid); state.active = true; state.idleWaiting = false; @@ -204,10 +188,7 @@ export class GroupQueue { state.pendingMessages = false; this.activeCount++; - logger.debug( - { groupJid, reason, activeCount: this.activeCount }, - 'Starting container for group', - ); + logger.debug({ groupJid, reason, activeCount: this.activeCount }, 'Starting container for group'); try { if (this.processMessagesFn) { @@ -239,10 +220,7 @@ export class GroupQueue { state.runningTaskId = task.id; this.activeCount++; - logger.debug( - { groupJid, taskId: task.id, activeCount: this.activeCount }, - 'Running queued task', - ); + logger.debug({ groupJid, taskId: task.id, activeCount: this.activeCount }, 'Running queued task'); try { await task.fn(); @@ -272,10 +250,7 @@ export class GroupQueue { } const delayMs = BASE_RETRY_MS * Math.pow(2, state.retryCount - 1); - logger.info( - { groupJid, retryCount: state.retryCount, delayMs }, - 'Scheduling retry with backoff', - ); + logger.info({ groupJid, retryCount: state.retryCount, delayMs }, 'Scheduling retry with backoff'); setTimeout(() => { if (!this.shuttingDown) { this.enqueueMessageCheck(groupJid); @@ -292,10 +267,7 @@ export class GroupQueue { if (state.pendingTasks.length > 0) { const task = state.pendingTasks.shift()!; this.runTask(groupJid, task).catch((err) => - logger.error( - { groupJid, taskId: task.id, err }, - 'Unhandled error in runTask (drain)', - ), + logger.error({ groupJid, taskId: task.id, err }, 'Unhandled error in runTask (drain)'), ); return; } @@ -303,10 +275,7 @@ export class GroupQueue { // Then pending messages if (state.pendingMessages) { this.runForGroup(groupJid, 'drain').catch((err) => - logger.error( - { groupJid, err }, - 'Unhandled error in runForGroup (drain)', - ), + logger.error({ groupJid, err }, 'Unhandled error in runForGroup (drain)'), ); return; } @@ -316,10 +285,7 @@ export class GroupQueue { } private drainWaiting(): void { - while ( - this.waitingGroups.length > 0 && - this.activeCount < MAX_CONCURRENT_CONTAINERS - ) { + while (this.waitingGroups.length > 0 && this.activeCount < MAX_CONCURRENT_CONTAINERS) { const nextJid = this.waitingGroups.shift()!; const state = this.getGroup(nextJid); @@ -327,17 +293,11 @@ export class GroupQueue { if (state.pendingTasks.length > 0) { const task = state.pendingTasks.shift()!; this.runTask(nextJid, task).catch((err) => - logger.error( - { groupJid: nextJid, taskId: task.id, err }, - 'Unhandled error in runTask (waiting)', - ), + logger.error({ groupJid: nextJid, taskId: task.id, err }, 'Unhandled error in runTask (waiting)'), ); } else if (state.pendingMessages) { this.runForGroup(nextJid, 'drain').catch((err) => - logger.error( - { groupJid: nextJid, err }, - 'Unhandled error in runForGroup (waiting)', - ), + logger.error({ groupJid: nextJid, err }, 'Unhandled error in runForGroup (waiting)'), ); } // If neither pending, skip this group diff --git a/src/index.ts b/src/index.ts index 004764d69..ded6b947c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,20 +15,9 @@ import { TIMEZONE, } from './config.js'; import './channels/index.js'; -import { - getChannelFactory, - getRegisteredChannelNames, -} from './channels/registry.js'; -import { - ContainerOutput, - runContainerAgent, - writeGroupsSnapshot, - writeTasksSnapshot, -} from './container-runner.js'; -import { - cleanupOrphans, - ensureContainerRuntimeRunning, -} from './container-runtime.js'; +import { getChannelFactory, getRegisteredChannelNames } from './channels/registry.js'; +import { ContainerOutput, runContainerAgent, writeGroupsSnapshot, writeTasksSnapshot } from './container-runner.js'; +import { cleanupOrphans, ensureContainerRuntimeRunning } from './container-runtime.js'; import { getAllChats, getAllRegisteredGroups, @@ -50,17 +39,8 @@ import { GroupQueue } from './group-queue.js'; import { resolveGroupFolderPath } from './group-folder.js'; import { startIpcWatcher } from './ipc.js'; import { findChannel, formatMessages, formatOutbound } from './router.js'; -import { - restoreRemoteControl, - startRemoteControl, - stopRemoteControl, -} from './remote-control.js'; -import { - isSenderAllowed, - isTriggerAllowed, - loadSenderAllowlist, - shouldDropMessage, -} from './sender-allowlist.js'; +import { restoreRemoteControl, startRemoteControl, stopRemoteControl } from './remote-control.js'; +import { isSenderAllowed, isTriggerAllowed, loadSenderAllowlist, shouldDropMessage } from './sender-allowlist.js'; import { startSessionCleanup } from './session-cleanup.js'; import { startSchedulerLoop } from './task-scheduler.js'; import { Channel, NewMessage, RegisteredGroup } from './types.js'; @@ -85,16 +65,10 @@ function ensureOneCLIAgent(jid: string, group: RegisteredGroup): void { const identifier = group.folder.toLowerCase().replace(/_/g, '-'); onecli.ensureAgent({ name: group.name, identifier }).then( (res) => { - logger.info( - { jid, identifier, created: res.created }, - 'OneCLI agent ensured', - ); + logger.info({ jid, identifier, created: res.created }, 'OneCLI agent ensured'); }, (err) => { - logger.debug( - { jid, identifier, err: String(err) }, - 'OneCLI agent ensure skipped', - ); + logger.debug({ jid, identifier, err: String(err) }, 'OneCLI agent ensure skipped'); }, ); } @@ -110,10 +84,7 @@ function loadState(): void { } sessions = getAllSessions(); registeredGroups = getAllRegisteredGroups(); - logger.info( - { groupCount: Object.keys(registeredGroups).length }, - 'State loaded', - ); + logger.info({ groupCount: Object.keys(registeredGroups).length }, 'State loaded'); } /** @@ -126,10 +97,7 @@ function getOrRecoverCursor(chatJid: string): string { const botTs = getLastBotMessageTimestamp(chatJid, ASSISTANT_NAME); if (botTs) { - logger.info( - { chatJid, recoveredFrom: botTs }, - 'Recovered message cursor from last bot reply', - ); + logger.info({ chatJid, recoveredFrom: botTs }, 'Recovered message cursor from last bot reply'); lastAgentTimestamp[chatJid] = botTs; saveState(); return botTs; @@ -147,10 +115,7 @@ function registerGroup(jid: string, group: RegisteredGroup): void { try { groupDir = resolveGroupFolderPath(group.folder); } catch (err) { - logger.warn( - { jid, folder: group.folder, err }, - 'Rejecting group registration with invalid folder', - ); + logger.warn({ jid, folder: group.folder, err }, 'Rejecting group registration with invalid folder'); return; } @@ -164,11 +129,7 @@ function registerGroup(jid: string, group: RegisteredGroup): void { // identity and instructions from the first run. (Fixes #1391) const groupMdFile = path.join(groupDir, 'CLAUDE.md'); if (!fs.existsSync(groupMdFile)) { - const templateFile = path.join( - GROUPS_DIR, - group.isMain ? 'main' : 'global', - 'CLAUDE.md', - ); + const templateFile = path.join(GROUPS_DIR, group.isMain ? 'main' : 'global', 'CLAUDE.md'); if (fs.existsSync(templateFile)) { let content = fs.readFileSync(templateFile, 'utf-8'); if (ASSISTANT_NAME !== 'Andy') { @@ -183,10 +144,7 @@ function registerGroup(jid: string, group: RegisteredGroup): void { // Ensure a corresponding OneCLI agent exists (best-effort, non-blocking) ensureOneCLIAgent(jid, group); - logger.info( - { jid, name: group.name, folder: group.folder }, - 'Group registered', - ); + logger.info({ jid, name: group.name, folder: group.folder }, 'Group registered'); } /** @@ -208,9 +166,7 @@ export function getAvailableGroups(): import('./container-runner.js').AvailableG } /** @internal - exported for testing */ -export function _setRegisteredGroups( - groups: Record, -): void { +export function _setRegisteredGroups(groups: Record): void { registeredGroups = groups; } @@ -245,8 +201,7 @@ async function processGroupMessages(chatJid: string): Promise { const allowlistCfg = loadSenderAllowlist(); const hasTrigger = missedMessages.some( (m) => - triggerPattern.test(m.content.trim()) && - (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)), + triggerPattern.test(m.content.trim()) && (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)), ); if (!hasTrigger) return true; } @@ -256,14 +211,10 @@ async function processGroupMessages(chatJid: string): Promise { // Advance cursor so the piping path in startMessageLoop won't re-fetch // these messages. Save the old cursor so we can roll back on error. const previousCursor = lastAgentTimestamp[chatJid] || ''; - lastAgentTimestamp[chatJid] = - missedMessages[missedMessages.length - 1].timestamp; + lastAgentTimestamp[chatJid] = missedMessages[missedMessages.length - 1].timestamp; saveState(); - logger.info( - { group: group.name, messageCount: missedMessages.length }, - 'Processing messages', - ); + logger.info({ group: group.name, messageCount: missedMessages.length }, 'Processing messages'); // Track idle timer for closing stdin when agent is idle let idleTimer: ReturnType | null = null; @@ -271,10 +222,7 @@ async function processGroupMessages(chatJid: string): Promise { const resetIdleTimer = () => { if (idleTimer) clearTimeout(idleTimer); idleTimer = setTimeout(() => { - logger.debug( - { group: group.name }, - 'Idle timeout, closing container stdin', - ); + logger.debug({ group: group.name }, 'Idle timeout, closing container stdin'); queue.closeStdin(chatJid); }, IDLE_TIMEOUT); }; @@ -286,10 +234,7 @@ async function processGroupMessages(chatJid: string): Promise { const output = await runAgent(group, prompt, chatJid, async (result) => { // Streaming output callback — called for each agent result if (result.result) { - const raw = - typeof result.result === 'string' - ? result.result - : JSON.stringify(result.result); + const raw = typeof result.result === 'string' ? result.result : JSON.stringify(result.result); // Strip ... blocks — agent uses these for internal reasoning const text = raw.replace(/[\s\S]*?<\/internal>/g, '').trim(); logger.info({ group: group.name }, `Agent output: ${raw.length} chars`); @@ -326,10 +271,7 @@ async function processGroupMessages(chatJid: string): Promise { // Roll back cursor so retries can re-process these messages lastAgentTimestamp[chatJid] = previousCursor; saveState(); - logger.warn( - { group: group.name }, - 'Agent error, rolled back message cursor for retry', - ); + logger.warn({ group: group.name }, 'Agent error, rolled back message cursor for retry'); return false; } @@ -364,12 +306,7 @@ async function runAgent( // Update available groups snapshot (main group only can see all groups) const availableGroups = getAvailableGroups(); - writeGroupsSnapshot( - group.folder, - isMain, - availableGroups, - new Set(Object.keys(registeredGroups)), - ); + writeGroupsSnapshot(group.folder, isMain, availableGroups, new Set(Object.keys(registeredGroups))); // Wrap onOutput to track session ID from streamed results const wrappedOnOutput = onOutput @@ -393,8 +330,7 @@ async function runAgent( isMain, assistantName: ASSISTANT_NAME, }, - (proc, containerName) => - queue.registerProcess(chatJid, proc, containerName, group.folder), + (proc, containerName) => queue.registerProcess(chatJid, proc, containerName, group.folder), wrappedOnOutput, ); @@ -409,11 +345,7 @@ async function runAgent( // deletion, or disk-full. The existing backoff in group-queue.ts // handles the retry; we just need to remove the broken session ID. const isStaleSession = - sessionId && - output.error && - /no conversation found|ENOENT.*\.jsonl|session.*not found/i.test( - output.error, - ); + sessionId && output.error && /no conversation found|ENOENT.*\.jsonl|session.*not found/i.test(output.error); if (isStaleSession) { logger.warn( @@ -424,10 +356,7 @@ async function runAgent( deleteSession(group.folder); } - logger.error( - { group: group.name, error: output.error }, - 'Container agent error', - ); + logger.error({ group: group.name, error: output.error }, 'Container agent error'); return 'error'; } @@ -450,11 +379,7 @@ async function startMessageLoop(): Promise { while (true) { try { const jids = Object.keys(registeredGroups); - const { messages, newTimestamp } = getNewMessages( - jids, - lastTimestamp, - ASSISTANT_NAME, - ); + const { messages, newTimestamp } = getNewMessages(jids, lastTimestamp, ASSISTANT_NAME); if (messages.length > 0) { logger.info({ count: messages.length }, 'New messages'); @@ -496,8 +421,7 @@ async function startMessageLoop(): Promise { const hasTrigger = groupMessages.some( (m) => triggerPattern.test(m.content.trim()) && - (m.is_from_me || - isTriggerAllowed(chatJid, m.sender, allowlistCfg)), + (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)), ); if (!hasTrigger) continue; } @@ -510,24 +434,17 @@ async function startMessageLoop(): Promise { ASSISTANT_NAME, MAX_MESSAGES_PER_PROMPT, ); - const messagesToSend = - allPending.length > 0 ? allPending : groupMessages; + const messagesToSend = allPending.length > 0 ? allPending : groupMessages; const formatted = formatMessages(messagesToSend, TIMEZONE); if (queue.sendMessage(chatJid, formatted)) { - logger.debug( - { chatJid, count: messagesToSend.length }, - 'Piped messages to active container', - ); - lastAgentTimestamp[chatJid] = - messagesToSend[messagesToSend.length - 1].timestamp; + logger.debug({ chatJid, count: messagesToSend.length }, 'Piped messages to active container'); + lastAgentTimestamp[chatJid] = messagesToSend[messagesToSend.length - 1].timestamp; saveState(); // Show typing indicator while the container processes the piped message channel .setTyping?.(chatJid, true) - ?.catch((err) => - logger.warn({ chatJid, err }, 'Failed to set typing indicator'), - ); + ?.catch((err) => logger.warn({ chatJid, err }, 'Failed to set typing indicator')); } else { // No active container — enqueue for a new one queue.enqueueMessageCheck(chatJid); @@ -547,17 +464,9 @@ async function startMessageLoop(): Promise { */ function recoverPendingMessages(): void { for (const [chatJid, group] of Object.entries(registeredGroups)) { - const pending = getMessagesSince( - chatJid, - getOrRecoverCursor(chatJid), - ASSISTANT_NAME, - MAX_MESSAGES_PER_PROMPT, - ); + const pending = getMessagesSince(chatJid, getOrRecoverCursor(chatJid), ASSISTANT_NAME, MAX_MESSAGES_PER_PROMPT); if (pending.length > 0) { - logger.info( - { group: group.name, pendingCount: pending.length }, - 'Recovery: found unprocessed messages', - ); + logger.info({ group: group.name, pendingCount: pending.length }, 'Recovery: found unprocessed messages'); queue.enqueueMessageCheck(chatJid); } } @@ -593,17 +502,10 @@ async function main(): Promise { process.on('SIGINT', () => shutdown('SIGINT')); // Handle /remote-control and /remote-control-end commands - async function handleRemoteControl( - command: string, - chatJid: string, - msg: NewMessage, - ): Promise { + async function handleRemoteControl(command: string, chatJid: string, msg: NewMessage): Promise { const group = registeredGroups[chatJid]; if (!group?.isMain) { - logger.warn( - { chatJid, sender: msg.sender }, - 'Remote control rejected: not main group', - ); + logger.warn({ chatJid, sender: msg.sender }, 'Remote control rejected: not main group'); return; } @@ -611,18 +513,11 @@ async function main(): Promise { if (!channel) return; if (command === '/remote-control') { - const result = await startRemoteControl( - msg.sender, - chatJid, - process.cwd(), - ); + const result = await startRemoteControl(msg.sender, chatJid, process.cwd()); if (result.ok) { await channel.sendMessage(chatJid, result.url); } else { - await channel.sendMessage( - chatJid, - `Remote Control failed: ${result.error}`, - ); + await channel.sendMessage(chatJid, `Remote Control failed: ${result.error}`); } } else { const result = stopRemoteControl(); @@ -649,28 +544,17 @@ async function main(): Promise { // 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 (shouldDropMessage(chatJid, cfg) && !isSenderAllowed(chatJid, msg.sender, cfg)) { if (cfg.logDenied) { - logger.debug( - { chatJid, sender: msg.sender }, - 'sender-allowlist: dropping message (drop mode)', - ); + 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), + onChatMetadata: (chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean) => + storeChatMetadata(chatJid, timestamp, name, channel, isGroup), registeredGroups: () => registeredGroups, }; @@ -721,15 +605,10 @@ async function main(): Promise { registeredGroups: () => registeredGroups, registerGroup, syncGroups: async (force: boolean) => { - await Promise.all( - channels - .filter((ch) => ch.syncGroups) - .map((ch) => ch.syncGroups!(force)), - ); + await Promise.all(channels.filter((ch) => ch.syncGroups).map((ch) => ch.syncGroups!(force))); }, getAvailableGroups, - writeGroupsSnapshot: (gf, im, ag, rj) => - writeGroupsSnapshot(gf, im, ag, rj), + writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj), onTasksChanged: () => { const tasks = getAllTasks(); const taskRows = tasks.map((t) => ({ @@ -758,9 +637,7 @@ async function main(): Promise { // 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; + process.argv[1] && new URL(import.meta.url).pathname === new URL(`file://${process.argv[1]}`).pathname; if (isDirectRun) { main().catch((err) => { diff --git a/src/ipc-auth.test.ts b/src/ipc-auth.test.ts index 0adf89958..c5bcd51dc 100644 --- a/src/ipc-auth.test.ts +++ b/src/ipc-auth.test.ts @@ -176,32 +176,17 @@ describe('pause_task authorization', () => { }); it('main group can pause any task', async () => { - await processTaskIpc( - { type: 'pause_task', taskId: 'task-other' }, - 'whatsapp_main', - true, - deps, - ); + await processTaskIpc({ type: 'pause_task', taskId: 'task-other' }, 'whatsapp_main', true, deps); expect(getTaskById('task-other')!.status).toBe('paused'); }); it('non-main group can pause its own task', async () => { - await processTaskIpc( - { type: 'pause_task', taskId: 'task-other' }, - 'other-group', - false, - deps, - ); + await processTaskIpc({ type: 'pause_task', taskId: 'task-other' }, 'other-group', false, deps); expect(getTaskById('task-other')!.status).toBe('paused'); }); it('non-main group cannot pause another groups task', async () => { - await processTaskIpc( - { type: 'pause_task', taskId: 'task-main' }, - 'other-group', - false, - deps, - ); + await processTaskIpc({ type: 'pause_task', taskId: 'task-main' }, 'other-group', false, deps); expect(getTaskById('task-main')!.status).toBe('active'); }); }); @@ -225,32 +210,17 @@ describe('resume_task authorization', () => { }); it('main group can resume any task', async () => { - await processTaskIpc( - { type: 'resume_task', taskId: 'task-paused' }, - 'whatsapp_main', - true, - deps, - ); + await processTaskIpc({ type: 'resume_task', taskId: 'task-paused' }, 'whatsapp_main', true, deps); expect(getTaskById('task-paused')!.status).toBe('active'); }); it('non-main group can resume its own task', async () => { - await processTaskIpc( - { type: 'resume_task', taskId: 'task-paused' }, - 'other-group', - false, - deps, - ); + await processTaskIpc({ type: 'resume_task', taskId: 'task-paused' }, 'other-group', false, deps); expect(getTaskById('task-paused')!.status).toBe('active'); }); it('non-main group cannot resume another groups task', async () => { - await processTaskIpc( - { type: 'resume_task', taskId: 'task-paused' }, - 'third-group', - false, - deps, - ); + await processTaskIpc({ type: 'resume_task', taskId: 'task-paused' }, 'third-group', false, deps); expect(getTaskById('task-paused')!.status).toBe('paused'); }); }); @@ -272,12 +242,7 @@ describe('cancel_task authorization', () => { created_at: '2024-01-01T00:00:00.000Z', }); - await processTaskIpc( - { type: 'cancel_task', taskId: 'task-to-cancel' }, - 'whatsapp_main', - true, - deps, - ); + await processTaskIpc({ type: 'cancel_task', taskId: 'task-to-cancel' }, 'whatsapp_main', true, deps); expect(getTaskById('task-to-cancel')).toBeUndefined(); }); @@ -295,12 +260,7 @@ describe('cancel_task authorization', () => { created_at: '2024-01-01T00:00:00.000Z', }); - await processTaskIpc( - { type: 'cancel_task', taskId: 'task-own' }, - 'other-group', - false, - deps, - ); + await processTaskIpc({ type: 'cancel_task', taskId: 'task-own' }, 'other-group', false, deps); expect(getTaskById('task-own')).toBeUndefined(); }); @@ -318,12 +278,7 @@ describe('cancel_task authorization', () => { created_at: '2024-01-01T00:00:00.000Z', }); - await processTaskIpc( - { type: 'cancel_task', taskId: 'task-foreign' }, - 'other-group', - false, - deps, - ); + await processTaskIpc({ type: 'cancel_task', taskId: 'task-foreign' }, 'other-group', false, deps); expect(getTaskById('task-foreign')).toBeDefined(); }); }); @@ -372,12 +327,7 @@ describe('register_group authorization', () => { describe('refresh_groups authorization', () => { it('non-main group cannot trigger refresh', async () => { // This should be silently blocked (no crash, no effect) - await processTaskIpc( - { type: 'refresh_groups' }, - 'other-group', - false, - deps, - ); + await processTaskIpc({ type: 'refresh_groups' }, 'other-group', false, deps); // If we got here without error, the auth gate worked }); }); @@ -399,40 +349,26 @@ describe('IPC message authorization', () => { } it('main group can send to any group', () => { - expect( - isMessageAuthorized('whatsapp_main', true, 'other@g.us', groups), - ).toBe(true); - expect( - isMessageAuthorized('whatsapp_main', true, 'third@g.us', groups), - ).toBe(true); + expect(isMessageAuthorized('whatsapp_main', true, 'other@g.us', groups)).toBe(true); + expect(isMessageAuthorized('whatsapp_main', true, 'third@g.us', groups)).toBe(true); }); it('non-main group can send to its own chat', () => { - expect( - isMessageAuthorized('other-group', false, 'other@g.us', groups), - ).toBe(true); + expect(isMessageAuthorized('other-group', false, 'other@g.us', groups)).toBe(true); }); it('non-main group cannot send to another groups chat', () => { - expect(isMessageAuthorized('other-group', false, 'main@g.us', groups)).toBe( - false, - ); - expect( - isMessageAuthorized('other-group', false, 'third@g.us', groups), - ).toBe(false); + expect(isMessageAuthorized('other-group', false, 'main@g.us', groups)).toBe(false); + expect(isMessageAuthorized('other-group', false, 'third@g.us', groups)).toBe(false); }); it('non-main group cannot send to unregistered JID', () => { - expect( - isMessageAuthorized('other-group', false, 'unknown@g.us', groups), - ).toBe(false); + expect(isMessageAuthorized('other-group', false, 'unknown@g.us', groups)).toBe(false); }); it('main group can send to unregistered JID', () => { // Main is always authorized regardless of target - expect( - isMessageAuthorized('whatsapp_main', true, 'unknown@g.us', groups), - ).toBe(true); + expect(isMessageAuthorized('whatsapp_main', true, 'unknown@g.us', groups)).toBe(true); }); }); @@ -458,9 +394,7 @@ describe('schedule_task schedule types', () => { expect(tasks[0].schedule_type).toBe('cron'); expect(tasks[0].next_run).toBeTruthy(); // next_run should be a valid ISO date in the future - expect(new Date(tasks[0].next_run!).getTime()).toBeGreaterThan( - Date.now() - 60000, - ); + expect(new Date(tasks[0].next_run!).getTime()).toBeGreaterThan(Date.now() - 60000); }); it('rejects invalid cron expression', async () => { diff --git a/src/ipc.ts b/src/ipc.ts index e17167110..badccb4b7 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -67,9 +67,7 @@ export function startIpcWatcher(deps: IpcDeps): void { // Process messages from this group's IPC directory try { if (fs.existsSync(messagesDir)) { - const messageFiles = fs - .readdirSync(messagesDir) - .filter((f) => f.endsWith('.json')); + const messageFiles = fs.readdirSync(messagesDir).filter((f) => f.endsWith('.json')); for (const file of messageFiles) { const filePath = path.join(messagesDir, file); try { @@ -77,50 +75,30 @@ export function startIpcWatcher(deps: IpcDeps): void { if (data.type === 'message' && data.chatJid && data.text) { // Authorization: verify this group can send to this chatJid const targetGroup = registeredGroups[data.chatJid]; - if ( - isMain || - (targetGroup && targetGroup.folder === sourceGroup) - ) { + if (isMain || (targetGroup && targetGroup.folder === sourceGroup)) { await deps.sendMessage(data.chatJid, data.text); - logger.info( - { chatJid: data.chatJid, sourceGroup }, - 'IPC message sent', - ); + logger.info({ chatJid: data.chatJid, sourceGroup }, 'IPC message sent'); } else { - logger.warn( - { chatJid: data.chatJid, sourceGroup }, - 'Unauthorized IPC message attempt blocked', - ); + logger.warn({ chatJid: data.chatJid, sourceGroup }, 'Unauthorized IPC message attempt blocked'); } } fs.unlinkSync(filePath); } catch (err) { - logger.error( - { file, sourceGroup, err }, - 'Error processing IPC message', - ); + logger.error({ file, sourceGroup, err }, 'Error processing IPC message'); const errorDir = path.join(ipcBaseDir, 'errors'); fs.mkdirSync(errorDir, { recursive: true }); - fs.renameSync( - filePath, - path.join(errorDir, `${sourceGroup}-${file}`), - ); + fs.renameSync(filePath, path.join(errorDir, `${sourceGroup}-${file}`)); } } } } catch (err) { - logger.error( - { err, sourceGroup }, - 'Error reading IPC messages directory', - ); + logger.error({ err, sourceGroup }, 'Error reading IPC messages directory'); } // Process tasks from this group's IPC directory try { if (fs.existsSync(tasksDir)) { - const taskFiles = fs - .readdirSync(tasksDir) - .filter((f) => f.endsWith('.json')); + const taskFiles = fs.readdirSync(tasksDir).filter((f) => f.endsWith('.json')); for (const file of taskFiles) { const filePath = path.join(tasksDir, file); try { @@ -129,16 +107,10 @@ export function startIpcWatcher(deps: IpcDeps): void { await processTaskIpc(data, sourceGroup, isMain, deps); fs.unlinkSync(filePath); } catch (err) { - logger.error( - { file, sourceGroup, err }, - 'Error processing IPC task', - ); + logger.error({ file, sourceGroup, err }, 'Error processing IPC task'); const errorDir = path.join(ipcBaseDir, 'errors'); fs.mkdirSync(errorDir, { recursive: true }); - fs.renameSync( - filePath, - path.join(errorDir, `${sourceGroup}-${file}`), - ); + fs.renameSync(filePath, path.join(errorDir, `${sourceGroup}-${file}`)); } } } @@ -182,21 +154,13 @@ export async function processTaskIpc( switch (data.type) { case 'schedule_task': - if ( - data.prompt && - data.schedule_type && - data.schedule_value && - data.targetJid - ) { + if (data.prompt && data.schedule_type && data.schedule_value && data.targetJid) { // Resolve the target group from JID const targetJid = data.targetJid as string; const targetGroupEntry = registeredGroups[targetJid]; if (!targetGroupEntry) { - logger.warn( - { targetJid }, - 'Cannot schedule task: target group not registered', - ); + logger.warn({ targetJid }, 'Cannot schedule task: target group not registered'); break; } @@ -204,10 +168,7 @@ export async function processTaskIpc( // Authorization: non-main groups can only schedule for themselves if (!isMain && targetFolder !== sourceGroup) { - logger.warn( - { sourceGroup, targetFolder }, - 'Unauthorized schedule_task attempt blocked', - ); + logger.warn({ sourceGroup, targetFolder }, 'Unauthorized schedule_task attempt blocked'); break; } @@ -221,41 +182,28 @@ export async function processTaskIpc( }); nextRun = interval.next().toISOString(); } catch { - logger.warn( - { scheduleValue: data.schedule_value }, - 'Invalid cron expression', - ); + logger.warn({ scheduleValue: data.schedule_value }, 'Invalid cron expression'); break; } } else if (scheduleType === 'interval') { const ms = parseInt(data.schedule_value, 10); if (isNaN(ms) || ms <= 0) { - logger.warn( - { scheduleValue: data.schedule_value }, - 'Invalid interval', - ); + logger.warn({ scheduleValue: data.schedule_value }, 'Invalid interval'); break; } nextRun = new Date(Date.now() + ms).toISOString(); } else if (scheduleType === 'once') { const date = new Date(data.schedule_value); if (isNaN(date.getTime())) { - logger.warn( - { scheduleValue: data.schedule_value }, - 'Invalid timestamp', - ); + logger.warn({ scheduleValue: data.schedule_value }, 'Invalid timestamp'); break; } nextRun = date.toISOString(); } - const taskId = - data.taskId || - `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const taskId = data.taskId || `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const contextMode = - data.context_mode === 'group' || data.context_mode === 'isolated' - ? data.context_mode - : 'isolated'; + data.context_mode === 'group' || data.context_mode === 'isolated' ? data.context_mode : 'isolated'; createTask({ id: taskId, group_folder: targetFolder, @@ -269,10 +217,7 @@ export async function processTaskIpc( status: 'active', created_at: new Date().toISOString(), }); - logger.info( - { taskId, sourceGroup, targetFolder, contextMode }, - 'Task created via IPC', - ); + logger.info({ taskId, sourceGroup, targetFolder, contextMode }, 'Task created via IPC'); deps.onTasksChanged(); } break; @@ -282,16 +227,10 @@ export async function processTaskIpc( const task = getTaskById(data.taskId); if (task && (isMain || task.group_folder === sourceGroup)) { updateTask(data.taskId, { status: 'paused' }); - logger.info( - { taskId: data.taskId, sourceGroup }, - 'Task paused via IPC', - ); + logger.info({ taskId: data.taskId, sourceGroup }, 'Task paused via IPC'); deps.onTasksChanged(); } else { - logger.warn( - { taskId: data.taskId, sourceGroup }, - 'Unauthorized task pause attempt', - ); + logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task pause attempt'); } } break; @@ -301,16 +240,10 @@ export async function processTaskIpc( const task = getTaskById(data.taskId); if (task && (isMain || task.group_folder === sourceGroup)) { updateTask(data.taskId, { status: 'active' }); - logger.info( - { taskId: data.taskId, sourceGroup }, - 'Task resumed via IPC', - ); + logger.info({ taskId: data.taskId, sourceGroup }, 'Task resumed via IPC'); deps.onTasksChanged(); } else { - logger.warn( - { taskId: data.taskId, sourceGroup }, - 'Unauthorized task resume attempt', - ); + logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task resume attempt'); } } break; @@ -320,16 +253,10 @@ export async function processTaskIpc( const task = getTaskById(data.taskId); if (task && (isMain || task.group_folder === sourceGroup)) { deleteTask(data.taskId); - logger.info( - { taskId: data.taskId, sourceGroup }, - 'Task cancelled via IPC', - ); + logger.info({ taskId: data.taskId, sourceGroup }, 'Task cancelled via IPC'); deps.onTasksChanged(); } else { - logger.warn( - { taskId: data.taskId, sourceGroup }, - 'Unauthorized task cancel attempt', - ); + logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task cancel attempt'); } } break; @@ -338,17 +265,11 @@ export async function processTaskIpc( if (data.taskId) { const task = getTaskById(data.taskId); if (!task) { - logger.warn( - { taskId: data.taskId, sourceGroup }, - 'Task not found for update', - ); + logger.warn({ taskId: data.taskId, sourceGroup }, 'Task not found for update'); break; } if (!isMain && task.group_folder !== sourceGroup) { - logger.warn( - { taskId: data.taskId, sourceGroup }, - 'Unauthorized task update attempt', - ); + logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task update attempt'); break; } @@ -356,12 +277,8 @@ export async function processTaskIpc( if (data.prompt !== undefined) updates.prompt = data.prompt; if (data.script !== undefined) updates.script = data.script || null; if (data.schedule_type !== undefined) - updates.schedule_type = data.schedule_type as - | 'cron' - | 'interval' - | 'once'; - if (data.schedule_value !== undefined) - updates.schedule_value = data.schedule_value; + updates.schedule_type = data.schedule_type as 'cron' | 'interval' | 'once'; + if (data.schedule_value !== undefined) updates.schedule_value = data.schedule_value; // Recompute next_run if schedule changed if (data.schedule_type || data.schedule_value) { @@ -371,16 +288,10 @@ export async function processTaskIpc( }; if (updatedTask.schedule_type === 'cron') { try { - const interval = CronExpressionParser.parse( - updatedTask.schedule_value, - { tz: TIMEZONE }, - ); + const interval = CronExpressionParser.parse(updatedTask.schedule_value, { tz: TIMEZONE }); updates.next_run = interval.next().toISOString(); } catch { - logger.warn( - { taskId: data.taskId, value: updatedTask.schedule_value }, - 'Invalid cron in task update', - ); + logger.warn({ taskId: data.taskId, value: updatedTask.schedule_value }, 'Invalid cron in task update'); break; } } else if (updatedTask.schedule_type === 'interval') { @@ -392,10 +303,7 @@ export async function processTaskIpc( } updateTask(data.taskId, updates); - logger.info( - { taskId: data.taskId, sourceGroup, updates }, - 'Task updated via IPC', - ); + logger.info({ taskId: data.taskId, sourceGroup, updates }, 'Task updated via IPC'); deps.onTasksChanged(); } break; @@ -403,42 +311,25 @@ export async function processTaskIpc( case 'refresh_groups': // Only main group can request a refresh if (isMain) { - logger.info( - { sourceGroup }, - 'Group metadata refresh requested via IPC', - ); + logger.info({ sourceGroup }, 'Group metadata refresh requested via IPC'); await deps.syncGroups(true); // Write updated snapshot immediately const availableGroups = deps.getAvailableGroups(); - deps.writeGroupsSnapshot( - sourceGroup, - true, - availableGroups, - new Set(Object.keys(registeredGroups)), - ); + deps.writeGroupsSnapshot(sourceGroup, true, availableGroups, new Set(Object.keys(registeredGroups))); } else { - logger.warn( - { sourceGroup }, - 'Unauthorized refresh_groups attempt blocked', - ); + logger.warn({ sourceGroup }, 'Unauthorized refresh_groups attempt blocked'); } break; case 'register_group': // Only main group can register new groups if (!isMain) { - logger.warn( - { sourceGroup }, - 'Unauthorized register_group attempt blocked', - ); + logger.warn({ sourceGroup }, 'Unauthorized register_group attempt blocked'); break; } if (data.jid && data.name && data.folder && data.trigger) { if (!isValidGroupFolder(data.folder)) { - logger.warn( - { sourceGroup, folder: data.folder }, - 'Invalid register_group request - unsafe folder name', - ); + logger.warn({ sourceGroup, folder: data.folder }, 'Invalid register_group request - unsafe folder name'); break; } // Defense in depth: agent cannot set isMain via IPC. @@ -455,10 +346,7 @@ export async function processTaskIpc( isMain: existingGroup?.isMain, }); } else { - logger.warn( - { data }, - 'Invalid register_group request - missing required fields', - ); + logger.warn({ data }, 'Invalid register_group request - missing required fields'); } break; diff --git a/src/logger.ts b/src/logger.ts index 6b18a9b17..df2511c64 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -13,8 +13,7 @@ const MSG_COLOR = '\x1b[36m'; const RESET = '\x1b[39m'; const FULL_RESET = '\x1b[0m'; -const threshold = - LEVELS[(process.env.LOG_LEVEL as Level) || 'info'] ?? LEVELS.info; +const threshold = LEVELS[(process.env.LOG_LEVEL as Level) || 'info'] ?? LEVELS.info; function formatErr(err: unknown): string { if (err instanceof Error) { @@ -40,36 +39,23 @@ function ts(): string { return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`; } -function log( - level: Level, - dataOrMsg: Record | string, - msg?: string, -): void { +function log(level: Level, dataOrMsg: Record | string, msg?: string): void { if (LEVELS[level] < threshold) return; const tag = `${COLORS[level]}${level.toUpperCase()}${level === 'fatal' ? FULL_RESET : RESET}`; const stream = LEVELS[level] >= LEVELS.warn ? process.stderr : process.stdout; if (typeof dataOrMsg === 'string') { - stream.write( - `[${ts()}] ${tag} (${process.pid}): ${MSG_COLOR}${dataOrMsg}${RESET}\n`, - ); + stream.write(`[${ts()}] ${tag} (${process.pid}): ${MSG_COLOR}${dataOrMsg}${RESET}\n`); } else { - stream.write( - `[${ts()}] ${tag} (${process.pid}): ${MSG_COLOR}${msg}${RESET}${formatData(dataOrMsg)}\n`, - ); + stream.write(`[${ts()}] ${tag} (${process.pid}): ${MSG_COLOR}${msg}${RESET}${formatData(dataOrMsg)}\n`); } } export const logger = { - debug: (dataOrMsg: Record | string, msg?: string) => - log('debug', dataOrMsg, msg), - info: (dataOrMsg: Record | string, msg?: string) => - log('info', dataOrMsg, msg), - warn: (dataOrMsg: Record | string, msg?: string) => - log('warn', dataOrMsg, msg), - error: (dataOrMsg: Record | string, msg?: string) => - log('error', dataOrMsg, msg), - fatal: (dataOrMsg: Record | string, msg?: string) => - log('fatal', dataOrMsg, msg), + debug: (dataOrMsg: Record | string, msg?: string) => log('debug', dataOrMsg, msg), + info: (dataOrMsg: Record | string, msg?: string) => log('info', dataOrMsg, msg), + warn: (dataOrMsg: Record | string, msg?: string) => log('warn', dataOrMsg, msg), + error: (dataOrMsg: Record | string, msg?: string) => log('error', dataOrMsg, msg), + fatal: (dataOrMsg: Record | string, msg?: string) => log('fatal', dataOrMsg, msg), }; // Route uncaught errors through logger so they get timestamps in stderr diff --git a/src/mount-security.ts b/src/mount-security.ts index 4a9eb1212..c44620c38 100644 --- a/src/mount-security.ts +++ b/src/mount-security.ts @@ -84,9 +84,7 @@ export function loadMountAllowlist(): MountAllowlist | null { } // Merge with default blocked patterns - const mergedBlockedPatterns = [ - ...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns]), - ]; + const mergedBlockedPatterns = [...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns])]; allowlist.blockedPatterns = mergedBlockedPatterns; cachedAllowlist = allowlist; @@ -142,10 +140,7 @@ function getRealPath(p: string): string | null { /** * Check if a path matches any blocked pattern */ -function matchesBlockedPattern( - realPath: string, - blockedPatterns: string[], -): string | null { +function matchesBlockedPattern(realPath: string, blockedPatterns: string[]): string | null { const pathParts = realPath.split(path.sep); for (const pattern of blockedPatterns) { @@ -168,10 +163,7 @@ function matchesBlockedPattern( /** * Check if a real path is under an allowed root */ -function findAllowedRoot( - realPath: string, - allowedRoots: AllowedRoot[], -): AllowedRoot | null { +function findAllowedRoot(realPath: string, allowedRoots: AllowedRoot[]): AllowedRoot | null { for (const root of allowedRoots) { const expandedRoot = expandPath(root.path); const realRoot = getRealPath(expandedRoot); @@ -230,10 +222,7 @@ export interface MountValidationResult { * Validate a single additional mount against the allowlist. * Returns validation result with reason. */ -export function validateMount( - mount: AdditionalMount, - isMain: boolean, -): MountValidationResult { +export function validateMount(mount: AdditionalMount, isMain: boolean): MountValidationResult { const allowlist = loadMountAllowlist(); // If no allowlist, block all additional mounts @@ -267,10 +256,7 @@ export function validateMount( } // Check against blocked patterns - const blockedMatch = matchesBlockedPattern( - realPath, - allowlist.blockedPatterns, - ); + const blockedMatch = matchesBlockedPattern(realPath, allowlist.blockedPatterns); if (blockedMatch !== null) { return { allowed: false, diff --git a/src/remote-control.test.ts b/src/remote-control.test.ts index 7dbf69ced..da8f05d88 100644 --- a/src/remote-control.test.ts +++ b/src/remote-control.test.ts @@ -50,20 +50,14 @@ describe('remote-control', () => { stdoutFileContent = ''; // Default fs mocks - _mkdirSyncSpy = vi - .spyOn(fs, 'mkdirSync') - .mockImplementation(() => undefined as any); - writeFileSyncSpy = vi - .spyOn(fs, 'writeFileSync') - .mockImplementation(() => {}); + _mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined as any); + writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => {}); unlinkSyncSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {}); openSyncSpy = vi.spyOn(fs, 'openSync').mockReturnValue(42 as any); closeSyncSpy = vi.spyOn(fs, 'closeSync').mockImplementation(() => {}); // readFileSync: return stdoutFileContent for the stdout file, state file, etc. - readFileSyncSpy = vi.spyOn(fs, 'readFileSync').mockImplementation((( - p: string, - ) => { + readFileSyncSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(((p: string) => { if (p.endsWith('remote-control.stdout')) return stdoutFileContent; if (p.endsWith('remote-control.json')) { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); @@ -85,8 +79,7 @@ describe('remote-control', () => { spawnMock.mockReturnValue(proc); // Simulate URL appearing in stdout file on first poll - stdoutFileContent = - 'Session URL: https://claude.ai/code?bridge=env_abc123\n'; + stdoutFileContent = 'Session URL: https://claude.ai/code?bridge=env_abc123\n'; vi.spyOn(process, 'kill').mockImplementation((() => true) as any); const result = await startRemoteControl('user1', 'tg:123', '/project'); @@ -140,10 +133,7 @@ describe('remote-control', () => { await startRemoteControl('user1', 'tg:123', '/project'); - expect(writeFileSyncSpy).toHaveBeenCalledWith( - STATE_FILE, - expect.stringContaining('"pid":99999'), - ); + expect(writeFileSyncSpy).toHaveBeenCalledWith(STATE_FILE, expect.stringContaining('"pid":99999')); }); it('returns existing URL if session is already active', async () => { @@ -169,9 +159,7 @@ describe('remote-control', () => { spawnMock.mockReturnValueOnce(proc1).mockReturnValueOnce(proc2); // First start: process alive, URL found - const killSpy = vi - .spyOn(process, 'kill') - .mockImplementation((() => true) as any); + const killSpy = vi.spyOn(process, 'kill').mockImplementation((() => true) as any); stdoutFileContent = 'https://claude.ai/code?bridge=env_first\n'; await startRemoteControl('user1', 'tg:123', '/project'); @@ -253,9 +241,7 @@ describe('remote-control', () => { const proc = createMockProcess(55555); spawnMock.mockReturnValue(proc); stdoutFileContent = 'https://claude.ai/code?bridge=env_stop\n'; - const killSpy = vi - .spyOn(process, 'kill') - .mockImplementation((() => true) as any); + const killSpy = vi.spyOn(process, 'kill').mockImplementation((() => true) as any); await startRemoteControl('user1', 'tg:123', '/project'); @@ -353,9 +339,7 @@ describe('remote-control', () => { if (p.endsWith('remote-control.json')) return JSON.stringify(session); return ''; }) as any); - const killSpy = vi - .spyOn(process, 'kill') - .mockImplementation((() => true) as any); + const killSpy = vi.spyOn(process, 'kill').mockImplementation((() => true) as any); restoreRemoteControl(); expect(getActiveSession()).not.toBeNull(); @@ -383,15 +367,13 @@ describe('remote-control', () => { restoreRemoteControl(); - return startRemoteControl('user2', 'tg:456', '/project').then( - (result) => { - expect(result).toEqual({ - ok: true, - url: 'https://claude.ai/code?bridge=env_restored', - }); - expect(spawnMock).not.toHaveBeenCalled(); - }, - ); + return startRemoteControl('user2', 'tg:456', '/project').then((result) => { + expect(result).toEqual({ + ok: true, + url: 'https://claude.ai/code?bridge=env_restored', + }); + expect(spawnMock).not.toHaveBeenCalled(); + }); }); }); }); diff --git a/src/remote-control.ts b/src/remote-control.ts index 2f0bdc4ad..2a6799a29 100644 --- a/src/remote-control.ts +++ b/src/remote-control.ts @@ -60,10 +60,7 @@ export function restoreRemoteControl(): void { const session: RemoteControlSession = JSON.parse(data); if (session.pid && isProcessAlive(session.pid)) { activeSession = session; - logger.info( - { pid: session.pid, url: session.url }, - 'Restored Remote Control session from previous run', - ); + logger.info({ pid: session.pid, url: session.url }, 'Restored Remote Control session from previous run'); } else { clearState(); } @@ -169,10 +166,7 @@ export async function startRemoteControl( activeSession = session; saveState(session); - logger.info( - { url: match[0], pid, sender, chatJid }, - 'Remote Control session started', - ); + logger.info({ url: match[0], pid, sender, chatJid }, 'Remote Control session started'); resolve({ ok: true, url: match[0] }); return; } diff --git a/src/router.ts b/src/router.ts index d6f88ad16..4c7dd3841 100644 --- a/src/router.ts +++ b/src/router.ts @@ -3,22 +3,13 @@ import { formatLocalTime } from './timezone.js'; export function escapeXml(s: string): string { if (!s) return ''; - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } -export function formatMessages( - messages: NewMessage[], - timezone: string, -): string { +export function formatMessages(messages: NewMessage[], timezone: string): string { const lines = messages.map((m) => { const displayTime = formatLocalTime(m.timestamp, timezone); - const replyAttr = m.reply_to_message_id - ? ` reply_to="${escapeXml(m.reply_to_message_id)}"` - : ''; + const replyAttr = m.reply_to_message_id ? ` reply_to="${escapeXml(m.reply_to_message_id)}"` : ''; const replySnippet = m.reply_to_message_content && m.reply_to_sender_name ? `\n ${escapeXml(m.reply_to_message_content)}` @@ -41,19 +32,12 @@ export function formatOutbound(rawText: string): string { return text; } -export function routeOutbound( - channels: Channel[], - jid: string, - text: string, -): Promise { +export function routeOutbound(channels: Channel[], jid: string, text: string): Promise { const channel = channels.find((c) => c.ownsJid(jid) && c.isConnected()); if (!channel) throw new Error(`No channel for JID: ${jid}`); return channel.sendMessage(jid, text); } -export function findChannel( - channels: Channel[], - jid: string, -): Channel | undefined { +export function findChannel(channels: Channel[], jid: string): Channel | undefined { return channels.find((c) => c.ownsJid(jid)); } diff --git a/src/routing.test.ts b/src/routing.test.ts index 6e445867a..9276f4810 100644 --- a/src/routing.test.ts +++ b/src/routing.test.ts @@ -28,27 +28,9 @@ describe('JID ownership patterns', () => { describe('getAvailableGroups', () => { it('returns only groups, excludes DMs', () => { - storeChatMetadata( - 'group1@g.us', - '2024-01-01T00:00:01.000Z', - 'Group 1', - 'whatsapp', - true, - ); - storeChatMetadata( - 'user@s.whatsapp.net', - '2024-01-01T00:00:02.000Z', - 'User DM', - 'whatsapp', - false, - ); - storeChatMetadata( - 'group2@g.us', - '2024-01-01T00:00:03.000Z', - 'Group 2', - 'whatsapp', - true, - ); + storeChatMetadata('group1@g.us', '2024-01-01T00:00:01.000Z', 'Group 1', 'whatsapp', true); + storeChatMetadata('user@s.whatsapp.net', '2024-01-01T00:00:02.000Z', 'User DM', 'whatsapp', false); + storeChatMetadata('group2@g.us', '2024-01-01T00:00:03.000Z', 'Group 2', 'whatsapp', true); const groups = getAvailableGroups(); expect(groups).toHaveLength(2); @@ -59,13 +41,7 @@ describe('getAvailableGroups', () => { it('excludes __group_sync__ sentinel', () => { storeChatMetadata('__group_sync__', '2024-01-01T00:00:00.000Z'); - storeChatMetadata( - 'group@g.us', - '2024-01-01T00:00:01.000Z', - 'Group', - 'whatsapp', - true, - ); + storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z', 'Group', 'whatsapp', true); const groups = getAvailableGroups(); expect(groups).toHaveLength(1); @@ -73,20 +49,8 @@ describe('getAvailableGroups', () => { }); it('marks registered groups correctly', () => { - storeChatMetadata( - 'reg@g.us', - '2024-01-01T00:00:01.000Z', - 'Registered', - 'whatsapp', - true, - ); - storeChatMetadata( - 'unreg@g.us', - '2024-01-01T00:00:02.000Z', - 'Unregistered', - 'whatsapp', - true, - ); + storeChatMetadata('reg@g.us', '2024-01-01T00:00:01.000Z', 'Registered', 'whatsapp', true); + storeChatMetadata('unreg@g.us', '2024-01-01T00:00:02.000Z', 'Unregistered', 'whatsapp', true); _setRegisteredGroups({ 'reg@g.us': { @@ -106,27 +70,9 @@ describe('getAvailableGroups', () => { }); it('returns groups ordered by most recent activity', () => { - storeChatMetadata( - 'old@g.us', - '2024-01-01T00:00:01.000Z', - 'Old', - 'whatsapp', - true, - ); - storeChatMetadata( - 'new@g.us', - '2024-01-01T00:00:05.000Z', - 'New', - 'whatsapp', - true, - ); - storeChatMetadata( - 'mid@g.us', - '2024-01-01T00:00:03.000Z', - 'Mid', - 'whatsapp', - true, - ); + storeChatMetadata('old@g.us', '2024-01-01T00:00:01.000Z', 'Old', 'whatsapp', true); + storeChatMetadata('new@g.us', '2024-01-01T00:00:05.000Z', 'New', 'whatsapp', true); + storeChatMetadata('mid@g.us', '2024-01-01T00:00:03.000Z', 'Mid', 'whatsapp', true); const groups = getAvailableGroups(); expect(groups[0].jid).toBe('new@g.us'); @@ -136,27 +82,11 @@ describe('getAvailableGroups', () => { it('excludes non-group chats regardless of JID format', () => { // Unknown JID format stored without is_group should not appear - storeChatMetadata( - 'unknown-format-123', - '2024-01-01T00:00:01.000Z', - 'Unknown', - ); + storeChatMetadata('unknown-format-123', '2024-01-01T00:00:01.000Z', 'Unknown'); // Explicitly non-group with unusual JID - storeChatMetadata( - 'custom:abc', - '2024-01-01T00:00:02.000Z', - 'Custom DM', - 'custom', - false, - ); + storeChatMetadata('custom:abc', '2024-01-01T00:00:02.000Z', 'Custom DM', 'custom', false); // A real group for contrast - storeChatMetadata( - 'group@g.us', - '2024-01-01T00:00:03.000Z', - 'Group', - 'whatsapp', - true, - ); + storeChatMetadata('group@g.us', '2024-01-01T00:00:03.000Z', 'Group', 'whatsapp', true); const groups = getAvailableGroups(); expect(groups).toHaveLength(1); diff --git a/src/sender-allowlist.ts b/src/sender-allowlist.ts index 9cc2bde5a..7a7a0feeb 100644 --- a/src/sender-allowlist.ts +++ b/src/sender-allowlist.ts @@ -23,16 +23,12 @@ const DEFAULT_CONFIG: SenderAllowlistConfig = { function isValidEntry(entry: unknown): entry is ChatAllowlistEntry { if (!entry || typeof entry !== 'object') return false; const e = entry as Record; - const validAllow = - e.allow === '*' || - (Array.isArray(e.allow) && e.allow.every((v) => typeof v === 'string')); + const validAllow = e.allow === '*' || (Array.isArray(e.allow) && e.allow.every((v) => typeof v === 'string')); const validMode = e.mode === 'trigger' || e.mode === 'drop'; return validAllow && validMode; } -export function loadSenderAllowlist( - pathOverride?: string, -): SenderAllowlistConfig { +export function loadSenderAllowlist(pathOverride?: string): SenderAllowlistConfig { const filePath = pathOverride ?? SENDER_ALLOWLIST_PATH; let raw: string; @@ -40,10 +36,7 @@ export function loadSenderAllowlist( raw = fs.readFileSync(filePath, 'utf-8'); } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return DEFAULT_CONFIG; - logger.warn( - { err, path: filePath }, - 'sender-allowlist: cannot read config', - ); + logger.warn({ err, path: filePath }, 'sender-allowlist: cannot read config'); return DEFAULT_CONFIG; } @@ -58,25 +51,17 @@ export function loadSenderAllowlist( const obj = parsed as Record; if (!isValidEntry(obj.default)) { - logger.warn( - { path: filePath }, - 'sender-allowlist: invalid or missing default entry', - ); + logger.warn({ path: filePath }, 'sender-allowlist: invalid or missing default entry'); return DEFAULT_CONFIG; } const chats: Record = {}; if (obj.chats && typeof obj.chats === 'object') { - for (const [jid, entry] of Object.entries( - obj.chats as Record, - )) { + for (const [jid, entry] of Object.entries(obj.chats as Record)) { if (isValidEntry(entry)) { chats[jid] = entry; } else { - logger.warn( - { jid, path: filePath }, - 'sender-allowlist: skipping invalid chat entry', - ); + logger.warn({ jid, path: filePath }, 'sender-allowlist: skipping invalid chat entry'); } } } @@ -88,41 +73,24 @@ export function loadSenderAllowlist( }; } -function getEntry( - chatJid: string, - cfg: SenderAllowlistConfig, -): ChatAllowlistEntry { +function getEntry(chatJid: string, cfg: SenderAllowlistConfig): ChatAllowlistEntry { return cfg.chats[chatJid] ?? cfg.default; } -export function isSenderAllowed( - chatJid: string, - sender: string, - cfg: SenderAllowlistConfig, -): boolean { +export function isSenderAllowed(chatJid: string, sender: string, cfg: SenderAllowlistConfig): boolean { const entry = getEntry(chatJid, cfg); if (entry.allow === '*') return true; return entry.allow.includes(sender); } -export function shouldDropMessage( - chatJid: string, - cfg: SenderAllowlistConfig, -): boolean { +export function shouldDropMessage(chatJid: string, cfg: SenderAllowlistConfig): boolean { return getEntry(chatJid, cfg).mode === 'drop'; } -export function isTriggerAllowed( - chatJid: string, - sender: string, - cfg: SenderAllowlistConfig, -): boolean { +export function isTriggerAllowed(chatJid: string, sender: string, cfg: SenderAllowlistConfig): boolean { const allowed = isSenderAllowed(chatJid, sender, cfg); if (!allowed && cfg.logDenied) { - logger.debug( - { chatJid, sender }, - 'sender-allowlist: trigger denied for sender', - ); + logger.debug({ chatJid, sender }, 'sender-allowlist: trigger denied for sender'); } return allowed; } diff --git a/src/task-scheduler.test.ts b/src/task-scheduler.test.ts index 2032b5177..f6eb00433 100644 --- a/src/task-scheduler.test.ts +++ b/src/task-scheduler.test.ts @@ -1,11 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { _initTestDatabase, createTask, getTaskById } from './db.js'; -import { - _resetSchedulerLoopForTests, - computeNextRun, - startSchedulerLoop, -} from './task-scheduler.js'; +import { _resetSchedulerLoopForTests, computeNextRun, startSchedulerLoop } from './task-scheduler.js'; describe('task scheduler', () => { beforeEach(() => { @@ -32,11 +28,9 @@ describe('task scheduler', () => { created_at: '2026-02-22T00:00:00.000Z', }); - const enqueueTask = vi.fn( - (_groupJid: string, _taskId: string, fn: () => Promise) => { - void fn(); - }, - ); + const enqueueTask = vi.fn((_groupJid: string, _taskId: string, fn: () => Promise) => { + void fn(); + }); startSchedulerLoop({ registeredGroups: () => ({}), @@ -122,8 +116,7 @@ describe('task scheduler', () => { // Must be in the future expect(new Date(nextRun!).getTime()).toBeGreaterThan(Date.now()); // Must be aligned to the original schedule grid - const offset = - (new Date(nextRun!).getTime() - new Date(scheduledTime).getTime()) % ms; + const offset = (new Date(nextRun!).getTime() - new Date(scheduledTime).getTime()) % ms; expect(offset).toBe(0); }); }); diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index f2b964d59..0d663a978 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -3,19 +3,8 @@ import { CronExpressionParser } from 'cron-parser'; import fs from 'fs'; import { ASSISTANT_NAME, SCHEDULER_POLL_INTERVAL, TIMEZONE } from './config.js'; -import { - ContainerOutput, - runContainerAgent, - writeTasksSnapshot, -} from './container-runner.js'; -import { - getAllTasks, - getDueTasks, - getTaskById, - logTaskRun, - updateTask, - updateTaskAfterRun, -} from './db.js'; +import { ContainerOutput, runContainerAgent, writeTasksSnapshot } from './container-runner.js'; +import { getAllTasks, getDueTasks, getTaskById, logTaskRun, updateTask, updateTaskAfterRun } from './db.js'; import { GroupQueue } from './group-queue.js'; import { resolveGroupFolderPath } from './group-folder.js'; import { logger } from './logger.js'; @@ -44,10 +33,7 @@ export function computeNextRun(task: ScheduledTask): string | null { const ms = parseInt(task.schedule_value, 10); if (!ms || ms <= 0) { // Guard against malformed interval that would cause an infinite loop - logger.warn( - { taskId: task.id, value: task.schedule_value }, - 'Invalid interval value', - ); + logger.warn({ taskId: task.id, value: task.schedule_value }, 'Invalid interval value'); return new Date(now + 60_000).toISOString(); } // Anchor to the scheduled time, not now, to prevent drift. @@ -66,19 +52,11 @@ export interface SchedulerDependencies { registeredGroups: () => Record; getSessions: () => Record; queue: GroupQueue; - onProcess: ( - groupJid: string, - proc: ChildProcess, - containerName: string, - groupFolder: string, - ) => void; + onProcess: (groupJid: string, proc: ChildProcess, containerName: string, groupFolder: string) => void; sendMessage: (jid: string, text: string) => Promise; } -async function runTask( - task: ScheduledTask, - deps: SchedulerDependencies, -): Promise { +async function runTask(task: ScheduledTask, deps: SchedulerDependencies): Promise { const startTime = Date.now(); let groupDir: string; try { @@ -87,10 +65,7 @@ async function runTask( const error = err instanceof Error ? err.message : String(err); // Stop retry churn for malformed legacy rows. updateTask(task.id, { status: 'paused' }); - logger.error( - { taskId: task.id, groupFolder: task.group_folder, error }, - 'Task has invalid group folder', - ); + logger.error({ taskId: task.id, groupFolder: task.group_folder, error }, 'Task has invalid group folder'); logTaskRun({ task_id: task.id, run_at: new Date().toISOString(), @@ -103,21 +78,13 @@ async function runTask( } fs.mkdirSync(groupDir, { recursive: true }); - logger.info( - { taskId: task.id, group: task.group_folder }, - 'Running scheduled task', - ); + logger.info({ taskId: task.id, group: task.group_folder }, 'Running scheduled task'); const groups = deps.registeredGroups(); - const group = Object.values(groups).find( - (g) => g.folder === task.group_folder, - ); + const group = Object.values(groups).find((g) => g.folder === task.group_folder); if (!group) { - logger.error( - { taskId: task.id, groupFolder: task.group_folder }, - 'Group not found for task', - ); + logger.error({ taskId: task.id, groupFolder: task.group_folder }, 'Group not found for task'); logTaskRun({ task_id: task.id, run_at: new Date().toISOString(), @@ -152,8 +119,7 @@ async function runTask( // For group context mode, use the group's current session const sessions = deps.getSessions(); - const sessionId = - task.context_mode === 'group' ? sessions[task.group_folder] : undefined; + const sessionId = task.context_mode === 'group' ? sessions[task.group_folder] : undefined; // After the task produces a result, close the container promptly. // Tasks are single-turn — no need to wait IDLE_TIMEOUT (30 min) for the @@ -182,8 +148,7 @@ async function runTask( assistantName: ASSISTANT_NAME, script: task.script || undefined, }, - (proc, containerName) => - deps.onProcess(task.chat_jid, proc, containerName, task.group_folder), + (proc, containerName) => deps.onProcess(task.chat_jid, proc, containerName, task.group_folder), async (streamedOutput: ContainerOutput) => { if (streamedOutput.result) { result = streamedOutput.result; @@ -210,10 +175,7 @@ async function runTask( result = output.result; } - logger.info( - { taskId: task.id, durationMs: Date.now() - startTime }, - 'Task completed', - ); + logger.info({ taskId: task.id, durationMs: Date.now() - startTime }, 'Task completed'); } catch (err) { if (closeTimer) clearTimeout(closeTimer); error = err instanceof Error ? err.message : String(err); @@ -232,11 +194,7 @@ async function runTask( }); const nextRun = computeNextRun(task); - const resultSummary = error - ? `Error: ${error}` - : result - ? result.slice(0, 200) - : 'Completed'; + const resultSummary = error ? `Error: ${error}` : result ? result.slice(0, 200) : 'Completed'; updateTaskAfterRun(task.id, nextRun, resultSummary); } @@ -264,9 +222,7 @@ export function startSchedulerLoop(deps: SchedulerDependencies): void { continue; } - deps.queue.enqueueTask(currentTask.chat_jid, currentTask.id, () => - runTask(currentTask, deps), - ); + deps.queue.enqueueTask(currentTask.chat_jid, currentTask.id, () => runTask(currentTask, deps)); } } catch (err) { logger.error({ err }, 'Error in scheduler loop'); diff --git a/src/timezone.test.ts b/src/timezone.test.ts index 1003a610e..d9e945403 100644 --- a/src/timezone.test.ts +++ b/src/timezone.test.ts @@ -1,20 +1,13 @@ import { describe, it, expect } from 'vitest'; -import { - formatLocalTime, - isValidTimezone, - resolveTimezone, -} from './timezone.js'; +import { formatLocalTime, isValidTimezone, resolveTimezone } from './timezone.js'; // --- formatLocalTime --- describe('formatLocalTime', () => { it('converts UTC to local time display', () => { // 2026-02-04T18:30:00Z in America/New_York (EST, UTC-5) = 1:30 PM - const result = formatLocalTime( - '2026-02-04T18:30:00.000Z', - 'America/New_York', - ); + const result = formatLocalTime('2026-02-04T18:30:00.000Z', 'America/New_York'); expect(result).toContain('1:30'); expect(result).toContain('PM'); expect(result).toContain('Feb'); @@ -32,9 +25,7 @@ describe('formatLocalTime', () => { }); it('does not throw on invalid timezone, falls back to UTC', () => { - expect(() => - formatLocalTime('2026-01-01T00:00:00.000Z', 'IST-2'), - ).not.toThrow(); + expect(() => formatLocalTime('2026-01-01T00:00:00.000Z', 'IST-2')).not.toThrow(); const result = formatLocalTime('2026-01-01T12:00:00.000Z', 'IST-2'); // Should format as UTC (noon UTC = 12:00 PM) expect(result).toContain('12:00');