mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
chore: set printWidth to 120 and reformat
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"singleQuote": true
|
||||
"singleQuote": true,
|
||||
"printWidth": 120
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
+11
-45
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<typeof createFakeProcess>;
|
||||
|
||||
// Mock child_process.spawn
|
||||
vi.mock('child_process', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('child_process')>('child_process');
|
||||
const actual = await vi.importActual<typeof import('child_process')>('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<typeof createFakeProcess>,
|
||||
output: ContainerOutput,
|
||||
) {
|
||||
function emitOutputMarker(proc: ReturnType<typeof createFakeProcess>, 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, {
|
||||
|
||||
+25
-103
@@ -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));
|
||||
|
||||
@@ -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',
|
||||
|
||||
+14
-36
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
+15
-76
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ScheduledTask, 'last_run' | 'last_result'>,
|
||||
): void {
|
||||
export function createTask(task: Omit<ScheduledTask, 'last_run' | 'last_result'>): 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<ScheduledTask, 'prompt' | 'script' | 'schedule_type' | 'schedule_value' | 'next_run' | 'status'>
|
||||
>,
|
||||
): 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<string, string> {
|
||||
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<string, string> = {};
|
||||
for (const row of rows) {
|
||||
result[row.group_folder] = row.session_id;
|
||||
@@ -594,12 +528,8 @@ export function getAllSessions(): Record<string, string> {
|
||||
|
||||
// --- 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<string, RegisteredGroup> {
|
||||
const result: Record<string, RegisteredGroup> = {};
|
||||
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<string, RegisteredGroup> {
|
||||
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<string, string> | 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<string, RegisteredGroup> | 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -31,8 +31,7 @@ export function readEnvFile(keys: string[]): Record<string, string> {
|
||||
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);
|
||||
}
|
||||
|
||||
+13
-47
@@ -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> = {}): 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: '<script>alert("xss")</script>' })],
|
||||
TZ,
|
||||
);
|
||||
expect(result).toContain(
|
||||
'<script>alert("xss")</script>',
|
||||
);
|
||||
const result = formatMessages([makeMsg({ content: '<script>alert("xss")</script>' })], 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(
|
||||
'<quoted_message from="Bob">Are you coming tonight?</quoted_message>',
|
||||
);
|
||||
expect(result).toContain('<quoted_message from="Bob">Are you coming tonight?</quoted_message>');
|
||||
expect(result).toContain('Yes, on my way!</message>');
|
||||
});
|
||||
|
||||
@@ -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('<context timezone="America/New_York" />');
|
||||
@@ -247,21 +224,15 @@ describe('getTriggerPattern', () => {
|
||||
|
||||
describe('stripInternalTags', () => {
|
||||
it('strips single-line internal tags', () => {
|
||||
expect(stripInternalTags('hello <internal>secret</internal> world')).toBe(
|
||||
'hello world',
|
||||
);
|
||||
expect(stripInternalTags('hello <internal>secret</internal> world')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('strips multi-line internal tags', () => {
|
||||
expect(
|
||||
stripInternalTags('hello <internal>\nsecret\nstuff\n</internal> world'),
|
||||
).toBe('hello world');
|
||||
expect(stripInternalTags('hello <internal>\nsecret\nstuff\n</internal> world')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('strips multiple internal tag blocks', () => {
|
||||
expect(
|
||||
stripInternalTags('<internal>a</internal>hello<internal>b</internal>'),
|
||||
).toBe('hello');
|
||||
expect(stripInternalTags('<internal>a</internal>hello<internal>b</internal>')).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('<internal>thinking</internal>The answer is 42'),
|
||||
).toBe('The answer is 42');
|
||||
expect(formatOutbound('<internal>thinking</internal>The 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
+6
-33
@@ -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!();
|
||||
|
||||
+14
-54
@@ -31,8 +31,7 @@ export class GroupQueue {
|
||||
private groups = new Map<string, GroupState>();
|
||||
private activeCount = 0;
|
||||
private waitingGroups: string[] = [];
|
||||
private processMessagesFn: ((groupJid: string) => Promise<boolean>) | null =
|
||||
null;
|
||||
private processMessagesFn: ((groupJid: string) => Promise<boolean>) | 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<void> {
|
||||
private async runForGroup(groupJid: string, reason: 'messages' | 'drain'): Promise<void> {
|
||||
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
|
||||
|
||||
+42
-165
@@ -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<string, RegisteredGroup>,
|
||||
): void {
|
||||
export function _setRegisteredGroups(groups: Record<string, RegisteredGroup>): void {
|
||||
registeredGroups = groups;
|
||||
}
|
||||
|
||||
@@ -245,8 +201,7 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
|
||||
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<boolean> {
|
||||
// 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<typeof setTimeout> | null = null;
|
||||
@@ -271,10 +222,7 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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 <internal>...</internal> blocks — agent uses these for internal reasoning
|
||||
const text = raw.replace(/<internal>[\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<boolean> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
*/
|
||||
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<void> {
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
// Handle /remote-control and /remote-control-end commands
|
||||
async function handleRemoteControl(
|
||||
command: string,
|
||||
chatJid: string,
|
||||
msg: NewMessage,
|
||||
): Promise<void> {
|
||||
async function handleRemoteControl(command: string, chatJid: string, msg: NewMessage): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
|
||||
// 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) => {
|
||||
|
||||
+18
-84
@@ -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 () => {
|
||||
|
||||
+38
-150
@@ -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;
|
||||
|
||||
|
||||
+9
-23
@@ -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, unknown> | string,
|
||||
msg?: string,
|
||||
): void {
|
||||
function log(level: Level, dataOrMsg: Record<string, unknown> | 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, unknown> | string, msg?: string) =>
|
||||
log('debug', dataOrMsg, msg),
|
||||
info: (dataOrMsg: Record<string, unknown> | string, msg?: string) =>
|
||||
log('info', dataOrMsg, msg),
|
||||
warn: (dataOrMsg: Record<string, unknown> | string, msg?: string) =>
|
||||
log('warn', dataOrMsg, msg),
|
||||
error: (dataOrMsg: Record<string, unknown> | string, msg?: string) =>
|
||||
log('error', dataOrMsg, msg),
|
||||
fatal: (dataOrMsg: Record<string, unknown> | string, msg?: string) =>
|
||||
log('fatal', dataOrMsg, msg),
|
||||
debug: (dataOrMsg: Record<string, unknown> | string, msg?: string) => log('debug', dataOrMsg, msg),
|
||||
info: (dataOrMsg: Record<string, unknown> | string, msg?: string) => log('info', dataOrMsg, msg),
|
||||
warn: (dataOrMsg: Record<string, unknown> | string, msg?: string) => log('warn', dataOrMsg, msg),
|
||||
error: (dataOrMsg: Record<string, unknown> | string, msg?: string) => log('error', dataOrMsg, msg),
|
||||
fatal: (dataOrMsg: Record<string, unknown> | string, msg?: string) => log('fatal', dataOrMsg, msg),
|
||||
};
|
||||
|
||||
// Route uncaught errors through logger so they get timestamps in stderr
|
||||
|
||||
+5
-19
@@ -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,
|
||||
|
||||
+15
-33
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+5
-21
@@ -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, '>')
|
||||
.replace(/"/g, '"');
|
||||
return s.replace(/&/g, '&').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 <quoted_message from="${escapeXml(m.reply_to_sender_name)}">${escapeXml(m.reply_to_message_content)}</quoted_message>`
|
||||
@@ -41,19 +32,12 @@ export function formatOutbound(rawText: string): string {
|
||||
return text;
|
||||
}
|
||||
|
||||
export function routeOutbound(
|
||||
channels: Channel[],
|
||||
jid: string,
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
export function routeOutbound(channels: Channel[], jid: string, text: string): Promise<void> {
|
||||
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));
|
||||
}
|
||||
|
||||
+12
-82
@@ -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);
|
||||
|
||||
+11
-43
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
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<string, ChatAllowlistEntry> = {};
|
||||
if (obj.chats && typeof obj.chats === 'object') {
|
||||
for (const [jid, entry] of Object.entries(
|
||||
obj.chats as Record<string, unknown>,
|
||||
)) {
|
||||
for (const [jid, entry] of Object.entries(obj.chats as Record<string, unknown>)) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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>) => {
|
||||
void fn();
|
||||
},
|
||||
);
|
||||
const enqueueTask = vi.fn((_groupJid: string, _taskId: string, fn: () => Promise<void>) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
+14
-58
@@ -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<string, RegisteredGroup>;
|
||||
getSessions: () => Record<string, string>;
|
||||
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<void>;
|
||||
}
|
||||
|
||||
async function runTask(
|
||||
task: ScheduledTask,
|
||||
deps: SchedulerDependencies,
|
||||
): Promise<void> {
|
||||
async function runTask(task: ScheduledTask, deps: SchedulerDependencies): Promise<void> {
|
||||
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');
|
||||
|
||||
+3
-12
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user