Merge pull request #1838 from qwibitai/refactor/pr1-prep
refactor: PR #1 prep — v1 removal, module contract, sdk bump
This commit is contained in:
@@ -8,8 +8,6 @@ The host is a single Node process that orchestrates per-session agent containers
|
||||
|
||||
**Everything is a message.** There is no IPC, no file watcher, no stdin piping between host and container. The two session DBs are the sole IO surface.
|
||||
|
||||
A `src/v1/` tree exists for historical reference and is not part of the runtime — ignore it unless you're explicitly working on a migration.
|
||||
|
||||
## Entity Model
|
||||
|
||||
```
|
||||
|
||||
@@ -1,736 +0,0 @@
|
||||
/**
|
||||
* NanoClaw Agent Runner
|
||||
* Runs inside a container, receives config via stdin, outputs result to stdout
|
||||
*
|
||||
* Input protocol:
|
||||
* Stdin: Full ContainerInput JSON (read until EOF, like before)
|
||||
* IPC: Follow-up messages written as JSON files to /workspace/ipc/input/
|
||||
* Files: {type:"message", text:"..."}.json — polled and consumed
|
||||
* Sentinel: /workspace/ipc/input/_close — signals session end
|
||||
*
|
||||
* Stdout protocol:
|
||||
* Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs.
|
||||
* Multiple results may be emitted (one per agent teams result).
|
||||
* Final marker after loop ends signals completion.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { execFile } from 'child_process';
|
||||
import {
|
||||
query,
|
||||
HookCallback,
|
||||
PreCompactHookInput,
|
||||
} from '@anthropic-ai/claude-agent-sdk';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
interface ContainerInput {
|
||||
prompt: string;
|
||||
sessionId?: string;
|
||||
groupFolder: string;
|
||||
chatJid: string;
|
||||
isMain: boolean;
|
||||
isScheduledTask?: boolean;
|
||||
assistantName?: string;
|
||||
script?: string;
|
||||
}
|
||||
|
||||
interface ContainerOutput {
|
||||
status: 'success' | 'error';
|
||||
result: string | null;
|
||||
newSessionId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SessionEntry {
|
||||
sessionId: string;
|
||||
fullPath: string;
|
||||
summary: string;
|
||||
firstPrompt: string;
|
||||
}
|
||||
|
||||
interface SessionsIndex {
|
||||
entries: SessionEntry[];
|
||||
}
|
||||
|
||||
interface SDKUserMessage {
|
||||
type: 'user';
|
||||
message: { role: 'user'; content: string };
|
||||
parent_tool_use_id: null;
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
const IPC_INPUT_DIR = '/workspace/ipc/input';
|
||||
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
|
||||
const IPC_POLL_MS = 500;
|
||||
|
||||
/**
|
||||
* Push-based async iterable for streaming user messages to the SDK.
|
||||
* Keeps the iterable alive until end() is called, preventing isSingleUserTurn.
|
||||
*/
|
||||
class MessageStream {
|
||||
private queue: SDKUserMessage[] = [];
|
||||
private waiting: (() => void) | null = null;
|
||||
private done = false;
|
||||
|
||||
push(text: string): void {
|
||||
this.queue.push({
|
||||
type: 'user',
|
||||
message: { role: 'user', content: text },
|
||||
parent_tool_use_id: null,
|
||||
session_id: '',
|
||||
});
|
||||
this.waiting?.();
|
||||
}
|
||||
|
||||
end(): void {
|
||||
this.done = true;
|
||||
this.waiting?.();
|
||||
}
|
||||
|
||||
async *[Symbol.asyncIterator](): AsyncGenerator<SDKUserMessage> {
|
||||
while (true) {
|
||||
while (this.queue.length > 0) {
|
||||
yield this.queue.shift()!;
|
||||
}
|
||||
if (this.done) return;
|
||||
await new Promise<void>((r) => {
|
||||
this.waiting = r;
|
||||
});
|
||||
this.waiting = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readStdin(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
process.stdin.on('end', () => resolve(data));
|
||||
process.stdin.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
|
||||
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
|
||||
|
||||
function writeOutput(output: ContainerOutput): void {
|
||||
console.log(OUTPUT_START_MARKER);
|
||||
console.log(JSON.stringify(output));
|
||||
console.log(OUTPUT_END_MARKER);
|
||||
}
|
||||
|
||||
function log(message: string): void {
|
||||
console.error(`[agent-runner] ${message}`);
|
||||
}
|
||||
|
||||
function getSessionSummary(
|
||||
sessionId: string,
|
||||
transcriptPath: string,
|
||||
): string | null {
|
||||
const projectDir = path.dirname(transcriptPath);
|
||||
const indexPath = path.join(projectDir, 'sessions-index.json');
|
||||
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
log(`Sessions index not found at ${indexPath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const index: SessionsIndex = JSON.parse(
|
||||
fs.readFileSync(indexPath, 'utf-8'),
|
||||
);
|
||||
const entry = index.entries.find((e) => e.sessionId === sessionId);
|
||||
if (entry?.summary) {
|
||||
return entry.summary;
|
||||
}
|
||||
} catch (err) {
|
||||
log(
|
||||
`Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive the full transcript to conversations/ before compaction.
|
||||
*/
|
||||
function createPreCompactHook(assistantName?: string): HookCallback {
|
||||
return async (input, _toolUseId, _context) => {
|
||||
const preCompact = input as PreCompactHookInput;
|
||||
const transcriptPath = preCompact.transcript_path;
|
||||
const sessionId = preCompact.session_id;
|
||||
|
||||
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
|
||||
log('No transcript found for archiving');
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(transcriptPath, 'utf-8');
|
||||
const messages = parseTranscript(content);
|
||||
|
||||
if (messages.length === 0) {
|
||||
log('No messages to archive');
|
||||
return {};
|
||||
}
|
||||
|
||||
const summary = getSessionSummary(sessionId, transcriptPath);
|
||||
const name = summary ? sanitizeFilename(summary) : generateFallbackName();
|
||||
|
||||
const conversationsDir = '/workspace/group/conversations';
|
||||
fs.mkdirSync(conversationsDir, { recursive: true });
|
||||
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
const filename = `${date}-${name}.md`;
|
||||
const filePath = path.join(conversationsDir, filename);
|
||||
|
||||
const markdown = formatTranscriptMarkdown(
|
||||
messages,
|
||||
summary,
|
||||
assistantName,
|
||||
);
|
||||
fs.writeFileSync(filePath, markdown);
|
||||
|
||||
log(`Archived conversation to ${filePath}`);
|
||||
} catch (err) {
|
||||
log(
|
||||
`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeFilename(summary: string): string {
|
||||
return summary
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 50);
|
||||
}
|
||||
|
||||
function generateFallbackName(): string {
|
||||
const time = new Date();
|
||||
return `conversation-${time.getHours().toString().padStart(2, '0')}${time.getMinutes().toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
interface ParsedMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
function parseTranscript(content: string): ParsedMessage[] {
|
||||
const messages: ParsedMessage[] = [];
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === 'user' && entry.message?.content) {
|
||||
const text =
|
||||
typeof entry.message.content === 'string'
|
||||
? entry.message.content
|
||||
: entry.message.content
|
||||
.map((c: { text?: string }) => c.text || '')
|
||||
.join('');
|
||||
if (text) messages.push({ role: 'user', content: text });
|
||||
} else if (entry.type === 'assistant' && entry.message?.content) {
|
||||
const textParts = entry.message.content
|
||||
.filter((c: { type: string }) => c.type === 'text')
|
||||
.map((c: { text: string }) => c.text);
|
||||
const text = textParts.join('');
|
||||
if (text) messages.push({ role: 'assistant', content: text });
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
function formatTranscriptMarkdown(
|
||||
messages: ParsedMessage[],
|
||||
title?: string | null,
|
||||
assistantName?: string,
|
||||
): string {
|
||||
const now = new Date();
|
||||
const formatDateTime = (d: Date) =>
|
||||
d.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`# ${title || 'Conversation'}`);
|
||||
lines.push('');
|
||||
lines.push(`Archived: ${formatDateTime(now)}`);
|
||||
lines.push('');
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
|
||||
for (const msg of messages) {
|
||||
const sender = msg.role === 'user' ? 'User' : assistantName || 'Assistant';
|
||||
const content =
|
||||
msg.content.length > 2000
|
||||
? msg.content.slice(0, 2000) + '...'
|
||||
: msg.content;
|
||||
lines.push(`**${sender}**: ${content}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for _close sentinel.
|
||||
*/
|
||||
function shouldClose(): boolean {
|
||||
if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) {
|
||||
try {
|
||||
fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain all pending IPC input messages.
|
||||
* Returns messages found, or empty array.
|
||||
*/
|
||||
function drainIpcInput(): string[] {
|
||||
try {
|
||||
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
|
||||
const files = fs
|
||||
.readdirSync(IPC_INPUT_DIR)
|
||||
.filter((f) => f.endsWith('.json'))
|
||||
.sort();
|
||||
|
||||
const messages: string[] = [];
|
||||
for (const file of files) {
|
||||
const filePath = path.join(IPC_INPUT_DIR, file);
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
fs.unlinkSync(filePath);
|
||||
if (data.type === 'message' && data.text) {
|
||||
messages.push(data.text);
|
||||
}
|
||||
} catch (err) {
|
||||
log(
|
||||
`Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
} catch (err) {
|
||||
log(`IPC drain error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a new IPC message or _close sentinel.
|
||||
* Returns the messages as a single string, or null if _close.
|
||||
*/
|
||||
function waitForIpcMessage(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const poll = () => {
|
||||
if (shouldClose()) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const messages = drainIpcInput();
|
||||
if (messages.length > 0) {
|
||||
resolve(messages.join('\n'));
|
||||
return;
|
||||
}
|
||||
setTimeout(poll, IPC_POLL_MS);
|
||||
};
|
||||
poll();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single query and stream results via writeOutput.
|
||||
* Uses MessageStream (AsyncIterable) to keep isSingleUserTurn=false,
|
||||
* allowing agent teams subagents to run to completion.
|
||||
* Also pipes IPC messages into the stream during the query.
|
||||
*/
|
||||
async function runQuery(
|
||||
prompt: string,
|
||||
sessionId: string | undefined,
|
||||
mcpServerPath: string,
|
||||
containerInput: ContainerInput,
|
||||
sdkEnv: Record<string, string | undefined>,
|
||||
resumeAt?: string,
|
||||
): Promise<{
|
||||
newSessionId?: string;
|
||||
lastAssistantUuid?: string;
|
||||
closedDuringQuery: boolean;
|
||||
}> {
|
||||
const stream = new MessageStream();
|
||||
stream.push(prompt);
|
||||
|
||||
// Poll IPC for follow-up messages and _close sentinel during the query
|
||||
let ipcPolling = true;
|
||||
let closedDuringQuery = false;
|
||||
const pollIpcDuringQuery = () => {
|
||||
if (!ipcPolling) return;
|
||||
if (shouldClose()) {
|
||||
log('Close sentinel detected during query, ending stream');
|
||||
closedDuringQuery = true;
|
||||
stream.end();
|
||||
ipcPolling = false;
|
||||
return;
|
||||
}
|
||||
const messages = drainIpcInput();
|
||||
for (const text of messages) {
|
||||
log(`Piping IPC message into active query (${text.length} chars)`);
|
||||
stream.push(text);
|
||||
}
|
||||
setTimeout(pollIpcDuringQuery, IPC_POLL_MS);
|
||||
};
|
||||
setTimeout(pollIpcDuringQuery, IPC_POLL_MS);
|
||||
|
||||
let newSessionId: string | undefined;
|
||||
let lastAssistantUuid: string | undefined;
|
||||
let messageCount = 0;
|
||||
let resultCount = 0;
|
||||
|
||||
// Load global CLAUDE.md as additional system context (shared across all groups)
|
||||
const globalClaudeMdPath = '/workspace/global/CLAUDE.md';
|
||||
let globalClaudeMd: string | undefined;
|
||||
if (!containerInput.isMain && fs.existsSync(globalClaudeMdPath)) {
|
||||
globalClaudeMd = fs.readFileSync(globalClaudeMdPath, 'utf-8');
|
||||
}
|
||||
|
||||
// Discover additional directories mounted at /workspace/extra/*
|
||||
// These are passed to the SDK so their CLAUDE.md files are loaded automatically
|
||||
const extraDirs: string[] = [];
|
||||
const extraBase = '/workspace/extra';
|
||||
if (fs.existsSync(extraBase)) {
|
||||
for (const entry of fs.readdirSync(extraBase)) {
|
||||
const fullPath = path.join(extraBase, entry);
|
||||
if (fs.statSync(fullPath).isDirectory()) {
|
||||
extraDirs.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (extraDirs.length > 0) {
|
||||
log(`Additional directories: ${extraDirs.join(', ')}`);
|
||||
}
|
||||
|
||||
for await (const message of query({
|
||||
prompt: stream,
|
||||
options: {
|
||||
cwd: '/workspace/group',
|
||||
additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined,
|
||||
resume: sessionId,
|
||||
resumeSessionAt: resumeAt,
|
||||
systemPrompt: globalClaudeMd
|
||||
? {
|
||||
type: 'preset' as const,
|
||||
preset: 'claude_code' as const,
|
||||
append: globalClaudeMd,
|
||||
}
|
||||
: undefined,
|
||||
allowedTools: [
|
||||
'Bash',
|
||||
'Read',
|
||||
'Write',
|
||||
'Edit',
|
||||
'Glob',
|
||||
'Grep',
|
||||
'WebSearch',
|
||||
'WebFetch',
|
||||
'Task',
|
||||
'TaskOutput',
|
||||
'TaskStop',
|
||||
'TeamCreate',
|
||||
'TeamDelete',
|
||||
'SendMessage',
|
||||
'TodoWrite',
|
||||
'ToolSearch',
|
||||
'Skill',
|
||||
'NotebookEdit',
|
||||
'mcp__nanoclaw__*',
|
||||
],
|
||||
env: sdkEnv,
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
settingSources: ['project', 'user'],
|
||||
mcpServers: {
|
||||
nanoclaw: {
|
||||
command: 'node',
|
||||
args: [mcpServerPath],
|
||||
env: {
|
||||
NANOCLAW_CHAT_JID: containerInput.chatJid,
|
||||
NANOCLAW_GROUP_FOLDER: containerInput.groupFolder,
|
||||
NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0',
|
||||
},
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
PreCompact: [
|
||||
{ hooks: [createPreCompactHook(containerInput.assistantName)] },
|
||||
],
|
||||
},
|
||||
},
|
||||
})) {
|
||||
messageCount++;
|
||||
const msgType =
|
||||
message.type === 'system'
|
||||
? `system/${(message as { subtype?: string }).subtype}`
|
||||
: message.type;
|
||||
log(`[msg #${messageCount}] type=${msgType}`);
|
||||
|
||||
if (message.type === 'assistant' && 'uuid' in message) {
|
||||
lastAssistantUuid = (message as { uuid: string }).uuid;
|
||||
}
|
||||
|
||||
if (message.type === 'system' && message.subtype === 'init') {
|
||||
newSessionId = message.session_id;
|
||||
log(`Session initialized: ${newSessionId}`);
|
||||
}
|
||||
|
||||
if (
|
||||
message.type === 'system' &&
|
||||
(message as { subtype?: string }).subtype === 'task_notification'
|
||||
) {
|
||||
const tn = message as {
|
||||
task_id: string;
|
||||
status: string;
|
||||
summary: string;
|
||||
};
|
||||
log(
|
||||
`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (message.type === 'result') {
|
||||
resultCount++;
|
||||
const textResult =
|
||||
'result' in message ? (message as { result?: string }).result : null;
|
||||
log(
|
||||
`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`,
|
||||
);
|
||||
writeOutput({
|
||||
status: 'success',
|
||||
result: textResult || null,
|
||||
newSessionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ipcPolling = false;
|
||||
log(
|
||||
`Query done. Messages: ${messageCount}, results: ${resultCount}, lastAssistantUuid: ${lastAssistantUuid || 'none'}, closedDuringQuery: ${closedDuringQuery}`,
|
||||
);
|
||||
return { newSessionId, lastAssistantUuid, closedDuringQuery };
|
||||
}
|
||||
|
||||
interface ScriptResult {
|
||||
wakeAgent: boolean;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
const SCRIPT_TIMEOUT_MS = 30_000;
|
||||
|
||||
async function runScript(script: string): Promise<ScriptResult | null> {
|
||||
const scriptPath = '/tmp/task-script.sh';
|
||||
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
|
||||
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
'bash',
|
||||
[scriptPath],
|
||||
{
|
||||
timeout: SCRIPT_TIMEOUT_MS,
|
||||
maxBuffer: 1024 * 1024,
|
||||
env: process.env,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (stderr) {
|
||||
log(`Script stderr: ${stderr.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
log(`Script error: ${error.message}`);
|
||||
return resolve(null);
|
||||
}
|
||||
|
||||
// Parse last non-empty line of stdout as JSON
|
||||
const lines = stdout.trim().split('\n');
|
||||
const lastLine = lines[lines.length - 1];
|
||||
if (!lastLine) {
|
||||
log('Script produced no output');
|
||||
return resolve(null);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = JSON.parse(lastLine);
|
||||
if (typeof result.wakeAgent !== 'boolean') {
|
||||
log(
|
||||
`Script output missing wakeAgent boolean: ${lastLine.slice(0, 200)}`,
|
||||
);
|
||||
return resolve(null);
|
||||
}
|
||||
resolve(result as ScriptResult);
|
||||
} catch {
|
||||
log(`Script output is not valid JSON: ${lastLine.slice(0, 200)}`);
|
||||
resolve(null);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
let containerInput: ContainerInput;
|
||||
|
||||
try {
|
||||
const stdinData = await readStdin();
|
||||
containerInput = JSON.parse(stdinData);
|
||||
try {
|
||||
fs.unlinkSync('/tmp/input.json');
|
||||
} catch {
|
||||
/* may not exist */
|
||||
}
|
||||
log(`Received input for group: ${containerInput.groupFolder}`);
|
||||
} catch (err) {
|
||||
writeOutput({
|
||||
status: 'error',
|
||||
result: null,
|
||||
error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Credentials are injected by the host's credential proxy via ANTHROPIC_BASE_URL.
|
||||
// No real secrets exist in the container environment.
|
||||
const sdkEnv: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
CLAUDE_CODE_AUTO_COMPACT_WINDOW: '165000',
|
||||
};
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js');
|
||||
|
||||
let sessionId = containerInput.sessionId;
|
||||
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
|
||||
|
||||
// Clean up stale _close sentinel from previous container runs
|
||||
try {
|
||||
fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// Build initial prompt (drain any pending IPC messages too)
|
||||
let prompt = containerInput.prompt;
|
||||
if (containerInput.isScheduledTask) {
|
||||
prompt = `[SCHEDULED TASK - The following message was sent automatically and is not coming directly from the user or group.]\n\n${prompt}`;
|
||||
}
|
||||
const pending = drainIpcInput();
|
||||
if (pending.length > 0) {
|
||||
log(`Draining ${pending.length} pending IPC messages into initial prompt`);
|
||||
prompt += '\n' + pending.join('\n');
|
||||
}
|
||||
|
||||
// Script phase: run script before waking agent
|
||||
if (containerInput.script && containerInput.isScheduledTask) {
|
||||
log('Running task script...');
|
||||
const scriptResult = await runScript(containerInput.script);
|
||||
|
||||
if (!scriptResult || !scriptResult.wakeAgent) {
|
||||
const reason = scriptResult
|
||||
? 'wakeAgent=false'
|
||||
: 'script error/no output';
|
||||
log(`Script decided not to wake agent: ${reason}`);
|
||||
writeOutput({
|
||||
status: 'success',
|
||||
result: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Script says wake agent — enrich prompt with script data
|
||||
log(`Script wakeAgent=true, enriching prompt with data`);
|
||||
prompt = `[SCHEDULED TASK]\n\nScript output:\n${JSON.stringify(scriptResult.data, null, 2)}\n\nInstructions:\n${containerInput.prompt}`;
|
||||
}
|
||||
|
||||
// Query loop: run query → wait for IPC message → run new query → repeat
|
||||
let resumeAt: string | undefined;
|
||||
try {
|
||||
while (true) {
|
||||
log(
|
||||
`Starting query (session: ${sessionId || 'new'}, resumeAt: ${resumeAt || 'latest'})...`,
|
||||
);
|
||||
|
||||
const queryResult = await runQuery(
|
||||
prompt,
|
||||
sessionId,
|
||||
mcpServerPath,
|
||||
containerInput,
|
||||
sdkEnv,
|
||||
resumeAt,
|
||||
);
|
||||
if (queryResult.newSessionId) {
|
||||
sessionId = queryResult.newSessionId;
|
||||
}
|
||||
if (queryResult.lastAssistantUuid) {
|
||||
resumeAt = queryResult.lastAssistantUuid;
|
||||
}
|
||||
|
||||
// If _close was consumed during the query, exit immediately.
|
||||
// Don't emit a session-update marker (it would reset the host's
|
||||
// idle timer and cause a 30-min delay before the next _close).
|
||||
if (queryResult.closedDuringQuery) {
|
||||
log('Close sentinel consumed during query, exiting');
|
||||
break;
|
||||
}
|
||||
|
||||
// Emit session update so host can track it
|
||||
writeOutput({ status: 'success', result: null, newSessionId: sessionId });
|
||||
|
||||
log('Query ended, waiting for next IPC message...');
|
||||
|
||||
// Wait for the next message or _close sentinel
|
||||
const nextMessage = await waitForIpcMessage();
|
||||
if (nextMessage === null) {
|
||||
log('Close sentinel received, exiting');
|
||||
break;
|
||||
}
|
||||
|
||||
log(`Got new message (${nextMessage.length} chars), starting new query`);
|
||||
prompt = nextMessage;
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log(`Agent error: ${errorMessage}`);
|
||||
writeOutput({
|
||||
status: 'error',
|
||||
result: null,
|
||||
newSessionId: sessionId,
|
||||
error: errorMessage,
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,508 +0,0 @@
|
||||
/**
|
||||
* Stdio MCP Server for NanoClaw
|
||||
* Standalone process that agent teams subagents can inherit.
|
||||
* Reads context from environment variables, writes IPC files for the host.
|
||||
*/
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
|
||||
const IPC_DIR = '/workspace/ipc';
|
||||
const MESSAGES_DIR = path.join(IPC_DIR, 'messages');
|
||||
const TASKS_DIR = path.join(IPC_DIR, 'tasks');
|
||||
|
||||
// Context from environment variables (set by the agent runner)
|
||||
const chatJid = process.env.NANOCLAW_CHAT_JID!;
|
||||
const groupFolder = process.env.NANOCLAW_GROUP_FOLDER!;
|
||||
const isMain = process.env.NANOCLAW_IS_MAIN === '1';
|
||||
|
||||
function writeIpcFile(dir: string, data: object): string {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`;
|
||||
const filepath = path.join(dir, filename);
|
||||
|
||||
// Atomic write: temp file then rename
|
||||
const tempPath = `${filepath}.tmp`;
|
||||
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2));
|
||||
fs.renameSync(tempPath, filepath);
|
||||
|
||||
return filename;
|
||||
}
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'nanoclaw',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
server.tool(
|
||||
'send_message',
|
||||
"Send a message to the user or group immediately while you're still running. Use this for progress updates or to send multiple messages. You can call this multiple times.",
|
||||
{
|
||||
text: z.string().describe('The message text to send'),
|
||||
sender: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Your role/identity name (e.g. "Researcher"). When set, messages appear from a dedicated bot in Telegram.',
|
||||
),
|
||||
},
|
||||
async (args) => {
|
||||
const data: Record<string, string | undefined> = {
|
||||
type: 'message',
|
||||
chatJid,
|
||||
text: args.text,
|
||||
sender: args.sender || undefined,
|
||||
groupFolder,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
writeIpcFile(MESSAGES_DIR, data);
|
||||
|
||||
return { content: [{ type: 'text' as const, text: 'Message sent.' }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'schedule_task',
|
||||
`Schedule a recurring or one-time task. The task will run as a full agent with access to all tools. Returns the task ID for future reference. To modify an existing task, use update_task instead.
|
||||
|
||||
CONTEXT MODE - Choose based on task type:
|
||||
\u2022 "group": Task runs in the group's conversation context, with access to chat history. Use for tasks that need context about ongoing discussions, user preferences, or recent interactions.
|
||||
\u2022 "isolated": Task runs in a fresh session with no conversation history. Use for independent tasks that don't need prior context. When using isolated mode, include all necessary context in the prompt itself.
|
||||
|
||||
If unsure which mode to use, you can ask the user. Examples:
|
||||
- "Remind me about our discussion" \u2192 group (needs conversation context)
|
||||
- "Check the weather every morning" \u2192 isolated (self-contained task)
|
||||
- "Follow up on my request" \u2192 group (needs to know what was requested)
|
||||
- "Generate a daily report" \u2192 isolated (just needs instructions in prompt)
|
||||
|
||||
MESSAGING BEHAVIOR - The task agent's output is sent to the user or group. It can also use send_message for immediate delivery, or wrap output in <internal> tags to suppress it. Include guidance in the prompt about whether the agent should:
|
||||
\u2022 Always send a message (e.g., reminders, daily briefings)
|
||||
\u2022 Only send a message when there's something to report (e.g., "notify me if...")
|
||||
\u2022 Never send a message (background maintenance tasks)
|
||||
|
||||
SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
|
||||
\u2022 cron: Standard cron expression (e.g., "*/5 * * * *" for every 5 minutes, "0 9 * * *" for daily at 9am LOCAL time)
|
||||
\u2022 interval: Milliseconds between runs (e.g., "300000" for 5 minutes, "3600000" for 1 hour)
|
||||
\u2022 once: Local time WITHOUT "Z" suffix (e.g., "2026-02-01T15:30:00"). Do NOT use UTC/Z suffix.`,
|
||||
{
|
||||
prompt: z
|
||||
.string()
|
||||
.describe(
|
||||
'What the agent should do when the task runs. For isolated mode, include all necessary context here.',
|
||||
),
|
||||
schedule_type: z
|
||||
.enum(['cron', 'interval', 'once'])
|
||||
.describe(
|
||||
'cron=recurring at specific times, interval=recurring every N ms, once=run once at specific time',
|
||||
),
|
||||
schedule_value: z
|
||||
.string()
|
||||
.describe(
|
||||
'cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)',
|
||||
),
|
||||
context_mode: z
|
||||
.enum(['group', 'isolated'])
|
||||
.default('group')
|
||||
.describe(
|
||||
'group=runs with chat history and memory, isolated=fresh session (include context in prompt)',
|
||||
),
|
||||
target_group_jid: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'(Main group only) JID of the group to schedule the task for. Defaults to the current group.',
|
||||
),
|
||||
script: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional bash script to run before waking the agent. Script must output JSON on the last line of stdout: { "wakeAgent": boolean, "data"?: any }. If wakeAgent is false, the agent is not called. Test your script with bash -c "..." before scheduling.',
|
||||
),
|
||||
},
|
||||
async (args) => {
|
||||
// Validate schedule_value before writing IPC
|
||||
if (args.schedule_type === 'cron') {
|
||||
try {
|
||||
CronExpressionParser.parse(args.schedule_value);
|
||||
} catch {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Invalid cron: "${args.schedule_value}". Use format like "0 9 * * *" (daily 9am) or "*/5 * * * *" (every 5 min).`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
} else if (args.schedule_type === 'interval') {
|
||||
const ms = parseInt(args.schedule_value, 10);
|
||||
if (isNaN(ms) || ms <= 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Invalid interval: "${args.schedule_value}". Must be positive milliseconds (e.g., "300000" for 5 min).`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
} else if (args.schedule_type === 'once') {
|
||||
if (
|
||||
/[Zz]$/.test(args.schedule_value) ||
|
||||
/[+-]\d{2}:\d{2}$/.test(args.schedule_value)
|
||||
) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Timestamp must be local time without timezone suffix. Got "${args.schedule_value}" — use format like "2026-02-01T15:30:00".`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const date = new Date(args.schedule_value);
|
||||
if (isNaN(date.getTime())) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Invalid timestamp: "${args.schedule_value}". Use local time format like "2026-02-01T15:30:00".`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Non-main groups can only schedule for themselves
|
||||
const targetJid =
|
||||
isMain && args.target_group_jid ? args.target_group_jid : chatJid;
|
||||
|
||||
const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const data = {
|
||||
type: 'schedule_task',
|
||||
taskId,
|
||||
prompt: args.prompt,
|
||||
script: args.script || undefined,
|
||||
schedule_type: args.schedule_type,
|
||||
schedule_value: args.schedule_value,
|
||||
context_mode: args.context_mode || 'group',
|
||||
targetJid,
|
||||
createdBy: groupFolder,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
writeIpcFile(TASKS_DIR, data);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Task ${taskId} scheduled: ${args.schedule_type} - ${args.schedule_value}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'list_tasks',
|
||||
"List all scheduled tasks. From main: shows all tasks. From other groups: shows only that group's tasks.",
|
||||
{},
|
||||
async () => {
|
||||
const tasksFile = path.join(IPC_DIR, 'current_tasks.json');
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(tasksFile)) {
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text' as const, text: 'No scheduled tasks found.' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const allTasks = JSON.parse(fs.readFileSync(tasksFile, 'utf-8'));
|
||||
|
||||
const tasks = isMain
|
||||
? allTasks
|
||||
: allTasks.filter(
|
||||
(t: { groupFolder: string }) => t.groupFolder === groupFolder,
|
||||
);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text' as const, text: 'No scheduled tasks found.' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const formatted = tasks
|
||||
.map(
|
||||
(t: {
|
||||
id: string;
|
||||
prompt: string;
|
||||
schedule_type: string;
|
||||
schedule_value: string;
|
||||
status: string;
|
||||
next_run: string;
|
||||
}) =>
|
||||
`- [${t.id}] ${t.prompt.slice(0, 50)}... (${t.schedule_type}: ${t.schedule_value}) - ${t.status}, next: ${t.next_run || 'N/A'}`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text' as const, text: `Scheduled tasks:\n${formatted}` },
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Error reading tasks: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'pause_task',
|
||||
'Pause a scheduled task. It will not run until resumed.',
|
||||
{ task_id: z.string().describe('The task ID to pause') },
|
||||
async (args) => {
|
||||
const data = {
|
||||
type: 'pause_task',
|
||||
taskId: args.task_id,
|
||||
groupFolder,
|
||||
isMain,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
writeIpcFile(TASKS_DIR, data);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Task ${args.task_id} pause requested.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'resume_task',
|
||||
'Resume a paused task.',
|
||||
{ task_id: z.string().describe('The task ID to resume') },
|
||||
async (args) => {
|
||||
const data = {
|
||||
type: 'resume_task',
|
||||
taskId: args.task_id,
|
||||
groupFolder,
|
||||
isMain,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
writeIpcFile(TASKS_DIR, data);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Task ${args.task_id} resume requested.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'cancel_task',
|
||||
'Cancel and delete a scheduled task.',
|
||||
{ task_id: z.string().describe('The task ID to cancel') },
|
||||
async (args) => {
|
||||
const data = {
|
||||
type: 'cancel_task',
|
||||
taskId: args.task_id,
|
||||
groupFolder,
|
||||
isMain,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
writeIpcFile(TASKS_DIR, data);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Task ${args.task_id} cancellation requested.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'update_task',
|
||||
'Update an existing scheduled task. Only provided fields are changed; omitted fields stay the same.',
|
||||
{
|
||||
task_id: z.string().describe('The task ID to update'),
|
||||
prompt: z.string().optional().describe('New prompt for the task'),
|
||||
schedule_type: z
|
||||
.enum(['cron', 'interval', 'once'])
|
||||
.optional()
|
||||
.describe('New schedule type'),
|
||||
schedule_value: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('New schedule value (see schedule_task for format)'),
|
||||
script: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'New script for the task. Set to empty string to remove the script.',
|
||||
),
|
||||
},
|
||||
async (args) => {
|
||||
// Validate schedule_value if provided
|
||||
if (
|
||||
args.schedule_type === 'cron' ||
|
||||
(!args.schedule_type && args.schedule_value)
|
||||
) {
|
||||
if (args.schedule_value) {
|
||||
try {
|
||||
CronExpressionParser.parse(args.schedule_value);
|
||||
} catch {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Invalid cron: "${args.schedule_value}".`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (args.schedule_type === 'interval' && args.schedule_value) {
|
||||
const ms = parseInt(args.schedule_value, 10);
|
||||
if (isNaN(ms) || ms <= 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Invalid interval: "${args.schedule_value}".`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const data: Record<string, string | undefined> = {
|
||||
type: 'update_task',
|
||||
taskId: args.task_id,
|
||||
groupFolder,
|
||||
isMain: String(isMain),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
if (args.prompt !== undefined) data.prompt = args.prompt;
|
||||
if (args.script !== undefined) data.script = args.script;
|
||||
if (args.schedule_type !== undefined)
|
||||
data.schedule_type = args.schedule_type;
|
||||
if (args.schedule_value !== undefined)
|
||||
data.schedule_value = args.schedule_value;
|
||||
|
||||
writeIpcFile(TASKS_DIR, data);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Task ${args.task_id} update requested.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'register_group',
|
||||
`Register a new chat/group so the agent can respond to messages there. Main group only.
|
||||
|
||||
Use available_groups.json to find the JID for a group. The folder name must be channel-prefixed: "{channel}_{group-name}" (e.g., "whatsapp_family-chat", "telegram_dev-team", "discord_general"). Use lowercase with hyphens for the group name part.`,
|
||||
{
|
||||
jid: z
|
||||
.string()
|
||||
.describe(
|
||||
'The chat JID (e.g., "120363336345536173@g.us", "tg:-1001234567890", "dc:1234567890123456")',
|
||||
),
|
||||
name: z.string().describe('Display name for the group'),
|
||||
folder: z
|
||||
.string()
|
||||
.describe(
|
||||
'Channel-prefixed folder name (e.g., "whatsapp_family-chat", "telegram_dev-team")',
|
||||
),
|
||||
trigger: z.string().describe('Trigger word (e.g., "@Andy")'),
|
||||
requiresTrigger: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'Whether messages must start with the trigger word. Default: false (respond to all messages). Set to true for busy groups with many participants where you only want the agent to respond when explicitly mentioned.',
|
||||
),
|
||||
},
|
||||
async (args) => {
|
||||
if (!isMain) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: 'Only the main group can register new groups.',
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const data = {
|
||||
type: 'register_group',
|
||||
jid: args.jid,
|
||||
name: args.name,
|
||||
folder: args.folder,
|
||||
trigger: args.trigger,
|
||||
requiresTrigger: args.requiresTrigger ?? false,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
writeIpcFile(TASKS_DIR, data);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Group "${args.name}" registered. It will start receiving messages immediately.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Start the stdio transport
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
@@ -1,81 +0,0 @@
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
import { writeMessageOut } from './db/messages-out.js';
|
||||
|
||||
function log(msg: string): void {
|
||||
console.error(`[mcp-tools] ${msg}`);
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the MCP server with NanoClaw tools.
|
||||
* Reads the session DB path from SESSION_DB_PATH env var.
|
||||
* Routing context is passed via env vars from the poll loop.
|
||||
*/
|
||||
export async function startMcpServer(): Promise<void> {
|
||||
const server = new Server({ name: 'nanoclaw', version: '2.0.0' }, { capabilities: { tools: {} } });
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [
|
||||
{
|
||||
name: 'send_message',
|
||||
description: 'Send a chat message to the current conversation or a specified destination.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
text: { type: 'string', description: 'Message content' },
|
||||
channel: { type: 'string', description: 'Target channel type (default: reply to origin)' },
|
||||
platformId: { type: 'string', description: 'Target platform ID' },
|
||||
threadId: { type: 'string', description: 'Target thread ID' },
|
||||
},
|
||||
required: ['text'],
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
|
||||
if (name === 'send_message') {
|
||||
const text = args?.text as string;
|
||||
if (!text) {
|
||||
return { content: [{ type: 'text', text: 'Error: text is required' }] };
|
||||
}
|
||||
|
||||
const id = generateId();
|
||||
const platformId = (args?.platformId as string) || process.env.NANOCLAW_PLATFORM_ID || null;
|
||||
const channelType = (args?.channel as string) || process.env.NANOCLAW_CHANNEL_TYPE || null;
|
||||
const threadId = (args?.threadId as string) || process.env.NANOCLAW_THREAD_ID || null;
|
||||
|
||||
writeMessageOut({
|
||||
id,
|
||||
kind: 'chat',
|
||||
platform_id: platformId,
|
||||
channel_type: channelType,
|
||||
thread_id: threadId,
|
||||
content: JSON.stringify({ text }),
|
||||
});
|
||||
|
||||
log(`send_message: ${id} → ${channelType || 'default'}/${platformId || 'default'}`);
|
||||
return { content: [{ type: 'text', text: `Message sent (id: ${id})` }] };
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
|
||||
});
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
log('MCP server started');
|
||||
}
|
||||
|
||||
// Run as standalone process
|
||||
startMcpServer().catch((err) => {
|
||||
log(`MCP server error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -10,5 +10,5 @@
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/v1/**/*"]
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
186
docs/module-contract.md
Normal file
186
docs/module-contract.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# Module Contract
|
||||
|
||||
This doc is the authoritative reference for how core and modules connect. Everything downstream — extraction PRs, install skills, module authors — keys off these signatures and defaults. See [REFACTOR_PLAN.md](../REFACTOR_PLAN.md) for the broader plan; this doc is the narrow interface spec.
|
||||
|
||||
## Principles
|
||||
|
||||
- Core runs standalone. The `src/modules/index.ts` barrel can be empty and NanoClaw still routes messages in and delivers responses out.
|
||||
- Modules are independent. No module imports from another module. Cross-module coordination goes through a core dispatcher.
|
||||
- Registries exist only when multiple modules plug into the same decision point. Single-consumer integrations use skill edits (`MODULE-HOOK` markers) or stay inline with `sqlite_master` guards.
|
||||
- Removing a module = delete files + remove barrel imports + revert any `MODULE-HOOK` content. Migration files stay (data is preserved).
|
||||
|
||||
## Module taxonomy
|
||||
|
||||
Three categories:
|
||||
|
||||
1. **Default modules** — ship on `main`, live in `src/modules/` for signaling, core imports them directly. No hook, no registry. Removing requires editing core imports (deliberately less frictionless than registry modules — the friction signals "not really core, but you probably want it").
|
||||
2. **Registry-based modules** — live on the `modules` branch, installed via `/add-<name>` skills. Plug into core through one of the four registries below.
|
||||
3. **Channel adapters** — live on the `channels` branch, installed via `/add-<channel>` skills. Not covered by this contract; they use the pre-existing `ChannelAdapter` interface and `registerChannelAdapter()`.
|
||||
|
||||
Current default modules:
|
||||
|
||||
- `src/modules/typing/` — typing indicator refresh
|
||||
- `src/modules/mount-security/` — container mount allowlist validation
|
||||
|
||||
## The four registries
|
||||
|
||||
Each registry has an explicit default for when no module registers. Core must run when all four are empty.
|
||||
|
||||
### 1. Delivery action handlers
|
||||
|
||||
```typescript
|
||||
// src/delivery.ts
|
||||
type ActionHandler = (
|
||||
content: Record<string, unknown>,
|
||||
session: Session,
|
||||
inDb: Database.Database,
|
||||
) => Promise<void>;
|
||||
|
||||
export function registerDeliveryAction(action: string, handler: ActionHandler): void;
|
||||
```
|
||||
|
||||
**Purpose:** system-kind outbound messages (`msg.kind === 'system'`) carry an `action` string. Core dispatches to the registered handler.
|
||||
|
||||
**Default when action is unknown:** log `"Unknown system action"` at `warn` and return. Message is still marked delivered (it was consumed by the host, not sent to a channel).
|
||||
|
||||
**Current consumers:** scheduling (5 actions — `schedule_task`, `cancel_task`, `pause_task`, `resume_task`, `update_task`), approvals (3 actions — `install_packages`, `request_rebuild`, `add_mcp_server`), agent-to-agent (`create_agent`, and the agent-routing branch keyed as a pseudo-action `agent_route`).
|
||||
|
||||
### 2. Router inbound gate
|
||||
|
||||
```typescript
|
||||
// src/router.ts
|
||||
type InboundGateResult =
|
||||
| { allowed: true; userId: string | null }
|
||||
| { allowed: false; userId: string | null; reason: string };
|
||||
|
||||
type InboundGateFn = (
|
||||
event: InboundEvent,
|
||||
mg: MessagingGroup,
|
||||
agentGroupId: string,
|
||||
) => InboundGateResult;
|
||||
|
||||
export function setInboundGate(fn: InboundGateFn): void;
|
||||
```
|
||||
|
||||
**Purpose:** single-setter gate that owns both sender resolution (user upsert) and access decision. Takes the raw event because the permissions module needs the sender fields inside `event.message.content`.
|
||||
|
||||
**Default when unset:** `{ allowed: true, userId: null }`. Every message routes through, no users table is needed, downstream must tolerate `userId=null`.
|
||||
|
||||
**Current consumer:** permissions module.
|
||||
|
||||
**Not a registry, a setter.** There is one decision per inbound message and one module that owns it. Calling `setInboundGate` twice overwrites; core does not iterate.
|
||||
|
||||
### 3. Response dispatcher
|
||||
|
||||
```typescript
|
||||
// src/index.ts (or src/response-dispatch.ts if it grows)
|
||||
interface ResponsePayload {
|
||||
questionId: string;
|
||||
value: string;
|
||||
userId: string | null;
|
||||
channelType: string;
|
||||
platformId: string;
|
||||
threadId: string | null;
|
||||
}
|
||||
|
||||
type ResponseHandler = (payload: ResponsePayload) => Promise<boolean>;
|
||||
|
||||
export function registerResponseHandler(handler: ResponseHandler): void;
|
||||
```
|
||||
|
||||
**Purpose:** button-click / question responses arrive via the channel adapter's `onAction` callback. Core iterates registered handlers in registration order. The first one that returns `true` claims the response.
|
||||
|
||||
**Default when empty:** log `"Unclaimed response"` at `warn` and drop.
|
||||
|
||||
**Current consumers:** interactive (matches `pending_questions`), approvals (matches `pending_approvals`). The two tables have disjoint `question_id` / `approval_id` namespaces in practice (`q-*` vs `appr-*`), so first-match-wins is safe.
|
||||
|
||||
### 4. Container MCP tool self-registration
|
||||
|
||||
```typescript
|
||||
// container/agent-runner/src/mcp-tools/server.ts
|
||||
export function registerTools(tools: McpToolDefinition[]): void;
|
||||
```
|
||||
|
||||
**Purpose:** each tool module calls `registerTools([...])` at import time. The MCP server uses whatever was registered.
|
||||
|
||||
**Default:** only `mcp-tools/core.ts` (`send_message`) registered.
|
||||
|
||||
**Current consumers:** all container-side modules (scheduling, interactive, agents, self-mod).
|
||||
|
||||
## Skill edits to core
|
||||
|
||||
For one-off integrations with a single consumer, install skills edit core directly between `MODULE-HOOK` markers. No registry.
|
||||
|
||||
Marker format:
|
||||
|
||||
```typescript
|
||||
// MODULE-HOOK:<module>-<site>:start
|
||||
// MODULE-HOOK:<module>-<site>:end
|
||||
```
|
||||
|
||||
The skill inserts between markers on install and clears between them on uninstall. Markers live in core from day one (empty until a skill fills them).
|
||||
|
||||
**Current uses:**
|
||||
|
||||
- `src/host-sweep.ts` → `MODULE-HOOK:scheduling-recurrence` — call to scheduling module's `handleRecurrence`.
|
||||
- `container/agent-runner/src/poll-loop.ts` → `MODULE-HOOK:scheduling-pre-task` — call to scheduling module's `applyPreTaskScripts`.
|
||||
|
||||
**Promotion rule:** if a third consumer appears for any marker, promote to a registry.
|
||||
|
||||
## Guarded inline (core)
|
||||
|
||||
Some code stays in core but references module-owned tables. These use `sqlite_master` checks to degrade cleanly when the owning module isn't installed.
|
||||
|
||||
| Site | Owning module | Fallback |
|
||||
|------|---------------|----------|
|
||||
| `container-runner.ts` admin-ID query (`user_roles`, `agent_group_members`) | permissions | returns `[]` |
|
||||
| `container-runner.ts` `writeDestinations` (`agent_destinations`) | agent-to-agent | no-op |
|
||||
| `delivery.ts` channel-permission check (`agent_destinations`) | agent-to-agent | permit (origin-chat always OK) |
|
||||
| `delivery.ts` `createPendingQuestion` (`pending_questions`) | interactive | no-op (log warning) |
|
||||
|
||||
`container/agent-runner/src/formatter.ts` has a related non-DB fallback: when `NANOCLAW_ADMIN_USER_IDS` is empty, every sender is treated as admin (permissionless mode). This is the one-line change from the current deny-all behavior.
|
||||
|
||||
## Migrations
|
||||
|
||||
All migrations live in `src/db/migrations/` as TypeScript files exporting a `Migration` object:
|
||||
|
||||
```typescript
|
||||
export interface Migration {
|
||||
version: number;
|
||||
name: string;
|
||||
up: (db: Database.Database) => void;
|
||||
}
|
||||
```
|
||||
|
||||
The barrel `src/db/migrations/index.ts` imports each and lists them in an ordered array.
|
||||
|
||||
**Uniqueness key is `name`, not `version`.** The migrator applies any migration whose `name` isn't in `schema_version`. Version stays as an ordering hint; integer collisions across modules are allowed.
|
||||
|
||||
**Module migration naming:**
|
||||
|
||||
- File: `src/db/migrations/module-<module>-<short>.ts`
|
||||
- `Migration.name`: `'<module>-<short>'` (e.g. `'approvals-pending-approvals'`)
|
||||
|
||||
**Uninstall behavior:** migration files and barrel entries stay. Tables persist across reinstalls. No down migrations.
|
||||
|
||||
## What a registry-based module provides
|
||||
|
||||
Each `src/modules/<name>/` module must supply:
|
||||
|
||||
- `index.ts` — imported by `src/modules/index.ts` for side-effect registration (calls `registerDeliveryAction` / `setInboundGate` / `registerResponseHandler` at module load time).
|
||||
- `project.md` — appended to project `CLAUDE.md` by the install skill. Describes module architecture for anyone reading the codebase.
|
||||
- `agent.md` — appended to `groups/global/CLAUDE.md` by the install skill. Describes the module's tools for the agent.
|
||||
- Migration file in `src/db/migrations/` if the module owns any tables.
|
||||
- Barrel entry in `src/db/migrations/index.ts` for that migration.
|
||||
|
||||
Optionally:
|
||||
|
||||
- Container-side additions to `container/agent-runner/src/mcp-tools/<name>.ts` that call `registerTools([...])`, with a barrel entry in `container/agent-runner/src/mcp-tools/index.ts`.
|
||||
- `MODULE-HOOK` edits to specific core files, applied by the install skill.
|
||||
|
||||
## What a module must not do
|
||||
|
||||
- Import from another module.
|
||||
- Write to core-owned tables (`sessions`, `agent_groups`, `messaging_groups`, `schema_version`, etc.) outside of migrations.
|
||||
- Depend on a specific channel adapter being installed.
|
||||
- Break core behavior when unloaded. If a module's absence leaves a core feature non-functional, that feature belongs in core, not the module.
|
||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -282,8 +282,8 @@ packages:
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.3':
|
||||
resolution: {integrity: sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==}
|
||||
'@napi-rs/wasm-runtime@1.1.4':
|
||||
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
|
||||
peerDependencies:
|
||||
'@emnapi/core': ^1.7.1
|
||||
'@emnapi/runtime': ^1.7.1
|
||||
@@ -1156,8 +1156,8 @@ packages:
|
||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
postcss@8.5.9:
|
||||
resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==}
|
||||
postcss@8.5.10:
|
||||
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
prebuild-install@7.1.3:
|
||||
@@ -1615,7 +1615,7 @@ snapshots:
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)':
|
||||
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.9.2
|
||||
'@emnapi/runtime': 1.9.2
|
||||
@@ -1666,7 +1666,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@emnapi/core': 1.9.2
|
||||
'@emnapi/runtime': 1.9.2
|
||||
'@napi-rs/wasm-runtime': 1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
|
||||
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15':
|
||||
@@ -2617,7 +2617,7 @@ snapshots:
|
||||
|
||||
picomatch@4.0.4: {}
|
||||
|
||||
postcss@8.5.9:
|
||||
postcss@8.5.10:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
@@ -2867,7 +2867,7 @@ snapshots:
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.9
|
||||
postcss: 8.5.10
|
||||
rolldown: 1.0.0-rc.15
|
||||
tinyglobby: 0.2.16
|
||||
optionalDependencies:
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
// v1 channel barrel — no-op (channels registered via separate skill branches)
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
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.
|
||||
// However, since vitest runs each file in its own context and we control
|
||||
// registration order, we can test the public API directly.
|
||||
|
||||
describe('channel registry', () => {
|
||||
// Note: registry is shared module state across tests in this file.
|
||||
// Tests are ordered to account for cumulative registrations.
|
||||
|
||||
it('getChannelFactory returns undefined for unknown channel', () => {
|
||||
expect(getChannelFactory('nonexistent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('registerChannel and getChannelFactory round-trip', () => {
|
||||
const factory = () => null;
|
||||
registerChannel('test-channel', factory);
|
||||
expect(getChannelFactory('test-channel')).toBe(factory);
|
||||
});
|
||||
|
||||
it('getRegisteredChannelNames includes registered channels', () => {
|
||||
registerChannel('another-channel', () => null);
|
||||
const names = getRegisteredChannelNames();
|
||||
expect(names).toContain('test-channel');
|
||||
expect(names).toContain('another-channel');
|
||||
});
|
||||
|
||||
it('later registration overwrites earlier one', () => {
|
||||
const factory1 = () => null;
|
||||
const factory2 = () => null;
|
||||
registerChannel('overwrite-test', factory1);
|
||||
registerChannel('overwrite-test', factory2);
|
||||
expect(getChannelFactory('overwrite-test')).toBe(factory2);
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Channel, OnInboundMessage, OnChatMetadata, RegisteredGroup } from '../types.js';
|
||||
|
||||
export interface ChannelOpts {
|
||||
onMessage: OnInboundMessage;
|
||||
onChatMetadata: OnChatMetadata;
|
||||
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||
}
|
||||
|
||||
export type ChannelFactory = (opts: ChannelOpts) => Channel | null;
|
||||
|
||||
const registry = new Map<string, ChannelFactory>();
|
||||
|
||||
export function registerChannel(name: string, factory: ChannelFactory): void {
|
||||
registry.set(name, factory);
|
||||
}
|
||||
|
||||
export function getChannelFactory(name: string): ChannelFactory | undefined {
|
||||
return registry.get(name);
|
||||
}
|
||||
|
||||
export function getRegisteredChannelNames(): string[] {
|
||||
return [...registry.keys()];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
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']);
|
||||
|
||||
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';
|
||||
export const POLL_INTERVAL = 2000;
|
||||
export const SCHEDULER_POLL_INTERVAL = 60000;
|
||||
|
||||
// Absolute paths needed for container mounts
|
||||
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 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 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 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);
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function buildTriggerPattern(trigger: string): RegExp {
|
||||
return new RegExp(`^${escapeRegex(trigger.trim())}\\b`, 'i');
|
||||
}
|
||||
|
||||
export const DEFAULT_TRIGGER = `@${ASSISTANT_NAME}`;
|
||||
|
||||
export function getTriggerPattern(trigger?: string): RegExp {
|
||||
const normalizedTrigger = trigger?.trim();
|
||||
return buildTriggerPattern(normalizedTrigger || DEFAULT_TRIGGER);
|
||||
}
|
||||
|
||||
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];
|
||||
for (const tz of candidates) {
|
||||
if (tz && isValidTimezone(tz)) return tz;
|
||||
}
|
||||
return 'UTC';
|
||||
}
|
||||
export const TIMEZONE = resolveConfigTimezone();
|
||||
@@ -1,204 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { EventEmitter } from 'events';
|
||||
import { PassThrough } from 'stream';
|
||||
|
||||
// Sentinel markers must match container-runner.ts
|
||||
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
|
||||
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
|
||||
|
||||
// Mock config
|
||||
vi.mock('./config.js', () => ({
|
||||
CONTAINER_IMAGE: 'nanoclaw-agent:latest',
|
||||
CONTAINER_MAX_OUTPUT_SIZE: 10485760,
|
||||
CONTAINER_TIMEOUT: 1800000, // 30min
|
||||
DATA_DIR: '/tmp/nanoclaw-test-data',
|
||||
GROUPS_DIR: '/tmp/nanoclaw-test-groups',
|
||||
IDLE_TIMEOUT: 1800000, // 30min
|
||||
ONECLI_URL: 'http://localhost:10254',
|
||||
TIMEZONE: 'America/Los_Angeles',
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('./logger.js', () => ({
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock fs
|
||||
vi.mock('fs', async () => {
|
||||
const actual = await vi.importActual<typeof import('fs')>('fs');
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual,
|
||||
existsSync: vi.fn(() => false),
|
||||
mkdirSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
readFileSync: vi.fn(() => ''),
|
||||
readdirSync: vi.fn(() => []),
|
||||
statSync: vi.fn(() => ({ isDirectory: () => false })),
|
||||
copyFileSync: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock mount-security
|
||||
vi.mock('./mount-security.js', () => ({
|
||||
validateAdditionalMounts: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
// Mock container-runtime
|
||||
vi.mock('./container-runtime.js', () => ({
|
||||
CONTAINER_RUNTIME_BIN: 'docker',
|
||||
hostGatewayArgs: () => [],
|
||||
readonlyMountArgs: (h: string, c: string) => ['-v', `${h}:${c}:ro`],
|
||||
stopContainer: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock OneCLI SDK
|
||||
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 });
|
||||
},
|
||||
}));
|
||||
|
||||
// Create a controllable fake ChildProcess
|
||||
function createFakeProcess() {
|
||||
const proc = new EventEmitter() as EventEmitter & {
|
||||
stdin: PassThrough;
|
||||
stdout: PassThrough;
|
||||
stderr: PassThrough;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
pid: number;
|
||||
};
|
||||
proc.stdin = new PassThrough();
|
||||
proc.stdout = new PassThrough();
|
||||
proc.stderr = new PassThrough();
|
||||
proc.kill = vi.fn();
|
||||
proc.pid = 12345;
|
||||
return proc;
|
||||
}
|
||||
|
||||
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');
|
||||
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();
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { runContainerAgent, ContainerOutput } from './container-runner.js';
|
||||
import type { RegisteredGroup } from './types.js';
|
||||
|
||||
const testGroup: RegisteredGroup = {
|
||||
name: 'Test Group',
|
||||
folder: 'test-group',
|
||||
trigger: '@Andy',
|
||||
added_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const testInput = {
|
||||
prompt: 'Hello',
|
||||
groupFolder: 'test-group',
|
||||
chatJid: 'test@g.us',
|
||||
isMain: false,
|
||||
};
|
||||
|
||||
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`);
|
||||
}
|
||||
|
||||
describe('container-runner timeout behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
fakeProc = createFakeProcess();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('timeout after output resolves as success', async () => {
|
||||
const onOutput = vi.fn(async () => {});
|
||||
const resultPromise = runContainerAgent(testGroup, testInput, () => {}, onOutput);
|
||||
|
||||
// Emit output with a result
|
||||
emitOutputMarker(fakeProc, {
|
||||
status: 'success',
|
||||
result: 'Here is my response',
|
||||
newSessionId: 'session-123',
|
||||
});
|
||||
|
||||
// Let output processing settle
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Fire the hard timeout (IDLE_TIMEOUT + 30s = 1830000ms)
|
||||
await vi.advanceTimersByTimeAsync(1830000);
|
||||
|
||||
// Emit close event (as if container was stopped by the timeout)
|
||||
fakeProc.emit('close', 137);
|
||||
|
||||
// Let the promise resolve
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
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' }));
|
||||
});
|
||||
|
||||
it('timeout with no output resolves as error', async () => {
|
||||
const onOutput = vi.fn(async () => {});
|
||||
const resultPromise = runContainerAgent(testGroup, testInput, () => {}, onOutput);
|
||||
|
||||
// No output emitted — fire the hard timeout
|
||||
await vi.advanceTimersByTimeAsync(1830000);
|
||||
|
||||
// Emit close event
|
||||
fakeProc.emit('close', 137);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.error).toContain('timed out');
|
||||
expect(onOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('normal exit after output resolves as success', async () => {
|
||||
const onOutput = vi.fn(async () => {});
|
||||
const resultPromise = runContainerAgent(testGroup, testInput, () => {}, onOutput);
|
||||
|
||||
// Emit output
|
||||
emitOutputMarker(fakeProc, {
|
||||
status: 'success',
|
||||
result: 'Done',
|
||||
newSessionId: 'session-456',
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Normal exit (no timeout)
|
||||
fakeProc.emit('close', 0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.newSessionId).toBe('session-456');
|
||||
});
|
||||
});
|
||||
@@ -1,677 +0,0 @@
|
||||
/**
|
||||
* Container Runner for NanoClaw
|
||||
* Spawns agent execution in containers and handles IPC
|
||||
*/
|
||||
import { ChildProcess, spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
CONTAINER_IMAGE,
|
||||
CONTAINER_MAX_OUTPUT_SIZE,
|
||||
CONTAINER_TIMEOUT,
|
||||
DATA_DIR,
|
||||
GROUPS_DIR,
|
||||
IDLE_TIMEOUT,
|
||||
ONECLI_URL,
|
||||
TIMEZONE,
|
||||
} 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 { OneCLI } from '@onecli-sh/sdk';
|
||||
import { validateAdditionalMounts } from './mount-security.js';
|
||||
import { RegisteredGroup } from './types.js';
|
||||
|
||||
const onecli = new OneCLI({ url: ONECLI_URL });
|
||||
|
||||
// Sentinel markers for robust output parsing (must match agent-runner)
|
||||
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
|
||||
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
|
||||
|
||||
export interface ContainerInput {
|
||||
prompt: string;
|
||||
sessionId?: string;
|
||||
groupFolder: string;
|
||||
chatJid: string;
|
||||
isMain: boolean;
|
||||
isScheduledTask?: boolean;
|
||||
assistantName?: string;
|
||||
script?: string;
|
||||
}
|
||||
|
||||
export interface ContainerOutput {
|
||||
status: 'success' | 'error';
|
||||
result: string | null;
|
||||
newSessionId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface VolumeMount {
|
||||
hostPath: string;
|
||||
containerPath: string;
|
||||
readonly: boolean;
|
||||
}
|
||||
|
||||
function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount[] {
|
||||
const mounts: VolumeMount[] = [];
|
||||
const projectRoot = process.cwd();
|
||||
const groupDir = resolveGroupFolderPath(group.folder);
|
||||
|
||||
if (isMain) {
|
||||
// Main gets the project root read-only. Writable paths the agent needs
|
||||
// (store, group folder, IPC, .claude/) are mounted separately below.
|
||||
// Read-only prevents the agent from modifying host application code
|
||||
// (src/, dist/, package.json, etc.) which would bypass the sandbox
|
||||
// entirely on next restart.
|
||||
mounts.push({
|
||||
hostPath: projectRoot,
|
||||
containerPath: '/workspace/project',
|
||||
readonly: true,
|
||||
});
|
||||
|
||||
// Shadow .env so the agent cannot read secrets from the mounted project root.
|
||||
// Credentials are injected by the OneCLI gateway, never exposed to containers.
|
||||
const envFile = path.join(projectRoot, '.env');
|
||||
if (fs.existsSync(envFile)) {
|
||||
mounts.push({
|
||||
hostPath: '/dev/null',
|
||||
containerPath: '/workspace/project/.env',
|
||||
readonly: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Main gets writable access to the store (SQLite DB) so it can
|
||||
// query and write to the database directly.
|
||||
const storeDir = path.join(projectRoot, 'store');
|
||||
mounts.push({
|
||||
hostPath: storeDir,
|
||||
containerPath: '/workspace/project/store',
|
||||
readonly: false,
|
||||
});
|
||||
|
||||
// Main also gets its group folder as the working directory
|
||||
mounts.push({
|
||||
hostPath: groupDir,
|
||||
containerPath: '/workspace/group',
|
||||
readonly: false,
|
||||
});
|
||||
|
||||
// Global memory directory — writable for main so it can update shared context
|
||||
const globalDir = path.join(GROUPS_DIR, 'global');
|
||||
if (fs.existsSync(globalDir)) {
|
||||
mounts.push({
|
||||
hostPath: globalDir,
|
||||
containerPath: '/workspace/global',
|
||||
readonly: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Other groups only get their own folder
|
||||
mounts.push({
|
||||
hostPath: groupDir,
|
||||
containerPath: '/workspace/group',
|
||||
readonly: false,
|
||||
});
|
||||
|
||||
// Global memory directory (read-only for non-main)
|
||||
// Only directory mounts are supported, not file mounts
|
||||
const globalDir = path.join(GROUPS_DIR, 'global');
|
||||
if (fs.existsSync(globalDir)) {
|
||||
mounts.push({
|
||||
hostPath: globalDir,
|
||||
containerPath: '/workspace/global',
|
||||
readonly: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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');
|
||||
fs.mkdirSync(groupSessionsDir, { recursive: true });
|
||||
const settingsFile = path.join(groupSessionsDir, 'settings.json');
|
||||
if (!fs.existsSync(settingsFile)) {
|
||||
fs.writeFileSync(
|
||||
settingsFile,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
// Enable agent swarms (subagent orchestration)
|
||||
// https://code.claude.com/docs/en/agent-teams#orchestrate-teams-of-claude-code-sessions
|
||||
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
||||
// Load CLAUDE.md from additional mounted directories
|
||||
// https://code.claude.com/docs/en/memory#load-memory-from-additional-directories
|
||||
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
|
||||
// Enable Claude's memory feature (persists user preferences between sessions)
|
||||
// https://code.claude.com/docs/en/memory#manage-auto-memory
|
||||
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
);
|
||||
}
|
||||
|
||||
// Sync skills from container/skills/ into each group's .claude/skills/
|
||||
const skillsSrc = path.join(process.cwd(), 'container', 'skills');
|
||||
const skillsDst = path.join(groupSessionsDir, 'skills');
|
||||
if (fs.existsSync(skillsSrc)) {
|
||||
for (const skillDir of fs.readdirSync(skillsSrc)) {
|
||||
const srcDir = path.join(skillsSrc, skillDir);
|
||||
if (!fs.statSync(srcDir).isDirectory()) continue;
|
||||
const dstDir = path.join(skillsDst, skillDir);
|
||||
fs.cpSync(srcDir, dstDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
mounts.push({
|
||||
hostPath: groupSessionsDir,
|
||||
containerPath: '/home/node/.claude',
|
||||
readonly: false,
|
||||
});
|
||||
|
||||
// Per-group IPC namespace: each group gets its own IPC directory
|
||||
// This prevents cross-group privilege escalation via IPC
|
||||
const groupIpcDir = resolveGroupIpcPath(group.folder);
|
||||
fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true });
|
||||
fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true });
|
||||
fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true });
|
||||
mounts.push({
|
||||
hostPath: groupIpcDir,
|
||||
containerPath: '/workspace/ipc',
|
||||
readonly: false,
|
||||
});
|
||||
|
||||
// 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');
|
||||
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);
|
||||
if (needsCopy) {
|
||||
fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
mounts.push({
|
||||
hostPath: groupAgentRunnerDir,
|
||||
containerPath: '/app/src',
|
||||
readonly: false,
|
||||
});
|
||||
|
||||
// Additional mounts validated against external allowlist (tamper-proof from containers)
|
||||
if (group.containerConfig?.additionalMounts) {
|
||||
const validatedMounts = validateAdditionalMounts(group.containerConfig.additionalMounts, group.name, isMain);
|
||||
mounts.push(...validatedMounts);
|
||||
}
|
||||
|
||||
return mounts;
|
||||
}
|
||||
|
||||
async function buildContainerArgs(
|
||||
mounts: VolumeMount[],
|
||||
containerName: string,
|
||||
agentIdentifier?: string,
|
||||
): Promise<string[]> {
|
||||
const args: string[] = ['run', '-i', '--rm', '--name', containerName];
|
||||
|
||||
// Pass host timezone so container's local time matches the user's
|
||||
args.push('-e', `TZ=${TIMEZONE}`);
|
||||
|
||||
// OneCLI gateway handles credential injection — containers never see real secrets.
|
||||
// The gateway intercepts HTTPS traffic and injects API keys or OAuth tokens.
|
||||
const onecliApplied = await onecli.applyContainerConfig(args, {
|
||||
addHostMapping: false, // Nanoclaw already handles host gateway
|
||||
agent: agentIdentifier,
|
||||
});
|
||||
if (onecliApplied) {
|
||||
logger.info({ containerName }, 'OneCLI gateway config applied');
|
||||
} else {
|
||||
logger.warn({ containerName }, 'OneCLI gateway not reachable — container will have no credentials');
|
||||
}
|
||||
|
||||
// Runtime-specific args for host gateway resolution
|
||||
args.push(...hostGatewayArgs());
|
||||
|
||||
// Run as host user so bind-mounted files are accessible.
|
||||
// Skip when running as root (uid 0), as the container's node user (uid 1000),
|
||||
// or when getuid is unavailable (native Windows without WSL).
|
||||
const hostUid = process.getuid?.();
|
||||
const hostGid = process.getgid?.();
|
||||
if (hostUid != null && hostUid !== 0 && hostUid !== 1000) {
|
||||
args.push('--user', `${hostUid}:${hostGid}`);
|
||||
args.push('-e', 'HOME=/home/node');
|
||||
}
|
||||
|
||||
for (const mount of mounts) {
|
||||
if (mount.readonly) {
|
||||
args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath));
|
||||
} else {
|
||||
args.push('-v', `${mount.hostPath}:${mount.containerPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
args.push(CONTAINER_IMAGE);
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
export async function runContainerAgent(
|
||||
group: RegisteredGroup,
|
||||
input: ContainerInput,
|
||||
onProcess: (proc: ChildProcess, containerName: string) => void,
|
||||
onOutput?: (output: ContainerOutput) => Promise<void>,
|
||||
): Promise<ContainerOutput> {
|
||||
const startTime = Date.now();
|
||||
|
||||
const groupDir = resolveGroupFolderPath(group.folder);
|
||||
fs.mkdirSync(groupDir, { recursive: true });
|
||||
|
||||
const mounts = buildVolumeMounts(group, input.isMain);
|
||||
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);
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
group: group.name,
|
||||
containerName,
|
||||
mounts: mounts.map((m) => `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`),
|
||||
containerArgs: containerArgs.join(' '),
|
||||
},
|
||||
'Container mount configuration',
|
||||
);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
group: group.name,
|
||||
containerName,
|
||||
mountCount: mounts.length,
|
||||
isMain: input.isMain,
|
||||
},
|
||||
'Spawning container agent',
|
||||
);
|
||||
|
||||
const logsDir = path.join(groupDir, 'logs');
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const container = spawn(CONTAINER_RUNTIME_BIN, containerArgs, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
onProcess(container, containerName);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let stdoutTruncated = false;
|
||||
let stderrTruncated = false;
|
||||
|
||||
container.stdin.write(JSON.stringify(input));
|
||||
container.stdin.end();
|
||||
|
||||
// Streaming output: parse OUTPUT_START/END marker pairs as they arrive
|
||||
let parseBuffer = '';
|
||||
let newSessionId: string | undefined;
|
||||
let outputChain = Promise.resolve();
|
||||
|
||||
container.stdout.on('data', (data) => {
|
||||
const chunk = data.toString();
|
||||
|
||||
// Always accumulate for logging
|
||||
if (!stdoutTruncated) {
|
||||
const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length;
|
||||
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');
|
||||
} else {
|
||||
stdout += chunk;
|
||||
}
|
||||
}
|
||||
|
||||
// Stream-parse for output markers
|
||||
if (onOutput) {
|
||||
parseBuffer += chunk;
|
||||
let startIdx: number;
|
||||
while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) {
|
||||
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();
|
||||
parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length);
|
||||
|
||||
try {
|
||||
const parsed: ContainerOutput = JSON.parse(jsonStr);
|
||||
if (parsed.newSessionId) {
|
||||
newSessionId = parsed.newSessionId;
|
||||
}
|
||||
hadStreamingOutput = true;
|
||||
// Activity detected — reset the hard timeout
|
||||
resetTimeout();
|
||||
// Call onOutput for all markers (including null results)
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
container.stderr.on('data', (data) => {
|
||||
const chunk = data.toString();
|
||||
const lines = chunk.trim().split('\n');
|
||||
for (const line of lines) {
|
||||
if (line) logger.debug({ container: group.folder }, line);
|
||||
}
|
||||
// Don't reset timeout on stderr — SDK writes debug logs continuously.
|
||||
// Timeout only resets on actual output (OUTPUT_MARKER in stdout).
|
||||
if (stderrTruncated) return;
|
||||
const remaining = CONTAINER_MAX_OUTPUT_SIZE - stderr.length;
|
||||
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');
|
||||
} else {
|
||||
stderr += chunk;
|
||||
}
|
||||
});
|
||||
|
||||
let timedOut = false;
|
||||
let hadStreamingOutput = false;
|
||||
const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT;
|
||||
// Grace period: hard timeout must be at least IDLE_TIMEOUT + 30s so the
|
||||
// graceful _close sentinel has time to trigger before the hard kill fires.
|
||||
const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000);
|
||||
|
||||
const killOnTimeout = () => {
|
||||
timedOut = true;
|
||||
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');
|
||||
container.kill('SIGKILL');
|
||||
}
|
||||
};
|
||||
|
||||
let timeout = setTimeout(killOnTimeout, timeoutMs);
|
||||
|
||||
// Reset the timeout whenever there's activity (streaming output)
|
||||
const resetTimeout = () => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(killOnTimeout, timeoutMs);
|
||||
};
|
||||
|
||||
container.on('close', (code) => {
|
||||
clearTimeout(timeout);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (timedOut) {
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const timeoutLog = path.join(logsDir, `container-${ts}.log`);
|
||||
fs.writeFileSync(
|
||||
timeoutLog,
|
||||
[
|
||||
`=== Container Run Log (TIMEOUT) ===`,
|
||||
`Timestamp: ${new Date().toISOString()}`,
|
||||
`Group: ${group.name}`,
|
||||
`Container: ${containerName}`,
|
||||
`Duration: ${duration}ms`,
|
||||
`Exit Code: ${code}`,
|
||||
`Had Streaming Output: ${hadStreamingOutput}`,
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
// Timeout after output = idle cleanup, not failure.
|
||||
// The agent already sent its response; this is just the
|
||||
// container being reaped after the idle period expired.
|
||||
if (hadStreamingOutput) {
|
||||
logger.info(
|
||||
{ group: group.name, containerName, duration, code },
|
||||
'Container timed out after output (idle cleanup)',
|
||||
);
|
||||
outputChain.then(() => {
|
||||
resolve({
|
||||
status: 'success',
|
||||
result: null,
|
||||
newSessionId,
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error({ group: group.name, containerName, duration, code }, 'Container timed out with no output');
|
||||
|
||||
resolve({
|
||||
status: 'error',
|
||||
result: null,
|
||||
error: `Container timed out after ${configTimeout}ms`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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 logLines = [
|
||||
`=== Container Run Log ===`,
|
||||
`Timestamp: ${new Date().toISOString()}`,
|
||||
`Group: ${group.name}`,
|
||||
`IsMain: ${input.isMain}`,
|
||||
`Duration: ${duration}ms`,
|
||||
`Exit Code: ${code}`,
|
||||
`Stdout Truncated: ${stdoutTruncated}`,
|
||||
`Stderr Truncated: ${stderrTruncated}`,
|
||||
``,
|
||||
];
|
||||
|
||||
const isError = code !== 0;
|
||||
|
||||
if (isVerbose || isError) {
|
||||
// On error, log input metadata only — not the full prompt.
|
||||
// Full input is only included at verbose level to avoid
|
||||
// persisting user conversation content on every non-zero exit.
|
||||
if (isVerbose) {
|
||||
logLines.push(`=== Input ===`, JSON.stringify(input, null, 2), ``);
|
||||
} else {
|
||||
logLines.push(
|
||||
`=== Input Summary ===`,
|
||||
`Prompt length: ${input.prompt.length} chars`,
|
||||
`Session ID: ${input.sessionId || 'new'}`,
|
||||
``,
|
||||
);
|
||||
}
|
||||
logLines.push(
|
||||
`=== Container Args ===`,
|
||||
containerArgs.join(' '),
|
||||
``,
|
||||
`=== Mounts ===`,
|
||||
mounts.map((m) => `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`).join('\n'),
|
||||
``,
|
||||
`=== Stderr${stderrTruncated ? ' (TRUNCATED)' : ''} ===`,
|
||||
stderr,
|
||||
``,
|
||||
`=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`,
|
||||
stdout,
|
||||
);
|
||||
} else {
|
||||
logLines.push(
|
||||
`=== Input Summary ===`,
|
||||
`Prompt length: ${input.prompt.length} chars`,
|
||||
`Session ID: ${input.sessionId || 'new'}`,
|
||||
``,
|
||||
`=== Mounts ===`,
|
||||
mounts.map((m) => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`).join('\n'),
|
||||
``,
|
||||
);
|
||||
}
|
||||
|
||||
fs.writeFileSync(logFile, logLines.join('\n'));
|
||||
logger.debug({ logFile, verbose: isVerbose }, 'Container log written');
|
||||
|
||||
if (code !== 0) {
|
||||
logger.error(
|
||||
{
|
||||
group: group.name,
|
||||
code,
|
||||
duration,
|
||||
stderr,
|
||||
stdout,
|
||||
logFile,
|
||||
},
|
||||
'Container exited with error',
|
||||
);
|
||||
|
||||
resolve({
|
||||
status: 'error',
|
||||
result: null,
|
||||
error: `Container exited with code ${code}: ${stderr.slice(-200)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 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)');
|
||||
resolve({
|
||||
status: 'success',
|
||||
result: null,
|
||||
newSessionId,
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy mode: parse the last output marker pair from accumulated stdout
|
||||
try {
|
||||
// Extract JSON between sentinel markers for robust parsing
|
||||
const startIdx = stdout.indexOf(OUTPUT_START_MARKER);
|
||||
const endIdx = stdout.indexOf(OUTPUT_END_MARKER);
|
||||
|
||||
let jsonLine: string;
|
||||
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
||||
jsonLine = stdout.slice(startIdx + OUTPUT_START_MARKER.length, endIdx).trim();
|
||||
} else {
|
||||
// Fallback: last non-empty line (backwards compatibility)
|
||||
const lines = stdout.trim().split('\n');
|
||||
jsonLine = lines[lines.length - 1];
|
||||
}
|
||||
|
||||
const output: ContainerOutput = JSON.parse(jsonLine);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
group: group.name,
|
||||
duration,
|
||||
status: output.status,
|
||||
hasResult: !!output.result,
|
||||
},
|
||||
'Container completed',
|
||||
);
|
||||
|
||||
resolve(output);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{
|
||||
group: group.name,
|
||||
stdout,
|
||||
stderr,
|
||||
error: err,
|
||||
},
|
||||
'Failed to parse container output',
|
||||
);
|
||||
|
||||
resolve({
|
||||
status: 'error',
|
||||
result: null,
|
||||
error: `Failed to parse container output: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
container.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
logger.error({ group: group.name, containerName, error: err }, 'Container spawn error');
|
||||
resolve({
|
||||
status: 'error',
|
||||
result: null,
|
||||
error: `Container spawn error: ${err.message}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function writeTasksSnapshot(
|
||||
groupFolder: string,
|
||||
isMain: boolean,
|
||||
tasks: Array<{
|
||||
id: string;
|
||||
groupFolder: string;
|
||||
prompt: string;
|
||||
script?: string | null;
|
||||
schedule_type: string;
|
||||
schedule_value: string;
|
||||
status: string;
|
||||
next_run: string | null;
|
||||
}>,
|
||||
): void {
|
||||
// Write filtered tasks to the group's IPC directory
|
||||
const groupIpcDir = resolveGroupIpcPath(groupFolder);
|
||||
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 tasksFile = path.join(groupIpcDir, 'current_tasks.json');
|
||||
fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2));
|
||||
}
|
||||
|
||||
export interface AvailableGroup {
|
||||
jid: string;
|
||||
name: string;
|
||||
lastActivity: string;
|
||||
isRegistered: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write available groups snapshot for the container to read.
|
||||
* Only main group can see all available groups (for activation).
|
||||
* Non-main groups only see their own registration status.
|
||||
*/
|
||||
export function writeGroupsSnapshot(
|
||||
groupFolder: string,
|
||||
isMain: boolean,
|
||||
groups: AvailableGroup[],
|
||||
_registeredJids: Set<string>,
|
||||
): void {
|
||||
const groupIpcDir = resolveGroupIpcPath(groupFolder);
|
||||
fs.mkdirSync(groupIpcDir, { recursive: true });
|
||||
|
||||
// Main sees all groups; others see nothing (they can't activate groups)
|
||||
const visibleGroups = isMain ? groups : [];
|
||||
|
||||
const groupsFile = path.join(groupIpcDir, 'available_groups.json');
|
||||
fs.writeFileSync(
|
||||
groupsFile,
|
||||
JSON.stringify(
|
||||
{
|
||||
groups: visibleGroups,
|
||||
lastSync: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock logger
|
||||
vi.mock('./logger.js', () => ({
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock child_process — store the mock fn so tests can configure it
|
||||
const mockExecSync = vi.fn();
|
||||
vi.mock('child_process', () => ({
|
||||
execSync: (...args: unknown[]) => mockExecSync(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
CONTAINER_RUNTIME_BIN,
|
||||
readonlyMountArgs,
|
||||
stopContainer,
|
||||
ensureContainerRuntimeRunning,
|
||||
cleanupOrphans,
|
||||
} from './container-runtime.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// --- Pure functions ---
|
||||
|
||||
describe('readonlyMountArgs', () => {
|
||||
it('returns -v flag with :ro suffix', () => {
|
||||
const args = readonlyMountArgs('/host/path', '/container/path');
|
||||
expect(args).toEqual(['-v', '/host/path:/container/path:ro']);
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
});
|
||||
|
||||
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`id`')).toThrow('Invalid container name');
|
||||
expect(mockExecSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// --- ensureContainerRuntimeRunning ---
|
||||
|
||||
describe('ensureContainerRuntimeRunning', () => {
|
||||
it('does nothing when runtime is already running', () => {
|
||||
mockExecSync.mockReturnValueOnce('');
|
||||
|
||||
ensureContainerRuntimeRunning();
|
||||
|
||||
expect(mockExecSync).toHaveBeenCalledTimes(1);
|
||||
expect(mockExecSync).toHaveBeenCalledWith(`${CONTAINER_RUNTIME_BIN} info`, {
|
||||
stdio: 'pipe',
|
||||
timeout: 10000,
|
||||
});
|
||||
expect(logger.debug).toHaveBeenCalledWith('Container runtime already running');
|
||||
});
|
||||
|
||||
it('throws when docker info fails', () => {
|
||||
mockExecSync.mockImplementationOnce(() => {
|
||||
throw new Error('Cannot connect to the Docker daemon');
|
||||
});
|
||||
|
||||
expect(() => ensureContainerRuntimeRunning()).toThrow('Container runtime is required but failed to start');
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// --- cleanupOrphans ---
|
||||
|
||||
describe('cleanupOrphans', () => {
|
||||
it('stops orphaned nanoclaw containers', () => {
|
||||
// docker ps returns container names, one per line
|
||||
mockExecSync.mockReturnValueOnce('nanoclaw-group1-111\nnanoclaw-group2-222\n');
|
||||
// stop calls succeed
|
||||
mockExecSync.mockReturnValue('');
|
||||
|
||||
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(logger.info).toHaveBeenCalledWith(
|
||||
{ count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group2-222'] },
|
||||
'Stopped orphaned containers',
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when no orphans exist', () => {
|
||||
mockExecSync.mockReturnValueOnce('');
|
||||
|
||||
cleanupOrphans();
|
||||
|
||||
expect(mockExecSync).toHaveBeenCalledTimes(1);
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns and continues when ps fails', () => {
|
||||
mockExecSync.mockImplementationOnce(() => {
|
||||
throw new Error('docker not available');
|
||||
});
|
||||
|
||||
cleanupOrphans(); // should not throw
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Failed to clean up orphaned containers',
|
||||
);
|
||||
});
|
||||
|
||||
it('continues stopping remaining containers when one stop fails', () => {
|
||||
mockExecSync.mockReturnValueOnce('nanoclaw-a-1\nnanoclaw-b-2\n');
|
||||
// First stop fails
|
||||
mockExecSync.mockImplementationOnce(() => {
|
||||
throw new Error('already stopped');
|
||||
});
|
||||
// Second stop succeeds
|
||||
mockExecSync.mockReturnValueOnce('');
|
||||
|
||||
cleanupOrphans(); // should not throw
|
||||
|
||||
expect(mockExecSync).toHaveBeenCalledTimes(3);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{ count: 2, names: ['nanoclaw-a-1', 'nanoclaw-b-2'] },
|
||||
'Stopped orphaned containers',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* Container runtime abstraction for NanoClaw.
|
||||
* All runtime-specific logic lives here so swapping runtimes means changing one file.
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
import os from 'os';
|
||||
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/** The container runtime binary name. */
|
||||
export const CONTAINER_RUNTIME_BIN = 'docker';
|
||||
|
||||
/** CLI args needed for the container to resolve the host gateway. */
|
||||
export function hostGatewayArgs(): string[] {
|
||||
// On Linux, host.docker.internal isn't built-in — add it explicitly
|
||||
if (os.platform() === 'linux') {
|
||||
return ['--add-host=host.docker.internal:host-gateway'];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Returns CLI args for a readonly bind mount. */
|
||||
export function readonlyMountArgs(hostPath: string, containerPath: string): string[] {
|
||||
return ['-v', `${hostPath}:${containerPath}:ro`];
|
||||
}
|
||||
|
||||
/** Stop a container by name. Uses execFileSync to avoid shell injection. */
|
||||
export function stopContainer(name: string): void {
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) {
|
||||
throw new Error(`Invalid container name: ${name}`);
|
||||
}
|
||||
execSync(`${CONTAINER_RUNTIME_BIN} stop -t 1 ${name}`, { stdio: 'pipe' });
|
||||
}
|
||||
|
||||
/** Ensure the container runtime is running, starting it if needed. */
|
||||
export function ensureContainerRuntimeRunning(): void {
|
||||
try {
|
||||
execSync(`${CONTAINER_RUNTIME_BIN} info`, {
|
||||
stdio: 'pipe',
|
||||
timeout: 10000,
|
||||
});
|
||||
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');
|
||||
throw new Error('Container runtime is required but failed to start', {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 orphans = output.trim().split('\n').filter(Boolean);
|
||||
for (const name of orphans) {
|
||||
try {
|
||||
stopContainer(name);
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
}
|
||||
if (orphans.length > 0) {
|
||||
logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Failed to clean up orphaned containers');
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('database migrations', () => {
|
||||
it('defaults Telegram backfill chats to direct messages', async () => {
|
||||
const repoRoot = process.cwd();
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-db-test-'));
|
||||
|
||||
try {
|
||||
process.chdir(tempDir);
|
||||
fs.mkdirSync(path.join(tempDir, 'store'), { recursive: true });
|
||||
|
||||
const dbPath = path.join(tempDir, 'store', 'messages.db');
|
||||
const legacyDb = new Database(dbPath);
|
||||
legacyDb.exec(`
|
||||
CREATE TABLE chats (
|
||||
jid TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
last_message_time TEXT
|
||||
);
|
||||
`);
|
||||
legacyDb
|
||||
.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 (?, ?, ?)`)
|
||||
.run('tg:-10012345', 'Telegram Group', '2024-01-01T00:00:01.000Z');
|
||||
legacyDb
|
||||
.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');
|
||||
|
||||
initDatabase();
|
||||
|
||||
const chats = getAllChats();
|
||||
expect(chats.find((chat) => chat.jid === 'tg:12345')).toMatchObject({
|
||||
channel: 'telegram',
|
||||
is_group: 0,
|
||||
});
|
||||
expect(chats.find((chat) => chat.jid === 'tg:-10012345')).toMatchObject({
|
||||
channel: 'telegram',
|
||||
is_group: 0,
|
||||
});
|
||||
expect(chats.find((chat) => chat.jid === 'room@g.us')).toMatchObject({
|
||||
channel: 'whatsapp',
|
||||
is_group: 1,
|
||||
});
|
||||
|
||||
_closeDatabase();
|
||||
} finally {
|
||||
process.chdir(repoRoot);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,591 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
import {
|
||||
_initTestDatabase,
|
||||
createTask,
|
||||
deleteTask,
|
||||
getAllChats,
|
||||
getAllRegisteredGroups,
|
||||
getLastBotMessageTimestamp,
|
||||
getMessagesSince,
|
||||
getNewMessages,
|
||||
getTaskById,
|
||||
setRegisteredGroup,
|
||||
storeChatMetadata,
|
||||
storeMessage,
|
||||
updateTask,
|
||||
} from './db.js';
|
||||
import { formatMessages } from './router.js';
|
||||
|
||||
beforeEach(() => {
|
||||
_initTestDatabase();
|
||||
});
|
||||
|
||||
// Helper to store a message using the normalized NewMessage interface
|
||||
function store(overrides: {
|
||||
id: string;
|
||||
chat_jid: string;
|
||||
sender: string;
|
||||
sender_name: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
is_from_me?: boolean;
|
||||
}) {
|
||||
storeMessage({
|
||||
id: overrides.id,
|
||||
chat_jid: overrides.chat_jid,
|
||||
sender: overrides.sender,
|
||||
sender_name: overrides.sender_name,
|
||||
content: overrides.content,
|
||||
timestamp: overrides.timestamp,
|
||||
is_from_me: overrides.is_from_me ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
// --- storeMessage (NewMessage format) ---
|
||||
|
||||
describe('storeMessage', () => {
|
||||
it('stores a message and retrieves it', () => {
|
||||
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
|
||||
|
||||
store({
|
||||
id: 'msg-1',
|
||||
chat_jid: 'group@g.us',
|
||||
sender: '123@s.whatsapp.net',
|
||||
sender_name: 'Alice',
|
||||
content: 'hello world',
|
||||
timestamp: '2024-01-01T00:00:01.000Z',
|
||||
});
|
||||
|
||||
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');
|
||||
expect(messages[0].sender_name).toBe('Alice');
|
||||
expect(messages[0].content).toBe('hello world');
|
||||
});
|
||||
|
||||
it('filters out empty content', () => {
|
||||
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
|
||||
|
||||
store({
|
||||
id: 'msg-2',
|
||||
chat_jid: 'group@g.us',
|
||||
sender: '111@s.whatsapp.net',
|
||||
sender_name: 'Dave',
|
||||
content: '',
|
||||
timestamp: '2024-01-01T00:00:04.000Z',
|
||||
});
|
||||
|
||||
const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy');
|
||||
expect(messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('stores is_from_me flag', () => {
|
||||
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
|
||||
|
||||
store({
|
||||
id: 'msg-3',
|
||||
chat_jid: 'group@g.us',
|
||||
sender: 'me@s.whatsapp.net',
|
||||
sender_name: 'Me',
|
||||
content: 'my message',
|
||||
timestamp: '2024-01-01T00:00:05.000Z',
|
||||
is_from_me: true,
|
||||
});
|
||||
|
||||
// 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');
|
||||
expect(messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('upserts on duplicate id+chat_jid', () => {
|
||||
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
|
||||
|
||||
store({
|
||||
id: 'msg-dup',
|
||||
chat_jid: 'group@g.us',
|
||||
sender: '123@s.whatsapp.net',
|
||||
sender_name: 'Alice',
|
||||
content: 'original',
|
||||
timestamp: '2024-01-01T00:00:01.000Z',
|
||||
});
|
||||
|
||||
store({
|
||||
id: 'msg-dup',
|
||||
chat_jid: 'group@g.us',
|
||||
sender: '123@s.whatsapp.net',
|
||||
sender_name: 'Alice',
|
||||
content: 'updated',
|
||||
timestamp: '2024-01-01T00:00:01.000Z',
|
||||
});
|
||||
|
||||
const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy');
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].content).toBe('updated');
|
||||
});
|
||||
});
|
||||
|
||||
// --- reply context persistence ---
|
||||
|
||||
describe('reply context', () => {
|
||||
it('stores and retrieves reply_to fields', () => {
|
||||
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
|
||||
|
||||
storeMessage({
|
||||
id: 'reply-1',
|
||||
chat_jid: 'group@g.us',
|
||||
sender: '123',
|
||||
sender_name: 'Alice',
|
||||
content: 'Yes, on my way!',
|
||||
timestamp: '2024-01-01T00:00:01.000Z',
|
||||
reply_to_message_id: '42',
|
||||
reply_to_message_content: 'Are you coming tonight?',
|
||||
reply_to_sender_name: 'Bob',
|
||||
});
|
||||
|
||||
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_sender_name).toBe('Bob');
|
||||
});
|
||||
|
||||
it('returns null for messages without reply context', () => {
|
||||
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
|
||||
|
||||
store({
|
||||
id: 'no-reply',
|
||||
chat_jid: 'group@g.us',
|
||||
sender: '123',
|
||||
sender_name: 'Alice',
|
||||
content: 'Just a normal message',
|
||||
timestamp: '2024-01-01T00:00:01.000Z',
|
||||
});
|
||||
|
||||
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();
|
||||
expect(messages[0].reply_to_sender_name).toBeNull();
|
||||
});
|
||||
|
||||
it('retrieves reply context via getNewMessages', () => {
|
||||
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
|
||||
|
||||
storeMessage({
|
||||
id: 'reply-2',
|
||||
chat_jid: 'group@g.us',
|
||||
sender: '456',
|
||||
sender_name: 'Carol',
|
||||
content: 'Agreed',
|
||||
timestamp: '2024-01-01T00:00:01.000Z',
|
||||
reply_to_message_id: '99',
|
||||
reply_to_message_content: 'We should meet',
|
||||
reply_to_sender_name: 'Dave',
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
// --- getMessagesSince ---
|
||||
|
||||
describe('getMessagesSince', () => {
|
||||
beforeEach(() => {
|
||||
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
|
||||
|
||||
store({
|
||||
id: 'm1',
|
||||
chat_jid: 'group@g.us',
|
||||
sender: 'Alice@s.whatsapp.net',
|
||||
sender_name: 'Alice',
|
||||
content: 'first',
|
||||
timestamp: '2024-01-01T00:00:01.000Z',
|
||||
});
|
||||
store({
|
||||
id: 'm2',
|
||||
chat_jid: 'group@g.us',
|
||||
sender: 'Bob@s.whatsapp.net',
|
||||
sender_name: 'Bob',
|
||||
content: 'second',
|
||||
timestamp: '2024-01-01T00:00:02.000Z',
|
||||
});
|
||||
storeMessage({
|
||||
id: 'm3',
|
||||
chat_jid: 'group@g.us',
|
||||
sender: 'Bot@s.whatsapp.net',
|
||||
sender_name: 'Bot',
|
||||
content: 'bot reply',
|
||||
timestamp: '2024-01-01T00:00:03.000Z',
|
||||
is_bot_message: true,
|
||||
});
|
||||
store({
|
||||
id: 'm4',
|
||||
chat_jid: 'group@g.us',
|
||||
sender: 'Carol@s.whatsapp.net',
|
||||
sender_name: 'Carol',
|
||||
content: 'third',
|
||||
timestamp: '2024-01-01T00:00:04.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns messages after the given timestamp', () => {
|
||||
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 botMsgs = msgs.filter((m) => m.content === 'bot reply');
|
||||
expect(botMsgs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns all non-bot messages when sinceTimestamp is empty', () => {
|
||||
const msgs = getMessagesSince('group@g.us', '', 'Andy');
|
||||
// 3 user messages (bot message excluded)
|
||||
expect(msgs).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('recovers cursor from last bot reply when lastAgentTimestamp is missing', () => {
|
||||
// beforeEach already inserts m3 (bot reply at 00:00:03) and m4 (user at 00:00:04)
|
||||
// Add more old history before the bot reply
|
||||
for (let i = 1; i <= 50; i++) {
|
||||
store({
|
||||
id: `history-${i}`,
|
||||
chat_jid: 'group@g.us',
|
||||
sender: 'user@s.whatsapp.net',
|
||||
sender_name: 'User',
|
||||
content: `old message ${i}`,
|
||||
timestamp: `2023-06-${String(i).padStart(2, '0')}T12:00:00.000Z`,
|
||||
});
|
||||
}
|
||||
|
||||
// New message after the bot reply (m3 at 00:00:03)
|
||||
store({
|
||||
id: 'new-1',
|
||||
chat_jid: 'group@g.us',
|
||||
sender: 'user@s.whatsapp.net',
|
||||
sender_name: 'User',
|
||||
content: 'new message after bot reply',
|
||||
timestamp: '2024-01-02T00:00:00.000Z',
|
||||
});
|
||||
|
||||
// Recover cursor from the last bot message (m3 from beforeEach)
|
||||
const recovered = getLastBotMessageTimestamp('group@g.us', 'Andy');
|
||||
expect(recovered).toBe('2024-01-01T00:00:03.000Z');
|
||||
|
||||
// Using recovered cursor: only gets messages after the bot reply
|
||||
const msgs = getMessagesSince('group@g.us', recovered!, 'Andy', 10);
|
||||
// m4 (third, 00:00:04) + new-1 — skips all 50 old messages and m1/m2
|
||||
expect(msgs).toHaveLength(2);
|
||||
expect(msgs[0].content).toBe('third');
|
||||
expect(msgs[1].content).toBe('new message after bot reply');
|
||||
});
|
||||
|
||||
it('caps messages to configured limit even with recovered cursor', () => {
|
||||
// beforeEach inserts m3 (bot at 00:00:03). Add 30 messages after it.
|
||||
for (let i = 1; i <= 30; i++) {
|
||||
store({
|
||||
id: `pending-${i}`,
|
||||
chat_jid: 'group@g.us',
|
||||
sender: 'user@s.whatsapp.net',
|
||||
sender_name: 'User',
|
||||
content: `pending message ${i}`,
|
||||
timestamp: `2024-02-${String(i).padStart(2, '0')}T12:00:00.000Z`,
|
||||
});
|
||||
}
|
||||
|
||||
const recovered = getLastBotMessageTimestamp('group@g.us', 'Andy');
|
||||
expect(recovered).toBe('2024-01-01T00:00:03.000Z');
|
||||
|
||||
// With limit=10, only the 10 most recent are returned
|
||||
const msgs = getMessagesSince('group@g.us', recovered!, 'Andy', 10);
|
||||
expect(msgs).toHaveLength(10);
|
||||
// Most recent 10: pending-21 through pending-30
|
||||
expect(msgs[0].content).toBe('pending message 21');
|
||||
expect(msgs[9].content).toBe('pending message 30');
|
||||
});
|
||||
|
||||
it('returns last N messages when no bot reply and no cursor exist', () => {
|
||||
// Use a fresh group with no bot messages
|
||||
storeChatMetadata('fresh@g.us', '2024-01-01T00:00:00.000Z');
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
store({
|
||||
id: `fresh-${i}`,
|
||||
chat_jid: 'fresh@g.us',
|
||||
sender: 'user@s.whatsapp.net',
|
||||
sender_name: 'User',
|
||||
content: `message ${i}`,
|
||||
timestamp: `2024-02-${String(i).padStart(2, '0')}T12:00:00.000Z`,
|
||||
});
|
||||
}
|
||||
|
||||
const recovered = getLastBotMessageTimestamp('fresh@g.us', 'Andy');
|
||||
expect(recovered).toBeUndefined();
|
||||
|
||||
// No cursor → sinceTimestamp = '' but limit caps the result
|
||||
const msgs = getMessagesSince('fresh@g.us', '', 'Andy', 10);
|
||||
expect(msgs).toHaveLength(10);
|
||||
|
||||
const prompt = formatMessages(msgs, 'Asia/Jerusalem');
|
||||
const messageTagCount = (prompt.match(/<message /g) || []).length;
|
||||
expect(messageTagCount).toBe(10);
|
||||
});
|
||||
|
||||
it('filters pre-migration bot messages via content prefix backstop', () => {
|
||||
// Simulate a message written before migration: has prefix but is_bot_message = 0
|
||||
store({
|
||||
id: 'm5',
|
||||
chat_jid: 'group@g.us',
|
||||
sender: 'Bot@s.whatsapp.net',
|
||||
sender_name: 'Bot',
|
||||
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');
|
||||
expect(msgs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- getNewMessages ---
|
||||
|
||||
describe('getNewMessages', () => {
|
||||
beforeEach(() => {
|
||||
storeChatMetadata('group1@g.us', '2024-01-01T00:00:00.000Z');
|
||||
storeChatMetadata('group2@g.us', '2024-01-01T00:00:00.000Z');
|
||||
|
||||
store({
|
||||
id: 'a1',
|
||||
chat_jid: 'group1@g.us',
|
||||
sender: 'user@s.whatsapp.net',
|
||||
sender_name: 'User',
|
||||
content: 'g1 msg1',
|
||||
timestamp: '2024-01-01T00:00:01.000Z',
|
||||
});
|
||||
store({
|
||||
id: 'a2',
|
||||
chat_jid: 'group2@g.us',
|
||||
sender: 'user@s.whatsapp.net',
|
||||
sender_name: 'User',
|
||||
content: 'g2 msg1',
|
||||
timestamp: '2024-01-01T00:00:02.000Z',
|
||||
});
|
||||
storeMessage({
|
||||
id: 'a3',
|
||||
chat_jid: 'group1@g.us',
|
||||
sender: 'user@s.whatsapp.net',
|
||||
sender_name: 'User',
|
||||
content: 'bot reply',
|
||||
timestamp: '2024-01-01T00:00:03.000Z',
|
||||
is_bot_message: true,
|
||||
});
|
||||
store({
|
||||
id: 'a4',
|
||||
chat_jid: 'group1@g.us',
|
||||
sender: 'user@s.whatsapp.net',
|
||||
sender_name: 'User',
|
||||
content: 'g1 msg2',
|
||||
timestamp: '2024-01-01T00:00:04.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns new messages across multiple groups', () => {
|
||||
const { messages, newTimestamp } = getNewMessages(
|
||||
['group1@g.us', 'group2@g.us'],
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
'Andy',
|
||||
);
|
||||
// Excludes bot message, returns 3 user messages
|
||||
expect(messages).toHaveLength(3);
|
||||
expect(newTimestamp).toBe('2024-01-01T00:00:04.000Z');
|
||||
});
|
||||
|
||||
it('filters by timestamp', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
it('returns empty for no registered groups', () => {
|
||||
const { messages, newTimestamp } = getNewMessages([], '', 'Andy');
|
||||
expect(messages).toHaveLength(0);
|
||||
expect(newTimestamp).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// --- storeChatMetadata ---
|
||||
|
||||
describe('storeChatMetadata', () => {
|
||||
it('stores chat with JID as default name', () => {
|
||||
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
|
||||
const chats = getAllChats();
|
||||
expect(chats).toHaveLength(1);
|
||||
expect(chats[0].jid).toBe('group@g.us');
|
||||
expect(chats[0].name).toBe('group@g.us');
|
||||
});
|
||||
|
||||
it('stores chat with explicit name', () => {
|
||||
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z', 'My Group');
|
||||
const chats = getAllChats();
|
||||
expect(chats[0].name).toBe('My Group');
|
||||
});
|
||||
|
||||
it('updates name on subsequent call with name', () => {
|
||||
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
|
||||
storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z', 'Updated Name');
|
||||
const chats = getAllChats();
|
||||
expect(chats).toHaveLength(1);
|
||||
expect(chats[0].name).toBe('Updated Name');
|
||||
});
|
||||
|
||||
it('preserves newer timestamp on conflict', () => {
|
||||
storeChatMetadata('group@g.us', '2024-01-01T00:00:05.000Z');
|
||||
storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z');
|
||||
const chats = getAllChats();
|
||||
expect(chats[0].last_message_time).toBe('2024-01-01T00:00:05.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Task CRUD ---
|
||||
|
||||
describe('task CRUD', () => {
|
||||
it('creates and retrieves a task', () => {
|
||||
createTask({
|
||||
id: 'task-1',
|
||||
group_folder: 'main',
|
||||
chat_jid: 'group@g.us',
|
||||
prompt: 'do something',
|
||||
schedule_type: 'once',
|
||||
schedule_value: '2024-06-01T00:00:00.000Z',
|
||||
context_mode: 'isolated',
|
||||
next_run: '2024-06-01T00:00:00.000Z',
|
||||
status: 'active',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const task = getTaskById('task-1');
|
||||
expect(task).toBeDefined();
|
||||
expect(task!.prompt).toBe('do something');
|
||||
expect(task!.status).toBe('active');
|
||||
});
|
||||
|
||||
it('updates task status', () => {
|
||||
createTask({
|
||||
id: 'task-2',
|
||||
group_folder: 'main',
|
||||
chat_jid: 'group@g.us',
|
||||
prompt: 'test',
|
||||
schedule_type: 'once',
|
||||
schedule_value: '2024-06-01T00:00:00.000Z',
|
||||
context_mode: 'isolated',
|
||||
next_run: null,
|
||||
status: 'active',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
updateTask('task-2', { status: 'paused' });
|
||||
expect(getTaskById('task-2')!.status).toBe('paused');
|
||||
});
|
||||
|
||||
it('deletes a task and its run logs', () => {
|
||||
createTask({
|
||||
id: 'task-3',
|
||||
group_folder: 'main',
|
||||
chat_jid: 'group@g.us',
|
||||
prompt: 'delete me',
|
||||
schedule_type: 'once',
|
||||
schedule_value: '2024-06-01T00:00:00.000Z',
|
||||
context_mode: 'isolated',
|
||||
next_run: null,
|
||||
status: 'active',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
deleteTask('task-3');
|
||||
expect(getTaskById('task-3')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// --- LIMIT behavior ---
|
||||
|
||||
describe('message query LIMIT', () => {
|
||||
beforeEach(() => {
|
||||
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
|
||||
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
store({
|
||||
id: `lim-${i}`,
|
||||
chat_jid: 'group@g.us',
|
||||
sender: 'user@s.whatsapp.net',
|
||||
sender_name: 'User',
|
||||
content: `message ${i}`,
|
||||
timestamp: `2024-01-01T00:00:${String(i).padStart(2, '0')}.000Z`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
expect(messages).toHaveLength(3);
|
||||
expect(messages[0].content).toBe('message 8');
|
||||
expect(messages[2].content).toBe('message 10');
|
||||
// Chronological order preserved
|
||||
expect(messages[1].timestamp > messages[0].timestamp).toBe(true);
|
||||
// newTimestamp reflects latest returned row
|
||||
expect(newTimestamp).toBe('2024-01-01T00:00:10.000Z');
|
||||
});
|
||||
|
||||
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);
|
||||
expect(messages).toHaveLength(3);
|
||||
expect(messages[0].content).toBe('message 8');
|
||||
expect(messages[2].content).toBe('message 10');
|
||||
expect(messages[1].timestamp > messages[0].timestamp).toBe(true);
|
||||
});
|
||||
|
||||
it('returns all messages when count is under the limit', () => {
|
||||
const { messages } = getNewMessages(['group@g.us'], '2024-01-01T00:00:00.000Z', 'Andy', 50);
|
||||
expect(messages).toHaveLength(10);
|
||||
});
|
||||
});
|
||||
|
||||
// --- RegisteredGroup isMain round-trip ---
|
||||
|
||||
describe('registered group isMain', () => {
|
||||
it('persists isMain=true through set/get round-trip', () => {
|
||||
setRegisteredGroup('main@s.whatsapp.net', {
|
||||
name: 'Main Chat',
|
||||
folder: 'whatsapp_main',
|
||||
trigger: '@Andy',
|
||||
added_at: '2024-01-01T00:00:00.000Z',
|
||||
isMain: true,
|
||||
});
|
||||
|
||||
const groups = getAllRegisteredGroups();
|
||||
const group = groups['main@s.whatsapp.net'];
|
||||
expect(group).toBeDefined();
|
||||
expect(group.isMain).toBe(true);
|
||||
expect(group.folder).toBe('whatsapp_main');
|
||||
});
|
||||
|
||||
it('omits isMain for non-main groups', () => {
|
||||
setRegisteredGroup('group@g.us', {
|
||||
name: 'Family Chat',
|
||||
folder: 'whatsapp_family-chat',
|
||||
trigger: '@Andy',
|
||||
added_at: '2024-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const groups = getAllRegisteredGroups();
|
||||
const group = groups['group@g.us'];
|
||||
expect(group).toBeDefined();
|
||||
expect(group.isMain).toBeUndefined();
|
||||
});
|
||||
});
|
||||
658
src/v1/db.ts
658
src/v1/db.ts
@@ -1,658 +0,0 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
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';
|
||||
|
||||
let db: Database.Database;
|
||||
|
||||
function createSchema(database: Database.Database): void {
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS chats (
|
||||
jid TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
last_message_time TEXT,
|
||||
channel TEXT,
|
||||
is_group INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT,
|
||||
chat_jid TEXT,
|
||||
sender TEXT,
|
||||
sender_name TEXT,
|
||||
content TEXT,
|
||||
timestamp TEXT,
|
||||
is_from_me INTEGER,
|
||||
is_bot_message INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (id, chat_jid),
|
||||
FOREIGN KEY (chat_jid) REFERENCES chats(jid)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_timestamp ON messages(timestamp);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scheduled_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
group_folder TEXT NOT NULL,
|
||||
chat_jid TEXT NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
schedule_type TEXT NOT NULL,
|
||||
schedule_value TEXT NOT NULL,
|
||||
next_run TEXT,
|
||||
last_run TEXT,
|
||||
last_result TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_next_run ON scheduled_tasks(next_run);
|
||||
CREATE INDEX IF NOT EXISTS idx_status ON scheduled_tasks(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_run_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL,
|
||||
run_at TEXT NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
result TEXT,
|
||||
error TEXT,
|
||||
FOREIGN KEY (task_id) REFERENCES scheduled_tasks(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_run_logs ON task_run_logs(task_id, run_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS router_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
group_folder TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS registered_groups (
|
||||
jid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL UNIQUE,
|
||||
trigger_pattern TEXT NOT NULL,
|
||||
added_at TEXT NOT NULL,
|
||||
container_config TEXT,
|
||||
requires_trigger INTEGER DEFAULT 1
|
||||
);
|
||||
`);
|
||||
|
||||
// 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'`);
|
||||
} catch {
|
||||
/* column already exists */
|
||||
}
|
||||
|
||||
// Add script column if it doesn't exist (migration for existing DBs)
|
||||
try {
|
||||
database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN script TEXT`);
|
||||
} catch {
|
||||
/* column already exists */
|
||||
}
|
||||
|
||||
// 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`);
|
||||
// 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}:%`);
|
||||
} 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`);
|
||||
// Backfill: existing rows with folder = 'main' are the main group
|
||||
database.exec(`UPDATE registered_groups SET is_main = 1 WHERE folder = 'main'`);
|
||||
} catch {
|
||||
/* column already exists */
|
||||
}
|
||||
|
||||
// Add channel and is_group columns if they don't exist (migration for existing DBs)
|
||||
try {
|
||||
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:%'`);
|
||||
} catch {
|
||||
/* columns already exist */
|
||||
}
|
||||
|
||||
// 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_sender_name TEXT`);
|
||||
} catch {
|
||||
/* columns already exist */
|
||||
}
|
||||
}
|
||||
|
||||
export function initDatabase(): void {
|
||||
const dbPath = path.join(STORE_DIR, 'messages.db');
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
|
||||
db = new Database(dbPath);
|
||||
createSchema(db);
|
||||
|
||||
// Migrate from JSON files if they exist
|
||||
migrateJsonState();
|
||||
}
|
||||
|
||||
/** @internal - for tests only. Creates a fresh in-memory database. */
|
||||
export function _initTestDatabase(): void {
|
||||
db = new Database(':memory:');
|
||||
createSchema(db);
|
||||
}
|
||||
|
||||
/** @internal - for tests only. */
|
||||
export function _closeDatabase(): void {
|
||||
db.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Store chat metadata only (no message content).
|
||||
* Used for all chats to enable group discovery without storing sensitive content.
|
||||
*/
|
||||
export function storeChatMetadata(
|
||||
chatJid: string,
|
||||
timestamp: string,
|
||||
name?: string,
|
||||
channel?: string,
|
||||
isGroup?: boolean,
|
||||
): void {
|
||||
const ch = channel ?? null;
|
||||
const group = isGroup === undefined ? null : isGroup ? 1 : 0;
|
||||
|
||||
if (name) {
|
||||
// Update with name, preserving existing timestamp if newer
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO chats (jid, name, last_message_time, channel, is_group) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(jid) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
last_message_time = MAX(last_message_time, excluded.last_message_time),
|
||||
channel = COALESCE(excluded.channel, channel),
|
||||
is_group = COALESCE(excluded.is_group, is_group)
|
||||
`,
|
||||
).run(chatJid, name, timestamp, ch, group);
|
||||
} else {
|
||||
// Update timestamp only, preserve existing name if any
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO chats (jid, name, last_message_time, channel, is_group) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(jid) DO UPDATE SET
|
||||
last_message_time = MAX(last_message_time, excluded.last_message_time),
|
||||
channel = COALESCE(excluded.channel, channel),
|
||||
is_group = COALESCE(excluded.is_group, is_group)
|
||||
`,
|
||||
).run(chatJid, chatJid, timestamp, ch, group);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update chat name without changing timestamp for existing chats.
|
||||
* New chats get the current time as their initial timestamp.
|
||||
* Used during group metadata sync.
|
||||
*/
|
||||
export function updateChatName(chatJid: string, name: string): void {
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)
|
||||
ON CONFLICT(jid) DO UPDATE SET name = excluded.name
|
||||
`,
|
||||
).run(chatJid, name, new Date().toISOString());
|
||||
}
|
||||
|
||||
export interface ChatInfo {
|
||||
jid: string;
|
||||
name: string;
|
||||
last_message_time: string;
|
||||
channel: string;
|
||||
is_group: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all known chats, ordered by most recent activity.
|
||||
*/
|
||||
export function getAllChats(): ChatInfo[] {
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT jid, name, last_message_time, channel, is_group
|
||||
FROM chats
|
||||
ORDER BY last_message_time DESC
|
||||
`,
|
||||
)
|
||||
.all() as ChatInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get timestamp of last group metadata sync.
|
||||
*/
|
||||
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;
|
||||
return row?.last_message_time || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that group metadata was synced.
|
||||
*/
|
||||
export function setLastGroupSync(): void {
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO chats (jid, name, last_message_time) VALUES ('__group_sync__', '__group_sync__', ?)`,
|
||||
).run(now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a message with full content.
|
||||
* Only call this for registered groups where message history is needed.
|
||||
*/
|
||||
export function storeMessage(msg: NewMessage): void {
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message, reply_to_message_id, reply_to_message_content, reply_to_sender_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
msg.id,
|
||||
msg.chat_jid,
|
||||
msg.sender,
|
||||
msg.sender_name,
|
||||
msg.content,
|
||||
msg.timestamp,
|
||||
msg.is_from_me ? 1 : 0,
|
||||
msg.is_bot_message ? 1 : 0,
|
||||
msg.reply_to_message_id ?? null,
|
||||
msg.reply_to_message_content ?? null,
|
||||
msg.reply_to_sender_name ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a message directly.
|
||||
*/
|
||||
export function storeMessageDirect(msg: {
|
||||
id: string;
|
||||
chat_jid: string;
|
||||
sender: string;
|
||||
sender_name: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
is_from_me: boolean;
|
||||
is_bot_message?: boolean;
|
||||
}): void {
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
msg.id,
|
||||
msg.chat_jid,
|
||||
msg.sender,
|
||||
msg.sender_name,
|
||||
msg.content,
|
||||
msg.timestamp,
|
||||
msg.is_from_me ? 1 : 0,
|
||||
msg.is_bot_message ? 1 : 0,
|
||||
);
|
||||
}
|
||||
|
||||
export function getNewMessages(
|
||||
jids: string[],
|
||||
lastTimestamp: string,
|
||||
botPrefix: string,
|
||||
limit: number = 200,
|
||||
): { messages: NewMessage[]; newTimestamp: string } {
|
||||
if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp };
|
||||
|
||||
const placeholders = jids.map(() => '?').join(',');
|
||||
// Filter bot messages using both the is_bot_message flag AND the content
|
||||
// prefix as a backstop for messages written before the migration ran.
|
||||
// Subquery takes the N most recent, outer query re-sorts chronologically.
|
||||
const sql = `
|
||||
SELECT * FROM (
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me,
|
||||
reply_to_message_id, reply_to_message_content, reply_to_sender_name
|
||||
FROM messages
|
||||
WHERE timestamp > ? AND chat_jid IN (${placeholders})
|
||||
AND is_bot_message = 0 AND content NOT LIKE ?
|
||||
AND content != '' AND content IS NOT NULL
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
) ORDER BY timestamp
|
||||
`;
|
||||
|
||||
const rows = db.prepare(sql).all(lastTimestamp, ...jids, `${botPrefix}:%`, limit) as NewMessage[];
|
||||
|
||||
let newTimestamp = lastTimestamp;
|
||||
for (const row of rows) {
|
||||
if (row.timestamp > newTimestamp) newTimestamp = row.timestamp;
|
||||
}
|
||||
|
||||
return { messages: rows, newTimestamp };
|
||||
}
|
||||
|
||||
export function getMessagesSince(
|
||||
chatJid: string,
|
||||
sinceTimestamp: string,
|
||||
botPrefix: string,
|
||||
limit: number = 200,
|
||||
): NewMessage[] {
|
||||
// Filter bot messages using both the is_bot_message flag AND the content
|
||||
// prefix as a backstop for messages written before the migration ran.
|
||||
// Subquery takes the N most recent, outer query re-sorts chronologically.
|
||||
const sql = `
|
||||
SELECT * FROM (
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me,
|
||||
reply_to_message_id, reply_to_message_content, reply_to_sender_name
|
||||
FROM messages
|
||||
WHERE chat_jid = ? AND timestamp > ?
|
||||
AND is_bot_message = 0 AND content NOT LIKE ?
|
||||
AND content != '' AND content IS NOT NULL
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
) ORDER BY timestamp
|
||||
`;
|
||||
return db.prepare(sql).all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit) as NewMessage[];
|
||||
}
|
||||
|
||||
export function getLastBotMessageTimestamp(chatJid: string, botPrefix: string): string | undefined {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT MAX(timestamp) as ts FROM messages
|
||||
WHERE chat_jid = ? AND (is_bot_message = 1 OR content LIKE ?)`,
|
||||
)
|
||||
.get(chatJid, `${botPrefix}:%`) as { ts: string | null } | undefined;
|
||||
return row?.ts ?? undefined;
|
||||
}
|
||||
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
task.id,
|
||||
task.group_folder,
|
||||
task.chat_jid,
|
||||
task.prompt,
|
||||
task.script || null,
|
||||
task.schedule_type,
|
||||
task.schedule_value,
|
||||
task.context_mode || 'isolated',
|
||||
task.next_run,
|
||||
task.status,
|
||||
task.created_at,
|
||||
);
|
||||
}
|
||||
|
||||
export function getTaskById(id: string): 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')
|
||||
.all(groupFolder) as ScheduledTask[];
|
||||
}
|
||||
|
||||
export function getAllTasks(): 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'>
|
||||
>,
|
||||
): void {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
||||
if (updates.prompt !== undefined) {
|
||||
fields.push('prompt = ?');
|
||||
values.push(updates.prompt);
|
||||
}
|
||||
if (updates.script !== undefined) {
|
||||
fields.push('script = ?');
|
||||
values.push(updates.script || null);
|
||||
}
|
||||
if (updates.schedule_type !== undefined) {
|
||||
fields.push('schedule_type = ?');
|
||||
values.push(updates.schedule_type);
|
||||
}
|
||||
if (updates.schedule_value !== undefined) {
|
||||
fields.push('schedule_value = ?');
|
||||
values.push(updates.schedule_value);
|
||||
}
|
||||
if (updates.next_run !== undefined) {
|
||||
fields.push('next_run = ?');
|
||||
values.push(updates.next_run);
|
||||
}
|
||||
if (updates.status !== undefined) {
|
||||
fields.push('status = ?');
|
||||
values.push(updates.status);
|
||||
}
|
||||
|
||||
if (fields.length === 0) return;
|
||||
|
||||
values.push(id);
|
||||
db.prepare(`UPDATE scheduled_tasks SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
}
|
||||
|
||||
export function deleteTask(id: string): void {
|
||||
// Delete child records first (FK constraint)
|
||||
db.prepare('DELETE FROM task_run_logs WHERE task_id = ?').run(id);
|
||||
db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
export function getDueTasks(): ScheduledTask[] {
|
||||
const now = new Date().toISOString();
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM scheduled_tasks
|
||||
WHERE status = 'active' AND next_run IS NOT NULL AND next_run <= ?
|
||||
ORDER BY next_run
|
||||
`,
|
||||
)
|
||||
.all(now) as ScheduledTask[];
|
||||
}
|
||||
|
||||
export function updateTaskAfterRun(id: string, nextRun: string | null, lastResult: string): void {
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE scheduled_tasks
|
||||
SET next_run = ?, last_run = ?, last_result = ?, status = CASE WHEN ? IS NULL THEN 'completed' ELSE status END
|
||||
WHERE id = ?
|
||||
`,
|
||||
).run(nextRun, now, lastResult, nextRun, id);
|
||||
}
|
||||
|
||||
export function logTaskRun(log: TaskRunLog): void {
|
||||
db.prepare(
|
||||
`
|
||||
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);
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
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);
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
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);
|
||||
}
|
||||
|
||||
export function deleteSession(groupFolder: string): void {
|
||||
db.prepare('DELETE FROM sessions WHERE group_folder = ?').run(groupFolder);
|
||||
}
|
||||
|
||||
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 result: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
result[row.group_folder] = row.session_id;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- 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
|
||||
| {
|
||||
jid: string;
|
||||
name: string;
|
||||
folder: string;
|
||||
trigger_pattern: string;
|
||||
added_at: string;
|
||||
container_config: string | null;
|
||||
requires_trigger: number | null;
|
||||
is_main: number | null;
|
||||
}
|
||||
| undefined;
|
||||
if (!row) return undefined;
|
||||
if (!isValidGroupFolder(row.folder)) {
|
||||
logger.warn({ jid: row.jid, folder: row.folder }, 'Skipping registered group with invalid folder');
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
jid: row.jid,
|
||||
name: row.name,
|
||||
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,
|
||||
isMain: row.is_main === 1 ? true : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
|
||||
if (!isValidGroupFolder(group.folder)) {
|
||||
throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`);
|
||||
}
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
jid,
|
||||
group.name,
|
||||
group.folder,
|
||||
group.trigger,
|
||||
group.added_at,
|
||||
group.containerConfig ? JSON.stringify(group.containerConfig) : null,
|
||||
group.requiresTrigger === undefined ? 1 : group.requiresTrigger ? 1 : 0,
|
||||
group.isMain ? 1 : 0,
|
||||
);
|
||||
}
|
||||
|
||||
export function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
|
||||
const rows = db.prepare('SELECT * FROM registered_groups').all() as Array<{
|
||||
jid: string;
|
||||
name: string;
|
||||
folder: string;
|
||||
trigger_pattern: string;
|
||||
added_at: string;
|
||||
container_config: string | null;
|
||||
requires_trigger: number | null;
|
||||
is_main: number | null;
|
||||
}>;
|
||||
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');
|
||||
continue;
|
||||
}
|
||||
result[row.jid] = {
|
||||
name: row.name,
|
||||
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,
|
||||
isMain: row.is_main === 1 ? true : undefined,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- JSON migration ---
|
||||
|
||||
function migrateJsonState(): void {
|
||||
const migrateFile = (filename: string) => {
|
||||
const filePath = path.join(DATA_DIR, filename);
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
fs.renameSync(filePath, `${filePath}.migrated`);
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Migrate router_state.json
|
||||
const routerState = migrateFile('router_state.json') as {
|
||||
last_timestamp?: string;
|
||||
last_agent_timestamp?: Record<string, string>;
|
||||
} | null;
|
||||
if (routerState) {
|
||||
if (routerState.last_timestamp) {
|
||||
setRouterState('last_timestamp', routerState.last_timestamp);
|
||||
}
|
||||
if (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;
|
||||
if (sessions) {
|
||||
for (const [folder, sessionId] of Object.entries(sessions)) {
|
||||
setSession(folder, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate registered_groups.json
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/**
|
||||
* Parse the .env file and return values for the requested keys.
|
||||
* Does NOT load anything into process.env — callers decide what to
|
||||
* do with the values. This keeps secrets out of the process environment
|
||||
* so they don't leak to child processes.
|
||||
*/
|
||||
export function readEnvFile(keys: string[]): Record<string, string> {
|
||||
const envFile = path.join(process.cwd(), '.env');
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(envFile, 'utf-8');
|
||||
} catch (err) {
|
||||
logger.debug({ err }, '.env file not found, using defaults');
|
||||
return {};
|
||||
}
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
const wanted = new Set(keys);
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIdx = trimmed.indexOf('=');
|
||||
if (eqIdx === -1) continue;
|
||||
const key = trimmed.slice(0, eqIdx).trim();
|
||||
if (!wanted.has(key)) continue;
|
||||
let value = trimmed.slice(eqIdx + 1).trim();
|
||||
if (
|
||||
value.length >= 2 &&
|
||||
((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (value) result[key] = value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
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 {
|
||||
return {
|
||||
id: '1',
|
||||
chat_jid: 'group@g.us',
|
||||
sender: '123@s.whatsapp.net',
|
||||
sender_name: 'Alice',
|
||||
content: 'hello',
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// --- escapeXml ---
|
||||
|
||||
describe('escapeXml', () => {
|
||||
it('escapes ampersands', () => {
|
||||
expect(escapeXml('a & b')).toBe('a & b');
|
||||
});
|
||||
|
||||
it('escapes less-than', () => {
|
||||
expect(escapeXml('a < b')).toBe('a < b');
|
||||
});
|
||||
|
||||
it('escapes greater-than', () => {
|
||||
expect(escapeXml('a > b')).toBe('a > b');
|
||||
});
|
||||
|
||||
it('escapes double quotes', () => {
|
||||
expect(escapeXml('"hello"')).toBe('"hello"');
|
||||
});
|
||||
|
||||
it('handles multiple special characters together', () => {
|
||||
expect(escapeXml('a & b < c > d "e"')).toBe('a & b < c > d "e"');
|
||||
});
|
||||
|
||||
it('passes through strings with no special chars', () => {
|
||||
expect(escapeXml('hello world')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(escapeXml('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// --- formatMessages ---
|
||||
|
||||
describe('formatMessages', () => {
|
||||
const TZ = 'UTC';
|
||||
|
||||
it('formats a single message as XML with context header', () => {
|
||||
const result = formatMessages([makeMsg()], TZ);
|
||||
expect(result).toContain('<context timezone="UTC" />');
|
||||
expect(result).toContain('<message sender="Alice"');
|
||||
expect(result).toContain('>hello</message>');
|
||||
expect(result).toContain('Jan 1, 2024');
|
||||
});
|
||||
|
||||
it('formats multiple messages', () => {
|
||||
const msgs = [
|
||||
makeMsg({
|
||||
id: '1',
|
||||
sender_name: 'Alice',
|
||||
content: 'hi',
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
}),
|
||||
makeMsg({
|
||||
id: '2',
|
||||
sender_name: 'Bob',
|
||||
content: 'hey',
|
||||
timestamp: '2024-01-01T01:00:00.000Z',
|
||||
}),
|
||||
];
|
||||
const result = formatMessages(msgs, TZ);
|
||||
expect(result).toContain('sender="Alice"');
|
||||
expect(result).toContain('sender="Bob"');
|
||||
expect(result).toContain('>hi</message>');
|
||||
expect(result).toContain('>hey</message>');
|
||||
});
|
||||
|
||||
it('escapes special characters in sender names', () => {
|
||||
const result = formatMessages([makeMsg({ sender_name: 'A & B <Co>' })], TZ);
|
||||
expect(result).toContain('sender="A & B <Co>"');
|
||||
});
|
||||
|
||||
it('escapes special characters in content', () => {
|
||||
const result = formatMessages([makeMsg({ content: '<script>alert("xss")</script>' })], TZ);
|
||||
expect(result).toContain('<script>alert("xss")</script>');
|
||||
});
|
||||
|
||||
it('handles empty array', () => {
|
||||
const result = formatMessages([], TZ);
|
||||
expect(result).toContain('<context timezone="UTC" />');
|
||||
expect(result).toContain('<messages>\n\n</messages>');
|
||||
});
|
||||
|
||||
it('renders reply context as quoted_message element', () => {
|
||||
const result = formatMessages(
|
||||
[
|
||||
makeMsg({
|
||||
content: 'Yes, on my way!',
|
||||
reply_to_message_id: '42',
|
||||
reply_to_message_content: 'Are you coming tonight?',
|
||||
reply_to_sender_name: 'Bob',
|
||||
}),
|
||||
],
|
||||
TZ,
|
||||
);
|
||||
expect(result).toContain('reply_to="42"');
|
||||
expect(result).toContain('<quoted_message from="Bob">Are you coming tonight?</quoted_message>');
|
||||
expect(result).toContain('Yes, on my way!</message>');
|
||||
});
|
||||
|
||||
it('omits reply attributes when no reply context', () => {
|
||||
const result = formatMessages([makeMsg()], TZ);
|
||||
expect(result).not.toContain('reply_to');
|
||||
expect(result).not.toContain('quoted_message');
|
||||
});
|
||||
|
||||
it('omits quoted_message when content is missing but id is present', () => {
|
||||
const result = formatMessages(
|
||||
[
|
||||
makeMsg({
|
||||
reply_to_message_id: '42',
|
||||
reply_to_sender_name: 'Bob',
|
||||
}),
|
||||
],
|
||||
TZ,
|
||||
);
|
||||
expect(result).toContain('reply_to="42"');
|
||||
expect(result).not.toContain('quoted_message');
|
||||
});
|
||||
|
||||
it('escapes special characters in reply context', () => {
|
||||
const result = formatMessages(
|
||||
[
|
||||
makeMsg({
|
||||
reply_to_message_id: '1',
|
||||
reply_to_message_content: '<script>alert("xss")</script>',
|
||||
reply_to_sender_name: 'A & B',
|
||||
}),
|
||||
],
|
||||
TZ,
|
||||
);
|
||||
expect(result).toContain('from="A & B"');
|
||||
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');
|
||||
expect(result).toContain('1:30');
|
||||
expect(result).toContain('PM');
|
||||
expect(result).toContain('<context timezone="America/New_York" />');
|
||||
});
|
||||
});
|
||||
|
||||
// --- TRIGGER_PATTERN ---
|
||||
|
||||
describe('TRIGGER_PATTERN', () => {
|
||||
const name = ASSISTANT_NAME;
|
||||
const lower = name.toLowerCase();
|
||||
const upper = name.toUpperCase();
|
||||
|
||||
it('matches @name at start of message', () => {
|
||||
expect(TRIGGER_PATTERN.test(`@${name} hello`)).toBe(true);
|
||||
});
|
||||
|
||||
it('matches case-insensitively', () => {
|
||||
expect(TRIGGER_PATTERN.test(`@${lower} hello`)).toBe(true);
|
||||
expect(TRIGGER_PATTERN.test(`@${upper} hello`)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match when not at start of message', () => {
|
||||
expect(TRIGGER_PATTERN.test(`hello @${name}`)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not match partial name like @NameExtra (word boundary)', () => {
|
||||
expect(TRIGGER_PATTERN.test(`@${name}extra hello`)).toBe(false);
|
||||
});
|
||||
|
||||
it('matches with word boundary before apostrophe', () => {
|
||||
expect(TRIGGER_PATTERN.test(`@${name}'s thing`)).toBe(true);
|
||||
});
|
||||
|
||||
it('matches @name alone (end of string is a word boundary)', () => {
|
||||
expect(TRIGGER_PATTERN.test(`@${name}`)).toBe(true);
|
||||
});
|
||||
|
||||
it('matches with leading whitespace after trim', () => {
|
||||
// The actual usage trims before testing: TRIGGER_PATTERN.test(m.content.trim())
|
||||
expect(TRIGGER_PATTERN.test(`@${name} hey`.trim())).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTriggerPattern', () => {
|
||||
it('uses the configured per-group trigger when provided', () => {
|
||||
const pattern = getTriggerPattern('@Claw');
|
||||
|
||||
expect(pattern.test('@Claw hello')).toBe(true);
|
||||
expect(pattern.test(`@${ASSISTANT_NAME} hello`)).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the default trigger when group trigger is missing', () => {
|
||||
const pattern = getTriggerPattern(undefined);
|
||||
|
||||
expect(pattern.test(`@${ASSISTANT_NAME} hello`)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats regex characters in custom triggers literally', () => {
|
||||
const pattern = getTriggerPattern('@C.L.A.U.D.E');
|
||||
|
||||
expect(pattern.test('@C.L.A.U.D.E hello')).toBe(true);
|
||||
expect(pattern.test('@CXLXAUXDXE hello')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Outbound formatting (internal tag stripping + prefix) ---
|
||||
|
||||
describe('stripInternalTags', () => {
|
||||
it('strips single-line internal tags', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
it('strips multiple internal tag blocks', () => {
|
||||
expect(stripInternalTags('<internal>a</internal>hello<internal>b</internal>')).toBe('hello');
|
||||
});
|
||||
|
||||
it('returns empty string when text is only internal tags', () => {
|
||||
expect(stripInternalTags('<internal>only this</internal>')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatOutbound', () => {
|
||||
it('returns text with internal tags stripped', () => {
|
||||
expect(formatOutbound('hello world')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('returns empty string when all text is internal', () => {
|
||||
expect(formatOutbound('<internal>hidden</internal>')).toBe('');
|
||||
});
|
||||
|
||||
it('strips internal tags from remaining text', () => {
|
||||
expect(formatOutbound('<internal>thinking</internal>The answer is 42')).toBe('The answer is 42');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Trigger gating with requiresTrigger flag ---
|
||||
|
||||
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 {
|
||||
return !isMainGroup && requiresTrigger !== false;
|
||||
}
|
||||
|
||||
function shouldProcess(
|
||||
isMainGroup: boolean,
|
||||
requiresTrigger: boolean | undefined,
|
||||
trigger: string | undefined,
|
||||
messages: NewMessage[],
|
||||
): boolean {
|
||||
if (!shouldRequireTrigger(isMainGroup, requiresTrigger)) return true;
|
||||
const triggerPattern = getTriggerPattern(trigger);
|
||||
return messages.some((m) => triggerPattern.test(m.content.trim()));
|
||||
}
|
||||
|
||||
it('main group always processes (no trigger needed)', () => {
|
||||
const msgs = [makeMsg({ content: 'hello no trigger' })];
|
||||
expect(shouldProcess(true, undefined, undefined, msgs)).toBe(true);
|
||||
});
|
||||
|
||||
it('main group processes even with requiresTrigger=true', () => {
|
||||
const msgs = [makeMsg({ content: 'hello no trigger' })];
|
||||
expect(shouldProcess(true, true, undefined, msgs)).toBe(true);
|
||||
});
|
||||
|
||||
it('non-main group with requiresTrigger=undefined requires trigger (defaults to true)', () => {
|
||||
const msgs = [makeMsg({ content: 'hello no trigger' })];
|
||||
expect(shouldProcess(false, undefined, undefined, msgs)).toBe(false);
|
||||
});
|
||||
|
||||
it('non-main group with requiresTrigger=true requires trigger', () => {
|
||||
const msgs = [makeMsg({ content: 'hello no trigger' })];
|
||||
expect(shouldProcess(false, true, undefined, msgs)).toBe(false);
|
||||
});
|
||||
|
||||
it('non-main group with requiresTrigger=true processes when trigger present', () => {
|
||||
const msgs = [makeMsg({ content: `@${ASSISTANT_NAME} do something` })];
|
||||
expect(shouldProcess(false, true, undefined, msgs)).toBe(true);
|
||||
});
|
||||
|
||||
it('non-main group uses its per-group trigger instead of the default trigger', () => {
|
||||
const msgs = [makeMsg({ content: '@Claw do something' })];
|
||||
expect(shouldProcess(false, true, '@Claw', msgs)).toBe(true);
|
||||
});
|
||||
|
||||
it('non-main group does not process when only the default trigger is present for a custom-trigger group', () => {
|
||||
const msgs = [makeMsg({ content: `@${ASSISTANT_NAME} do something` })];
|
||||
expect(shouldProcess(false, true, '@Claw', msgs)).toBe(false);
|
||||
});
|
||||
|
||||
it('non-main group with requiresTrigger=false always processes (no trigger needed)', () => {
|
||||
const msgs = [makeMsg({ content: 'hello no trigger' })];
|
||||
expect(shouldProcess(false, false, undefined, msgs)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import path from 'path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isValidGroupFolder, resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
|
||||
|
||||
describe('group folder validation', () => {
|
||||
it('accepts normal group folder names', () => {
|
||||
expect(isValidGroupFolder('main')).toBe(true);
|
||||
expect(isValidGroupFolder('family-chat')).toBe(true);
|
||||
expect(isValidGroupFolder('Team_42')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects traversal and reserved names', () => {
|
||||
expect(isValidGroupFolder('../../etc')).toBe(false);
|
||||
expect(isValidGroupFolder('/tmp')).toBe(false);
|
||||
expect(isValidGroupFolder('global')).toBe(false);
|
||||
expect(isValidGroupFolder('')).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves safe paths under groups directory', () => {
|
||||
const resolved = resolveGroupFolderPath('family-chat');
|
||||
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);
|
||||
});
|
||||
|
||||
it('throws for unsafe folder names', () => {
|
||||
expect(() => resolveGroupFolderPath('../../etc')).toThrow();
|
||||
expect(() => resolveGroupIpcPath('/tmp')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR, GROUPS_DIR } from './config.js';
|
||||
|
||||
const GROUP_FOLDER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
||||
const RESERVED_FOLDERS = new Set(['global']);
|
||||
|
||||
export function isValidGroupFolder(folder: string): boolean {
|
||||
if (!folder) return false;
|
||||
if (folder !== folder.trim()) return false;
|
||||
if (!GROUP_FOLDER_PATTERN.test(folder)) return false;
|
||||
if (folder.includes('/') || folder.includes('\\')) return false;
|
||||
if (folder.includes('..')) return false;
|
||||
if (RESERVED_FOLDERS.has(folder.toLowerCase())) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function assertValidGroupFolder(folder: string): void {
|
||||
if (!isValidGroupFolder(folder)) {
|
||||
throw new Error(`Invalid group folder "${folder}"`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureWithinBase(baseDir: string, resolvedPath: string): void {
|
||||
const rel = path.relative(baseDir, resolvedPath);
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||
throw new Error(`Path escapes base directory: ${resolvedPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveGroupFolderPath(folder: string): string {
|
||||
assertValidGroupFolder(folder);
|
||||
const groupPath = path.resolve(GROUPS_DIR, folder);
|
||||
ensureWithinBase(GROUPS_DIR, groupPath);
|
||||
return groupPath;
|
||||
}
|
||||
|
||||
export function resolveGroupIpcPath(folder: string): string {
|
||||
assertValidGroupFolder(folder);
|
||||
const ipcBaseDir = path.resolve(DATA_DIR, 'ipc');
|
||||
const ipcPath = path.resolve(ipcBaseDir, folder);
|
||||
ensureWithinBase(ipcBaseDir, ipcPath);
|
||||
return ipcPath;
|
||||
}
|
||||
@@ -1,457 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
|
||||
import { GroupQueue } from './group-queue.js';
|
||||
|
||||
// Mock config to control concurrency limit
|
||||
vi.mock('./config.js', () => ({
|
||||
DATA_DIR: '/tmp/nanoclaw-test-data',
|
||||
MAX_CONCURRENT_CONTAINERS: 2,
|
||||
}));
|
||||
|
||||
// Mock fs operations used by sendMessage/closeStdin
|
||||
vi.mock('fs', async () => {
|
||||
const actual = await vi.importActual<typeof import('fs')>('fs');
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual,
|
||||
mkdirSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('GroupQueue', () => {
|
||||
let queue: GroupQueue;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
queue = new GroupQueue();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// --- Single group at a time ---
|
||||
|
||||
it('only runs one container per group at a time', async () => {
|
||||
let concurrentCount = 0;
|
||||
let maxConcurrent = 0;
|
||||
|
||||
const processMessages = vi.fn(async (_groupJid: string) => {
|
||||
concurrentCount++;
|
||||
maxConcurrent = Math.max(maxConcurrent, concurrentCount);
|
||||
// Simulate async work
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
concurrentCount--;
|
||||
return true;
|
||||
});
|
||||
|
||||
queue.setProcessMessagesFn(processMessages);
|
||||
|
||||
// Enqueue two messages for the same group
|
||||
queue.enqueueMessageCheck('group1@g.us');
|
||||
queue.enqueueMessageCheck('group1@g.us');
|
||||
|
||||
// Advance timers to let the first process complete
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
// Second enqueue should have been queued, not concurrent
|
||||
expect(maxConcurrent).toBe(1);
|
||||
});
|
||||
|
||||
// --- Global concurrency limit ---
|
||||
|
||||
it('respects global concurrency limit', async () => {
|
||||
let activeCount = 0;
|
||||
let maxActive = 0;
|
||||
const completionCallbacks: Array<() => void> = [];
|
||||
|
||||
const processMessages = vi.fn(async (_groupJid: string) => {
|
||||
activeCount++;
|
||||
maxActive = Math.max(maxActive, activeCount);
|
||||
await new Promise<void>((resolve) => completionCallbacks.push(resolve));
|
||||
activeCount--;
|
||||
return true;
|
||||
});
|
||||
|
||||
queue.setProcessMessagesFn(processMessages);
|
||||
|
||||
// Enqueue 3 groups (limit is 2)
|
||||
queue.enqueueMessageCheck('group1@g.us');
|
||||
queue.enqueueMessageCheck('group2@g.us');
|
||||
queue.enqueueMessageCheck('group3@g.us');
|
||||
|
||||
// Let promises settle
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Only 2 should be active (MAX_CONCURRENT_CONTAINERS = 2)
|
||||
expect(maxActive).toBe(2);
|
||||
expect(activeCount).toBe(2);
|
||||
|
||||
// Complete one — third should start
|
||||
completionCallbacks[0]();
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(processMessages).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
// --- Tasks prioritized over messages ---
|
||||
|
||||
it('drains tasks before messages for same group', async () => {
|
||||
const executionOrder: string[] = [];
|
||||
let resolveFirst: () => void;
|
||||
|
||||
const processMessages = vi.fn(async (_groupJid: string) => {
|
||||
if (executionOrder.length === 0) {
|
||||
// First call: block until we release it
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
}
|
||||
executionOrder.push('messages');
|
||||
return true;
|
||||
});
|
||||
|
||||
queue.setProcessMessagesFn(processMessages);
|
||||
|
||||
// Start processing messages (takes the active slot)
|
||||
queue.enqueueMessageCheck('group1@g.us');
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// While active, enqueue both a task and pending messages
|
||||
const taskFn = vi.fn(async () => {
|
||||
executionOrder.push('task');
|
||||
});
|
||||
queue.enqueueTask('group1@g.us', 'task-1', taskFn);
|
||||
queue.enqueueMessageCheck('group1@g.us');
|
||||
|
||||
// Release the first processing
|
||||
resolveFirst!();
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Task should have run before the second message check
|
||||
expect(executionOrder[0]).toBe('messages'); // first call
|
||||
expect(executionOrder[1]).toBe('task'); // task runs first in drain
|
||||
// Messages would run after task completes
|
||||
});
|
||||
|
||||
// --- Retry with backoff on failure ---
|
||||
|
||||
it('retries with exponential backoff on failure', async () => {
|
||||
let callCount = 0;
|
||||
|
||||
const processMessages = vi.fn(async () => {
|
||||
callCount++;
|
||||
return false; // failure
|
||||
});
|
||||
|
||||
queue.setProcessMessagesFn(processMessages);
|
||||
queue.enqueueMessageCheck('group1@g.us');
|
||||
|
||||
// First call happens immediately
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
// First retry after 5000ms (BASE_RETRY_MS * 2^0)
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(callCount).toBe(2);
|
||||
|
||||
// Second retry after 10000ms (BASE_RETRY_MS * 2^1)
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(callCount).toBe(3);
|
||||
});
|
||||
|
||||
// --- Shutdown prevents new enqueues ---
|
||||
|
||||
it('prevents new enqueues after shutdown', async () => {
|
||||
const processMessages = vi.fn(async () => true);
|
||||
queue.setProcessMessagesFn(processMessages);
|
||||
|
||||
await queue.shutdown(1000);
|
||||
|
||||
queue.enqueueMessageCheck('group1@g.us');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(processMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- Max retries exceeded ---
|
||||
|
||||
it('stops retrying after MAX_RETRIES and resets', async () => {
|
||||
let callCount = 0;
|
||||
|
||||
const processMessages = vi.fn(async () => {
|
||||
callCount++;
|
||||
return false; // always fail
|
||||
});
|
||||
|
||||
queue.setProcessMessagesFn(processMessages);
|
||||
queue.enqueueMessageCheck('group1@g.us');
|
||||
|
||||
// Run through all 5 retries (MAX_RETRIES = 5)
|
||||
// Initial call
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
// Retry 1: 5000ms, Retry 2: 10000ms, Retry 3: 20000ms, Retry 4: 40000ms, Retry 5: 80000ms
|
||||
const retryDelays = [5000, 10000, 20000, 40000, 80000];
|
||||
for (let i = 0; i < retryDelays.length; i++) {
|
||||
await vi.advanceTimersByTimeAsync(retryDelays[i] + 10);
|
||||
expect(callCount).toBe(i + 2);
|
||||
}
|
||||
|
||||
// After 5 retries (6 total calls), should stop — no more retries
|
||||
const countAfterMaxRetries = callCount;
|
||||
await vi.advanceTimersByTimeAsync(200000); // Wait a long time
|
||||
expect(callCount).toBe(countAfterMaxRetries);
|
||||
});
|
||||
|
||||
// --- Waiting groups get drained when slots free up ---
|
||||
|
||||
it('drains waiting groups when active slots free up', async () => {
|
||||
const processed: string[] = [];
|
||||
const completionCallbacks: Array<() => void> = [];
|
||||
|
||||
const processMessages = vi.fn(async (groupJid: string) => {
|
||||
processed.push(groupJid);
|
||||
await new Promise<void>((resolve) => completionCallbacks.push(resolve));
|
||||
return true;
|
||||
});
|
||||
|
||||
queue.setProcessMessagesFn(processMessages);
|
||||
|
||||
// Fill both slots
|
||||
queue.enqueueMessageCheck('group1@g.us');
|
||||
queue.enqueueMessageCheck('group2@g.us');
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Queue a third
|
||||
queue.enqueueMessageCheck('group3@g.us');
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(processed).toEqual(['group1@g.us', 'group2@g.us']);
|
||||
|
||||
// Free up a slot
|
||||
completionCallbacks[0]();
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(processed).toContain('group3@g.us');
|
||||
});
|
||||
|
||||
// --- Running task dedup (Issue #138) ---
|
||||
|
||||
it('rejects duplicate enqueue of a currently-running task', async () => {
|
||||
let resolveTask: () => void;
|
||||
let taskCallCount = 0;
|
||||
|
||||
const taskFn = vi.fn(async () => {
|
||||
taskCallCount++;
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
});
|
||||
|
||||
// Start the task (runs immediately — slot available)
|
||||
queue.enqueueTask('group1@g.us', 'task-1', taskFn);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(taskCallCount).toBe(1);
|
||||
|
||||
// Scheduler poll re-discovers the same task while it's running —
|
||||
// this must be silently dropped
|
||||
const dupFn = vi.fn(async () => {});
|
||||
queue.enqueueTask('group1@g.us', 'task-1', dupFn);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Duplicate was NOT queued
|
||||
expect(dupFn).not.toHaveBeenCalled();
|
||||
|
||||
// Complete the original task
|
||||
resolveTask!();
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Only one execution total
|
||||
expect(taskCallCount).toBe(1);
|
||||
});
|
||||
|
||||
// --- Idle preemption ---
|
||||
|
||||
it('does NOT preempt active container when not idle', async () => {
|
||||
const fs = await import('fs');
|
||||
let resolveProcess: () => void;
|
||||
|
||||
const processMessages = vi.fn(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveProcess = resolve;
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
queue.setProcessMessagesFn(processMessages);
|
||||
|
||||
// Start processing (takes the active slot)
|
||||
queue.enqueueMessageCheck('group1@g.us');
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Register a process so closeStdin has a groupFolder
|
||||
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 () => {});
|
||||
queue.enqueueTask('group1@g.us', 'task-1', taskFn);
|
||||
|
||||
// _close should NOT have been written (container is working, not idle)
|
||||
const writeFileSync = vi.mocked(fs.default.writeFileSync);
|
||||
const closeWrites = writeFileSync.mock.calls.filter(
|
||||
(call) => typeof call[0] === 'string' && call[0].endsWith('_close'),
|
||||
);
|
||||
expect(closeWrites).toHaveLength(0);
|
||||
|
||||
resolveProcess!();
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
});
|
||||
|
||||
it('preempts idle container when task is enqueued', async () => {
|
||||
const fs = await import('fs');
|
||||
let resolveProcess: () => void;
|
||||
|
||||
const processMessages = vi.fn(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveProcess = resolve;
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
queue.setProcessMessagesFn(processMessages);
|
||||
|
||||
// Start processing
|
||||
queue.enqueueMessageCheck('group1@g.us');
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Register process and mark idle
|
||||
queue.registerProcess('group1@g.us', {} as any, 'container-1', 'test-group');
|
||||
queue.notifyIdle('group1@g.us');
|
||||
|
||||
// Clear previous writes, then enqueue a task
|
||||
const writeFileSync = vi.mocked(fs.default.writeFileSync);
|
||||
writeFileSync.mockClear();
|
||||
|
||||
const taskFn = vi.fn(async () => {});
|
||||
queue.enqueueTask('group1@g.us', 'task-1', taskFn);
|
||||
|
||||
// _close SHOULD have been written (container is idle)
|
||||
const closeWrites = writeFileSync.mock.calls.filter(
|
||||
(call) => typeof call[0] === 'string' && call[0].endsWith('_close'),
|
||||
);
|
||||
expect(closeWrites).toHaveLength(1);
|
||||
|
||||
resolveProcess!();
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
});
|
||||
|
||||
it('sendMessage resets idleWaiting so a subsequent task enqueue does not preempt', async () => {
|
||||
const fs = await import('fs');
|
||||
let resolveProcess: () => void;
|
||||
|
||||
const processMessages = vi.fn(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveProcess = resolve;
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
queue.setProcessMessagesFn(processMessages);
|
||||
queue.enqueueMessageCheck('group1@g.us');
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
queue.registerProcess('group1@g.us', {} as any, 'container-1', 'test-group');
|
||||
|
||||
// Container becomes idle
|
||||
queue.notifyIdle('group1@g.us');
|
||||
|
||||
// A new user message arrives — resets idleWaiting
|
||||
queue.sendMessage('group1@g.us', 'hello');
|
||||
|
||||
// Task enqueued after message reset — should NOT preempt (agent is working)
|
||||
const writeFileSync = vi.mocked(fs.default.writeFileSync);
|
||||
writeFileSync.mockClear();
|
||||
|
||||
const taskFn = vi.fn(async () => {});
|
||||
queue.enqueueTask('group1@g.us', 'task-1', taskFn);
|
||||
|
||||
const closeWrites = writeFileSync.mock.calls.filter(
|
||||
(call) => typeof call[0] === 'string' && call[0].endsWith('_close'),
|
||||
);
|
||||
expect(closeWrites).toHaveLength(0);
|
||||
|
||||
resolveProcess!();
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
});
|
||||
|
||||
it('sendMessage returns false for task containers so user messages queue up', async () => {
|
||||
let resolveTask: () => void;
|
||||
|
||||
const taskFn = vi.fn(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
});
|
||||
|
||||
// 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');
|
||||
|
||||
// sendMessage should return false — user messages must not go to task containers
|
||||
const result = queue.sendMessage('group1@g.us', 'hello');
|
||||
expect(result).toBe(false);
|
||||
|
||||
resolveTask!();
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
});
|
||||
|
||||
it('preempts when idle arrives with pending tasks', async () => {
|
||||
const fs = await import('fs');
|
||||
let resolveProcess: () => void;
|
||||
|
||||
const processMessages = vi.fn(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveProcess = resolve;
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
queue.setProcessMessagesFn(processMessages);
|
||||
|
||||
// Start processing
|
||||
queue.enqueueMessageCheck('group1@g.us');
|
||||
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');
|
||||
|
||||
const writeFileSync = vi.mocked(fs.default.writeFileSync);
|
||||
writeFileSync.mockClear();
|
||||
|
||||
const taskFn = vi.fn(async () => {});
|
||||
queue.enqueueTask('group1@g.us', 'task-1', taskFn);
|
||||
|
||||
let closeWrites = writeFileSync.mock.calls.filter(
|
||||
(call) => typeof call[0] === 'string' && call[0].endsWith('_close'),
|
||||
);
|
||||
expect(closeWrites).toHaveLength(0);
|
||||
|
||||
// Now container becomes idle — should preempt because task is pending
|
||||
writeFileSync.mockClear();
|
||||
queue.notifyIdle('group1@g.us');
|
||||
|
||||
closeWrites = writeFileSync.mock.calls.filter((call) => typeof call[0] === 'string' && call[0].endsWith('_close'));
|
||||
expect(closeWrites).toHaveLength(1);
|
||||
|
||||
resolveProcess!();
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
});
|
||||
});
|
||||
@@ -1,325 +0,0 @@
|
||||
import { ChildProcess } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR, MAX_CONCURRENT_CONTAINERS } from './config.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
interface QueuedTask {
|
||||
id: string;
|
||||
groupJid: string;
|
||||
fn: () => Promise<void>;
|
||||
}
|
||||
|
||||
const MAX_RETRIES = 5;
|
||||
const BASE_RETRY_MS = 5000;
|
||||
|
||||
interface GroupState {
|
||||
active: boolean;
|
||||
idleWaiting: boolean;
|
||||
isTaskContainer: boolean;
|
||||
runningTaskId: string | null;
|
||||
pendingMessages: boolean;
|
||||
pendingTasks: QueuedTask[];
|
||||
process: ChildProcess | null;
|
||||
containerName: string | null;
|
||||
groupFolder: string | null;
|
||||
retryCount: number;
|
||||
}
|
||||
|
||||
export class GroupQueue {
|
||||
private groups = new Map<string, GroupState>();
|
||||
private activeCount = 0;
|
||||
private waitingGroups: string[] = [];
|
||||
private processMessagesFn: ((groupJid: string) => Promise<boolean>) | null = null;
|
||||
private shuttingDown = false;
|
||||
|
||||
private getGroup(groupJid: string): GroupState {
|
||||
let state = this.groups.get(groupJid);
|
||||
if (!state) {
|
||||
state = {
|
||||
active: false,
|
||||
idleWaiting: false,
|
||||
isTaskContainer: false,
|
||||
runningTaskId: null,
|
||||
pendingMessages: false,
|
||||
pendingTasks: [],
|
||||
process: null,
|
||||
containerName: null,
|
||||
groupFolder: null,
|
||||
retryCount: 0,
|
||||
};
|
||||
this.groups.set(groupJid, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
setProcessMessagesFn(fn: (groupJid: string) => Promise<boolean>): void {
|
||||
this.processMessagesFn = fn;
|
||||
}
|
||||
|
||||
enqueueMessageCheck(groupJid: string): void {
|
||||
if (this.shuttingDown) return;
|
||||
|
||||
const state = this.getGroup(groupJid);
|
||||
|
||||
if (state.active) {
|
||||
state.pendingMessages = true;
|
||||
logger.debug({ groupJid }, 'Container active, message queued');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeCount >= MAX_CONCURRENT_CONTAINERS) {
|
||||
state.pendingMessages = true;
|
||||
if (!this.waitingGroups.includes(groupJid)) {
|
||||
this.waitingGroups.push(groupJid);
|
||||
}
|
||||
logger.debug({ groupJid, activeCount: this.activeCount }, 'At concurrency limit, message queued');
|
||||
return;
|
||||
}
|
||||
|
||||
this.runForGroup(groupJid, 'messages').catch((err) =>
|
||||
logger.error({ groupJid, err }, 'Unhandled error in runForGroup'),
|
||||
);
|
||||
}
|
||||
|
||||
enqueueTask(groupJid: string, taskId: string, fn: () => Promise<void>): void {
|
||||
if (this.shuttingDown) return;
|
||||
|
||||
const state = this.getGroup(groupJid);
|
||||
|
||||
// Prevent double-queuing: check both pending and currently-running task
|
||||
if (state.runningTaskId === taskId) {
|
||||
logger.debug({ groupJid, taskId }, 'Task already running, skipping');
|
||||
return;
|
||||
}
|
||||
if (state.pendingTasks.some((t) => t.id === taskId)) {
|
||||
logger.debug({ groupJid, taskId }, 'Task already queued, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.active) {
|
||||
state.pendingTasks.push({ id: taskId, groupJid, fn });
|
||||
if (state.idleWaiting) {
|
||||
this.closeStdin(groupJid);
|
||||
}
|
||||
logger.debug({ groupJid, taskId }, 'Container active, task queued');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeCount >= MAX_CONCURRENT_CONTAINERS) {
|
||||
state.pendingTasks.push({ id: taskId, groupJid, fn });
|
||||
if (!this.waitingGroups.includes(groupJid)) {
|
||||
this.waitingGroups.push(groupJid);
|
||||
}
|
||||
logger.debug({ groupJid, taskId, activeCount: this.activeCount }, 'At concurrency limit, task queued');
|
||||
return;
|
||||
}
|
||||
|
||||
// Run immediately
|
||||
this.runTask(groupJid, { id: taskId, groupJid, fn }).catch((err) =>
|
||||
logger.error({ groupJid, taskId, err }, 'Unhandled error in runTask'),
|
||||
);
|
||||
}
|
||||
|
||||
registerProcess(groupJid: string, proc: ChildProcess, containerName: string, groupFolder?: string): void {
|
||||
const state = this.getGroup(groupJid);
|
||||
state.process = proc;
|
||||
state.containerName = containerName;
|
||||
if (groupFolder) state.groupFolder = groupFolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the container as idle-waiting (finished work, waiting for IPC input).
|
||||
* If tasks are pending, preempt the idle container immediately.
|
||||
*/
|
||||
notifyIdle(groupJid: string): void {
|
||||
const state = this.getGroup(groupJid);
|
||||
state.idleWaiting = true;
|
||||
if (state.pendingTasks.length > 0) {
|
||||
this.closeStdin(groupJid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a follow-up message to the active container via IPC file.
|
||||
* Returns true if the message was written, false if no active container.
|
||||
*/
|
||||
sendMessage(groupJid: string, text: string): boolean {
|
||||
const state = this.getGroup(groupJid);
|
||||
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');
|
||||
try {
|
||||
fs.mkdirSync(inputDir, { recursive: true });
|
||||
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}.json`;
|
||||
const filepath = path.join(inputDir, filename);
|
||||
const tempPath = `${filepath}.tmp`;
|
||||
fs.writeFileSync(tempPath, JSON.stringify({ type: 'message', text }));
|
||||
fs.renameSync(tempPath, filepath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal the active container to wind down by writing a close sentinel.
|
||||
*/
|
||||
closeStdin(groupJid: string): void {
|
||||
const state = this.getGroup(groupJid);
|
||||
if (!state.active || !state.groupFolder) return;
|
||||
|
||||
const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input');
|
||||
try {
|
||||
fs.mkdirSync(inputDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(inputDir, '_close'), '');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private async runForGroup(groupJid: string, reason: 'messages' | 'drain'): Promise<void> {
|
||||
const state = this.getGroup(groupJid);
|
||||
state.active = true;
|
||||
state.idleWaiting = false;
|
||||
state.isTaskContainer = false;
|
||||
state.pendingMessages = false;
|
||||
this.activeCount++;
|
||||
|
||||
logger.debug({ groupJid, reason, activeCount: this.activeCount }, 'Starting container for group');
|
||||
|
||||
try {
|
||||
if (this.processMessagesFn) {
|
||||
const success = await this.processMessagesFn(groupJid);
|
||||
if (success) {
|
||||
state.retryCount = 0;
|
||||
} else {
|
||||
this.scheduleRetry(groupJid, state);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ groupJid, err }, 'Error processing messages for group');
|
||||
this.scheduleRetry(groupJid, state);
|
||||
} finally {
|
||||
state.active = false;
|
||||
state.process = null;
|
||||
state.containerName = null;
|
||||
state.groupFolder = null;
|
||||
this.activeCount--;
|
||||
this.drainGroup(groupJid);
|
||||
}
|
||||
}
|
||||
|
||||
private async runTask(groupJid: string, task: QueuedTask): Promise<void> {
|
||||
const state = this.getGroup(groupJid);
|
||||
state.active = true;
|
||||
state.idleWaiting = false;
|
||||
state.isTaskContainer = true;
|
||||
state.runningTaskId = task.id;
|
||||
this.activeCount++;
|
||||
|
||||
logger.debug({ groupJid, taskId: task.id, activeCount: this.activeCount }, 'Running queued task');
|
||||
|
||||
try {
|
||||
await task.fn();
|
||||
} catch (err) {
|
||||
logger.error({ groupJid, taskId: task.id, err }, 'Error running task');
|
||||
} finally {
|
||||
state.active = false;
|
||||
state.isTaskContainer = false;
|
||||
state.runningTaskId = null;
|
||||
state.process = null;
|
||||
state.containerName = null;
|
||||
state.groupFolder = null;
|
||||
this.activeCount--;
|
||||
this.drainGroup(groupJid);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRetry(groupJid: string, state: GroupState): void {
|
||||
state.retryCount++;
|
||||
if (state.retryCount > MAX_RETRIES) {
|
||||
logger.error(
|
||||
{ groupJid, retryCount: state.retryCount },
|
||||
'Max retries exceeded, dropping messages (will retry on next incoming message)',
|
||||
);
|
||||
state.retryCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const delayMs = BASE_RETRY_MS * Math.pow(2, state.retryCount - 1);
|
||||
logger.info({ groupJid, retryCount: state.retryCount, delayMs }, 'Scheduling retry with backoff');
|
||||
setTimeout(() => {
|
||||
if (!this.shuttingDown) {
|
||||
this.enqueueMessageCheck(groupJid);
|
||||
}
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
private drainGroup(groupJid: string): void {
|
||||
if (this.shuttingDown) return;
|
||||
|
||||
const state = this.getGroup(groupJid);
|
||||
|
||||
// Tasks first (they won't be re-discovered from SQLite like messages)
|
||||
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)'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Then pending messages
|
||||
if (state.pendingMessages) {
|
||||
this.runForGroup(groupJid, 'drain').catch((err) =>
|
||||
logger.error({ groupJid, err }, 'Unhandled error in runForGroup (drain)'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Nothing pending for this group; check if other groups are waiting for a slot
|
||||
this.drainWaiting();
|
||||
}
|
||||
|
||||
private drainWaiting(): void {
|
||||
while (this.waitingGroups.length > 0 && this.activeCount < MAX_CONCURRENT_CONTAINERS) {
|
||||
const nextJid = this.waitingGroups.shift()!;
|
||||
const state = this.getGroup(nextJid);
|
||||
|
||||
// Prioritize tasks over messages
|
||||
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)'),
|
||||
);
|
||||
} else if (state.pendingMessages) {
|
||||
this.runForGroup(nextJid, 'drain').catch((err) =>
|
||||
logger.error({ groupJid: nextJid, err }, 'Unhandled error in runForGroup (waiting)'),
|
||||
);
|
||||
}
|
||||
// If neither pending, skip this group
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown(_gracePeriodMs: number): Promise<void> {
|
||||
this.shuttingDown = true;
|
||||
|
||||
// Count active containers but don't kill them — they'll finish on their own
|
||||
// via idle timeout or container timeout. The --rm flag cleans them up on exit.
|
||||
// This prevents WhatsApp reconnection restarts from killing working agents.
|
||||
const activeContainers: string[] = [];
|
||||
for (const [_jid, state] of this.groups) {
|
||||
if (state.process && !state.process.killed && state.containerName) {
|
||||
activeContainers.push(state.containerName);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ activeCount: this.activeCount, detachedContainers: activeContainers },
|
||||
'GroupQueue shutting down (containers detached, not killed)',
|
||||
);
|
||||
}
|
||||
}
|
||||
647
src/v1/index.ts
647
src/v1/index.ts
@@ -1,647 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { OneCLI } from '@onecli-sh/sdk';
|
||||
|
||||
import {
|
||||
ASSISTANT_NAME,
|
||||
DEFAULT_TRIGGER,
|
||||
getTriggerPattern,
|
||||
GROUPS_DIR,
|
||||
IDLE_TIMEOUT,
|
||||
MAX_MESSAGES_PER_PROMPT,
|
||||
ONECLI_URL,
|
||||
POLL_INTERVAL,
|
||||
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 {
|
||||
getAllChats,
|
||||
getAllRegisteredGroups,
|
||||
getAllSessions,
|
||||
deleteSession,
|
||||
getAllTasks,
|
||||
getLastBotMessageTimestamp,
|
||||
getMessagesSince,
|
||||
getNewMessages,
|
||||
getRouterState,
|
||||
initDatabase,
|
||||
setRegisteredGroup,
|
||||
setRouterState,
|
||||
setSession,
|
||||
storeChatMetadata,
|
||||
storeMessage,
|
||||
} from './db.js';
|
||||
import { GroupQueue } from './group-queue.js';
|
||||
import { resolveGroupFolderPath } from './group-folder.js';
|
||||
import { startIpcWatcher } from './ipc.js';
|
||||
import { findChannel, formatMessages, formatOutbound } from './router.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';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
// Re-export for backwards compatibility during refactor
|
||||
export { escapeXml, formatMessages } from './router.js';
|
||||
|
||||
let lastTimestamp = '';
|
||||
let sessions: Record<string, string> = {};
|
||||
let registeredGroups: Record<string, RegisteredGroup> = {};
|
||||
let lastAgentTimestamp: Record<string, string> = {};
|
||||
let messageLoopRunning = false;
|
||||
|
||||
const channels: Channel[] = [];
|
||||
const queue = new GroupQueue();
|
||||
|
||||
const onecli = new OneCLI({ url: ONECLI_URL });
|
||||
|
||||
function ensureOneCLIAgent(jid: string, group: RegisteredGroup): void {
|
||||
if (group.isMain) return;
|
||||
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');
|
||||
},
|
||||
(err) => {
|
||||
logger.debug({ jid, identifier, err: String(err) }, 'OneCLI agent ensure skipped');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function loadState(): void {
|
||||
lastTimestamp = getRouterState('last_timestamp') || '';
|
||||
const agentTs = getRouterState('last_agent_timestamp');
|
||||
try {
|
||||
lastAgentTimestamp = agentTs ? JSON.parse(agentTs) : {};
|
||||
} catch {
|
||||
logger.warn('Corrupted last_agent_timestamp in DB, resetting');
|
||||
lastAgentTimestamp = {};
|
||||
}
|
||||
sessions = getAllSessions();
|
||||
registeredGroups = getAllRegisteredGroups();
|
||||
logger.info({ groupCount: Object.keys(registeredGroups).length }, 'State loaded');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the message cursor for a group, recovering from the last bot reply
|
||||
* if lastAgentTimestamp is missing (new group, corrupted state, restart).
|
||||
*/
|
||||
function getOrRecoverCursor(chatJid: string): string {
|
||||
const existing = lastAgentTimestamp[chatJid];
|
||||
if (existing) return existing;
|
||||
|
||||
const botTs = getLastBotMessageTimestamp(chatJid, ASSISTANT_NAME);
|
||||
if (botTs) {
|
||||
logger.info({ chatJid, recoveredFrom: botTs }, 'Recovered message cursor from last bot reply');
|
||||
lastAgentTimestamp[chatJid] = botTs;
|
||||
saveState();
|
||||
return botTs;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function saveState(): void {
|
||||
setRouterState('last_timestamp', lastTimestamp);
|
||||
setRouterState('last_agent_timestamp', JSON.stringify(lastAgentTimestamp));
|
||||
}
|
||||
|
||||
function registerGroup(jid: string, group: RegisteredGroup): void {
|
||||
let groupDir: string;
|
||||
try {
|
||||
groupDir = resolveGroupFolderPath(group.folder);
|
||||
} catch (err) {
|
||||
logger.warn({ jid, folder: group.folder, err }, 'Rejecting group registration with invalid folder');
|
||||
return;
|
||||
}
|
||||
|
||||
registeredGroups[jid] = group;
|
||||
setRegisteredGroup(jid, group);
|
||||
|
||||
// Create group folder
|
||||
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
|
||||
|
||||
// Copy CLAUDE.md template into the new group folder so agents have
|
||||
// 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');
|
||||
if (fs.existsSync(templateFile)) {
|
||||
let content = fs.readFileSync(templateFile, 'utf-8');
|
||||
if (ASSISTANT_NAME !== 'Andy') {
|
||||
content = content.replace(/^# Andy$/m, `# ${ASSISTANT_NAME}`);
|
||||
content = content.replace(/You are Andy/g, `You are ${ASSISTANT_NAME}`);
|
||||
}
|
||||
fs.writeFileSync(groupMdFile, content);
|
||||
logger.info({ folder: group.folder }, 'Created CLAUDE.md from template');
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure a corresponding OneCLI agent exists (best-effort, non-blocking)
|
||||
ensureOneCLIAgent(jid, group);
|
||||
|
||||
logger.info({ jid, name: group.name, folder: group.folder }, 'Group registered');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available groups list for the agent.
|
||||
* Returns groups ordered by most recent activity.
|
||||
*/
|
||||
export function getAvailableGroups(): import('./container-runner.js').AvailableGroup[] {
|
||||
const chats = getAllChats();
|
||||
const registeredJids = new Set(Object.keys(registeredGroups));
|
||||
|
||||
return chats
|
||||
.filter((c) => c.jid !== '__group_sync__' && c.is_group)
|
||||
.map((c) => ({
|
||||
jid: c.jid,
|
||||
name: c.name,
|
||||
lastActivity: c.last_message_time,
|
||||
isRegistered: registeredJids.has(c.jid),
|
||||
}));
|
||||
}
|
||||
|
||||
/** @internal - exported for testing */
|
||||
export function _setRegisteredGroups(groups: Record<string, RegisteredGroup>): void {
|
||||
registeredGroups = groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all pending messages for a group.
|
||||
* Called by the GroupQueue when it's this group's turn.
|
||||
*/
|
||||
async function processGroupMessages(chatJid: string): Promise<boolean> {
|
||||
const group = registeredGroups[chatJid];
|
||||
if (!group) return true;
|
||||
|
||||
const channel = findChannel(channels, chatJid);
|
||||
if (!channel) {
|
||||
logger.warn({ chatJid }, 'No channel owns JID, skipping messages');
|
||||
return true;
|
||||
}
|
||||
|
||||
const isMainGroup = group.isMain === true;
|
||||
|
||||
const missedMessages = getMessagesSince(
|
||||
chatJid,
|
||||
getOrRecoverCursor(chatJid),
|
||||
ASSISTANT_NAME,
|
||||
MAX_MESSAGES_PER_PROMPT,
|
||||
);
|
||||
|
||||
if (missedMessages.length === 0) return true;
|
||||
|
||||
// For non-main groups, check if trigger is required and present
|
||||
if (!isMainGroup && group.requiresTrigger !== false) {
|
||||
const triggerPattern = getTriggerPattern(group.trigger);
|
||||
const allowlistCfg = loadSenderAllowlist();
|
||||
const hasTrigger = missedMessages.some(
|
||||
(m) =>
|
||||
triggerPattern.test(m.content.trim()) && (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
|
||||
);
|
||||
if (!hasTrigger) return true;
|
||||
}
|
||||
|
||||
const prompt = formatMessages(missedMessages, TIMEZONE);
|
||||
|
||||
// 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;
|
||||
saveState();
|
||||
|
||||
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;
|
||||
|
||||
const resetIdleTimer = () => {
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => {
|
||||
logger.debug({ group: group.name }, 'Idle timeout, closing container stdin');
|
||||
queue.closeStdin(chatJid);
|
||||
}, IDLE_TIMEOUT);
|
||||
};
|
||||
|
||||
await channel.setTyping?.(chatJid, true);
|
||||
let hadError = false;
|
||||
let outputSentToUser = false;
|
||||
|
||||
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);
|
||||
// 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`);
|
||||
if (text) {
|
||||
await channel.sendMessage(chatJid, text);
|
||||
outputSentToUser = true;
|
||||
}
|
||||
// Only reset idle timer on actual results, not session-update markers (result: null)
|
||||
resetIdleTimer();
|
||||
}
|
||||
|
||||
if (result.status === 'success') {
|
||||
queue.notifyIdle(chatJid);
|
||||
}
|
||||
|
||||
if (result.status === 'error') {
|
||||
hadError = true;
|
||||
}
|
||||
});
|
||||
|
||||
await channel.setTyping?.(chatJid, false);
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
|
||||
if (output === 'error' || hadError) {
|
||||
// If we already sent output to the user, don't roll back the cursor —
|
||||
// the user got their response and re-processing would send duplicates.
|
||||
if (outputSentToUser) {
|
||||
logger.warn(
|
||||
{ group: group.name },
|
||||
'Agent error after output was sent, skipping cursor rollback to prevent duplicates',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
// 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');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function runAgent(
|
||||
group: RegisteredGroup,
|
||||
prompt: string,
|
||||
chatJid: string,
|
||||
onOutput?: (output: ContainerOutput) => Promise<void>,
|
||||
): Promise<'success' | 'error'> {
|
||||
const isMain = group.isMain === true;
|
||||
const sessionId = sessions[group.folder];
|
||||
|
||||
// Update tasks snapshot for container to read (filtered by group)
|
||||
const tasks = getAllTasks();
|
||||
writeTasksSnapshot(
|
||||
group.folder,
|
||||
isMain,
|
||||
tasks.map((t) => ({
|
||||
id: t.id,
|
||||
groupFolder: t.group_folder,
|
||||
prompt: t.prompt,
|
||||
script: t.script || undefined,
|
||||
schedule_type: t.schedule_type,
|
||||
schedule_value: t.schedule_value,
|
||||
status: t.status,
|
||||
next_run: t.next_run,
|
||||
})),
|
||||
);
|
||||
|
||||
// Update available groups snapshot (main group only can see all groups)
|
||||
const availableGroups = getAvailableGroups();
|
||||
writeGroupsSnapshot(group.folder, isMain, availableGroups, new Set(Object.keys(registeredGroups)));
|
||||
|
||||
// Wrap onOutput to track session ID from streamed results
|
||||
const wrappedOnOutput = onOutput
|
||||
? async (output: ContainerOutput) => {
|
||||
if (output.newSessionId) {
|
||||
sessions[group.folder] = output.newSessionId;
|
||||
setSession(group.folder, output.newSessionId);
|
||||
}
|
||||
await onOutput(output);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const output = await runContainerAgent(
|
||||
group,
|
||||
{
|
||||
prompt,
|
||||
sessionId,
|
||||
groupFolder: group.folder,
|
||||
chatJid,
|
||||
isMain,
|
||||
assistantName: ASSISTANT_NAME,
|
||||
},
|
||||
(proc, containerName) => queue.registerProcess(chatJid, proc, containerName, group.folder),
|
||||
wrappedOnOutput,
|
||||
);
|
||||
|
||||
if (output.newSessionId) {
|
||||
sessions[group.folder] = output.newSessionId;
|
||||
setSession(group.folder, output.newSessionId);
|
||||
}
|
||||
|
||||
if (output.status === 'error') {
|
||||
// Detect stale/corrupt session — clear it so the next retry starts fresh.
|
||||
// The session .jsonl can go missing after a crash mid-write, manual
|
||||
// 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);
|
||||
|
||||
if (isStaleSession) {
|
||||
logger.warn(
|
||||
{ group: group.name, staleSessionId: sessionId, error: output.error },
|
||||
'Stale session detected — clearing for next retry',
|
||||
);
|
||||
delete sessions[group.folder];
|
||||
deleteSession(group.folder);
|
||||
}
|
||||
|
||||
logger.error({ group: group.name, error: output.error }, 'Container agent error');
|
||||
return 'error';
|
||||
}
|
||||
|
||||
return 'success';
|
||||
} catch (err) {
|
||||
logger.error({ group: group.name, err }, 'Agent error');
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
|
||||
async function startMessageLoop(): Promise<void> {
|
||||
if (messageLoopRunning) {
|
||||
logger.debug('Message loop already running, skipping duplicate start');
|
||||
return;
|
||||
}
|
||||
messageLoopRunning = true;
|
||||
|
||||
logger.info(`NanoClaw running (default trigger: ${DEFAULT_TRIGGER})`);
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const jids = Object.keys(registeredGroups);
|
||||
const { messages, newTimestamp } = getNewMessages(jids, lastTimestamp, ASSISTANT_NAME);
|
||||
|
||||
if (messages.length > 0) {
|
||||
logger.info({ count: messages.length }, 'New messages');
|
||||
|
||||
// Advance the "seen" cursor for all messages immediately
|
||||
lastTimestamp = newTimestamp;
|
||||
saveState();
|
||||
|
||||
// Deduplicate by group
|
||||
const messagesByGroup = new Map<string, NewMessage[]>();
|
||||
for (const msg of messages) {
|
||||
const existing = messagesByGroup.get(msg.chat_jid);
|
||||
if (existing) {
|
||||
existing.push(msg);
|
||||
} else {
|
||||
messagesByGroup.set(msg.chat_jid, [msg]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [chatJid, groupMessages] of messagesByGroup) {
|
||||
const group = registeredGroups[chatJid];
|
||||
if (!group) continue;
|
||||
|
||||
const channel = findChannel(channels, chatJid);
|
||||
if (!channel) {
|
||||
logger.warn({ chatJid }, 'No channel owns JID, skipping messages');
|
||||
continue;
|
||||
}
|
||||
|
||||
const isMainGroup = group.isMain === true;
|
||||
const needsTrigger = !isMainGroup && group.requiresTrigger !== false;
|
||||
|
||||
// For non-main groups, only act on trigger messages.
|
||||
// Non-trigger messages accumulate in DB and get pulled as
|
||||
// context when a trigger eventually arrives.
|
||||
if (needsTrigger) {
|
||||
const triggerPattern = getTriggerPattern(group.trigger);
|
||||
const allowlistCfg = loadSenderAllowlist();
|
||||
const hasTrigger = groupMessages.some(
|
||||
(m) =>
|
||||
triggerPattern.test(m.content.trim()) &&
|
||||
(m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
|
||||
);
|
||||
if (!hasTrigger) continue;
|
||||
}
|
||||
|
||||
// Pull all messages since lastAgentTimestamp so non-trigger
|
||||
// context that accumulated between triggers is included.
|
||||
const allPending = getMessagesSince(
|
||||
chatJid,
|
||||
getOrRecoverCursor(chatJid),
|
||||
ASSISTANT_NAME,
|
||||
MAX_MESSAGES_PER_PROMPT,
|
||||
);
|
||||
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;
|
||||
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'));
|
||||
} else {
|
||||
// No active container — enqueue for a new one
|
||||
queue.enqueueMessageCheck(chatJid);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Error in message loop');
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup recovery: check for unprocessed messages in registered groups.
|
||||
* Handles crash between advancing lastTimestamp and processing messages.
|
||||
*/
|
||||
function recoverPendingMessages(): void {
|
||||
for (const [chatJid, group] of Object.entries(registeredGroups)) {
|
||||
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');
|
||||
queue.enqueueMessageCheck(chatJid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureContainerSystemRunning(): void {
|
||||
ensureContainerRuntimeRunning();
|
||||
cleanupOrphans();
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
ensureContainerSystemRunning();
|
||||
initDatabase();
|
||||
logger.info('Database initialized');
|
||||
loadState();
|
||||
|
||||
// Ensure OneCLI agents exist for all registered groups.
|
||||
// Recovers from missed creates (e.g. OneCLI was down at registration time).
|
||||
for (const [jid, group] of Object.entries(registeredGroups)) {
|
||||
ensureOneCLIAgent(jid, group);
|
||||
}
|
||||
|
||||
restoreRemoteControl();
|
||||
|
||||
// Graceful shutdown handlers
|
||||
const shutdown = async (signal: string) => {
|
||||
logger.info({ signal }, 'Shutdown signal received');
|
||||
await queue.shutdown(10000);
|
||||
for (const ch of channels) await ch.disconnect();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
// Handle /remote-control and /remote-control-end commands
|
||||
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');
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = findChannel(channels, chatJid);
|
||||
if (!channel) return;
|
||||
|
||||
if (command === '/remote-control') {
|
||||
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}`);
|
||||
}
|
||||
} else {
|
||||
const result = stopRemoteControl();
|
||||
if (result.ok) {
|
||||
await channel.sendMessage(chatJid, 'Remote Control session ended.');
|
||||
} else {
|
||||
await channel.sendMessage(chatJid, result.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Channel callbacks (shared by all channels)
|
||||
const channelOpts = {
|
||||
onMessage: (chatJid: string, msg: NewMessage) => {
|
||||
// Remote control commands — intercept before storage
|
||||
const trimmed = msg.content.trim();
|
||||
if (trimmed === '/remote-control' || trimmed === '/remote-control-end') {
|
||||
handleRemoteControl(trimmed, chatJid, msg).catch((err) =>
|
||||
logger.error({ err, chatJid }, 'Remote control command error'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sender allowlist drop mode: discard messages from denied senders before storing
|
||||
if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) {
|
||||
const cfg = loadSenderAllowlist();
|
||||
if (shouldDropMessage(chatJid, cfg) && !isSenderAllowed(chatJid, msg.sender, cfg)) {
|
||||
if (cfg.logDenied) {
|
||||
logger.debug({ chatJid, sender: msg.sender }, 'sender-allowlist: dropping message (drop mode)');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
storeMessage(msg);
|
||||
},
|
||||
onChatMetadata: (chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean) =>
|
||||
storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
|
||||
registeredGroups: () => registeredGroups,
|
||||
};
|
||||
|
||||
// Create and connect all registered channels.
|
||||
// Each channel self-registers via the barrel import above.
|
||||
// Factories return null when credentials are missing, so unconfigured channels are skipped.
|
||||
for (const channelName of getRegisteredChannelNames()) {
|
||||
const factory = getChannelFactory(channelName)!;
|
||||
const channel = factory(channelOpts);
|
||||
if (!channel) {
|
||||
logger.warn(
|
||||
{ channel: channelName },
|
||||
'Channel installed but credentials missing — skipping. Check .env or re-run the channel skill.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
channels.push(channel);
|
||||
await channel.connect();
|
||||
}
|
||||
if (channels.length === 0) {
|
||||
logger.fatal('No channels connected');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Start subsystems (independently of connection handler)
|
||||
startSchedulerLoop({
|
||||
registeredGroups: () => registeredGroups,
|
||||
getSessions: () => sessions,
|
||||
queue,
|
||||
onProcess: (groupJid, proc, containerName, groupFolder) =>
|
||||
queue.registerProcess(groupJid, proc, containerName, groupFolder),
|
||||
sendMessage: async (jid, rawText) => {
|
||||
const channel = findChannel(channels, jid);
|
||||
if (!channel) {
|
||||
logger.warn({ jid }, 'No channel owns JID, cannot send message');
|
||||
return;
|
||||
}
|
||||
const text = formatOutbound(rawText);
|
||||
if (text) await channel.sendMessage(jid, text);
|
||||
},
|
||||
});
|
||||
startIpcWatcher({
|
||||
sendMessage: (jid, text) => {
|
||||
const channel = findChannel(channels, jid);
|
||||
if (!channel) throw new Error(`No channel for JID: ${jid}`);
|
||||
return channel.sendMessage(jid, text);
|
||||
},
|
||||
registeredGroups: () => registeredGroups,
|
||||
registerGroup,
|
||||
syncGroups: async (force: boolean) => {
|
||||
await Promise.all(channels.filter((ch) => ch.syncGroups).map((ch) => ch.syncGroups!(force)));
|
||||
},
|
||||
getAvailableGroups,
|
||||
writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj),
|
||||
onTasksChanged: () => {
|
||||
const tasks = getAllTasks();
|
||||
const taskRows = tasks.map((t) => ({
|
||||
id: t.id,
|
||||
groupFolder: t.group_folder,
|
||||
prompt: t.prompt,
|
||||
script: t.script || undefined,
|
||||
schedule_type: t.schedule_type,
|
||||
schedule_value: t.schedule_value,
|
||||
status: t.status,
|
||||
next_run: t.next_run,
|
||||
}));
|
||||
for (const group of Object.values(registeredGroups)) {
|
||||
writeTasksSnapshot(group.folder, group.isMain === true, taskRows);
|
||||
}
|
||||
},
|
||||
});
|
||||
startSessionCleanup();
|
||||
queue.setProcessMessagesFn(processGroupMessages);
|
||||
recoverPendingMessages();
|
||||
startMessageLoop().catch((err) => {
|
||||
logger.fatal({ err }, 'Message loop crashed unexpectedly');
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
// Guard: only run when executed directly, not when imported by tests
|
||||
const isDirectRun =
|
||||
process.argv[1] && new URL(import.meta.url).pathname === new URL(`file://${process.argv[1]}`).pathname;
|
||||
|
||||
if (isDirectRun) {
|
||||
main().catch((err) => {
|
||||
logger.error({ err }, 'Failed to start NanoClaw');
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,613 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
import {
|
||||
_initTestDatabase,
|
||||
createTask,
|
||||
getAllTasks,
|
||||
getRegisteredGroup,
|
||||
getTaskById,
|
||||
setRegisteredGroup,
|
||||
} from './db.js';
|
||||
import { processTaskIpc, IpcDeps } from './ipc.js';
|
||||
import { RegisteredGroup } from './types.js';
|
||||
|
||||
// Set up registered groups used across tests
|
||||
const MAIN_GROUP: RegisteredGroup = {
|
||||
name: 'Main',
|
||||
folder: 'whatsapp_main',
|
||||
trigger: 'always',
|
||||
added_at: '2024-01-01T00:00:00.000Z',
|
||||
isMain: true,
|
||||
};
|
||||
|
||||
const OTHER_GROUP: RegisteredGroup = {
|
||||
name: 'Other',
|
||||
folder: 'other-group',
|
||||
trigger: '@Andy',
|
||||
added_at: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
const THIRD_GROUP: RegisteredGroup = {
|
||||
name: 'Third',
|
||||
folder: 'third-group',
|
||||
trigger: '@Andy',
|
||||
added_at: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
let groups: Record<string, RegisteredGroup>;
|
||||
let deps: IpcDeps;
|
||||
|
||||
beforeEach(() => {
|
||||
_initTestDatabase();
|
||||
|
||||
groups = {
|
||||
'main@g.us': MAIN_GROUP,
|
||||
'other@g.us': OTHER_GROUP,
|
||||
'third@g.us': THIRD_GROUP,
|
||||
};
|
||||
|
||||
// Populate DB as well
|
||||
setRegisteredGroup('main@g.us', MAIN_GROUP);
|
||||
setRegisteredGroup('other@g.us', OTHER_GROUP);
|
||||
setRegisteredGroup('third@g.us', THIRD_GROUP);
|
||||
|
||||
deps = {
|
||||
sendMessage: async () => {},
|
||||
registeredGroups: () => groups,
|
||||
registerGroup: (jid, group) => {
|
||||
groups[jid] = group;
|
||||
setRegisteredGroup(jid, group);
|
||||
// Mock the fs.mkdirSync that registerGroup does
|
||||
},
|
||||
syncGroups: async () => {},
|
||||
getAvailableGroups: () => [],
|
||||
writeGroupsSnapshot: () => {},
|
||||
onTasksChanged: () => {},
|
||||
};
|
||||
});
|
||||
|
||||
// --- schedule_task authorization ---
|
||||
|
||||
describe('schedule_task authorization', () => {
|
||||
it('main group can schedule for another group', async () => {
|
||||
await processTaskIpc(
|
||||
{
|
||||
type: 'schedule_task',
|
||||
prompt: 'do something',
|
||||
schedule_type: 'once',
|
||||
schedule_value: '2025-06-01T00:00:00',
|
||||
targetJid: 'other@g.us',
|
||||
},
|
||||
'whatsapp_main',
|
||||
true,
|
||||
deps,
|
||||
);
|
||||
|
||||
// Verify task was created in DB for the other group
|
||||
const allTasks = getAllTasks();
|
||||
expect(allTasks.length).toBe(1);
|
||||
expect(allTasks[0].group_folder).toBe('other-group');
|
||||
});
|
||||
|
||||
it('non-main group can schedule for itself', async () => {
|
||||
await processTaskIpc(
|
||||
{
|
||||
type: 'schedule_task',
|
||||
prompt: 'self task',
|
||||
schedule_type: 'once',
|
||||
schedule_value: '2025-06-01T00:00:00',
|
||||
targetJid: 'other@g.us',
|
||||
},
|
||||
'other-group',
|
||||
false,
|
||||
deps,
|
||||
);
|
||||
|
||||
const allTasks = getAllTasks();
|
||||
expect(allTasks.length).toBe(1);
|
||||
expect(allTasks[0].group_folder).toBe('other-group');
|
||||
});
|
||||
|
||||
it('non-main group cannot schedule for another group', async () => {
|
||||
await processTaskIpc(
|
||||
{
|
||||
type: 'schedule_task',
|
||||
prompt: 'unauthorized',
|
||||
schedule_type: 'once',
|
||||
schedule_value: '2025-06-01T00:00:00',
|
||||
targetJid: 'main@g.us',
|
||||
},
|
||||
'other-group',
|
||||
false,
|
||||
deps,
|
||||
);
|
||||
|
||||
const allTasks = getAllTasks();
|
||||
expect(allTasks.length).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects schedule_task for unregistered target JID', async () => {
|
||||
await processTaskIpc(
|
||||
{
|
||||
type: 'schedule_task',
|
||||
prompt: 'no target',
|
||||
schedule_type: 'once',
|
||||
schedule_value: '2025-06-01T00:00:00',
|
||||
targetJid: 'unknown@g.us',
|
||||
},
|
||||
'whatsapp_main',
|
||||
true,
|
||||
deps,
|
||||
);
|
||||
|
||||
const allTasks = getAllTasks();
|
||||
expect(allTasks.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- pause_task authorization ---
|
||||
|
||||
describe('pause_task authorization', () => {
|
||||
beforeEach(() => {
|
||||
createTask({
|
||||
id: 'task-main',
|
||||
group_folder: 'whatsapp_main',
|
||||
chat_jid: 'main@g.us',
|
||||
prompt: 'main task',
|
||||
schedule_type: 'once',
|
||||
schedule_value: '2025-06-01T00:00:00',
|
||||
context_mode: 'isolated',
|
||||
next_run: '2025-06-01T00:00:00.000Z',
|
||||
status: 'active',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
});
|
||||
createTask({
|
||||
id: 'task-other',
|
||||
group_folder: 'other-group',
|
||||
chat_jid: 'other@g.us',
|
||||
prompt: 'other task',
|
||||
schedule_type: 'once',
|
||||
schedule_value: '2025-06-01T00:00:00',
|
||||
context_mode: 'isolated',
|
||||
next_run: '2025-06-01T00:00:00.000Z',
|
||||
status: 'active',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('main group can pause any task', async () => {
|
||||
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);
|
||||
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);
|
||||
expect(getTaskById('task-main')!.status).toBe('active');
|
||||
});
|
||||
});
|
||||
|
||||
// --- resume_task authorization ---
|
||||
|
||||
describe('resume_task authorization', () => {
|
||||
beforeEach(() => {
|
||||
createTask({
|
||||
id: 'task-paused',
|
||||
group_folder: 'other-group',
|
||||
chat_jid: 'other@g.us',
|
||||
prompt: 'paused task',
|
||||
schedule_type: 'once',
|
||||
schedule_value: '2025-06-01T00:00:00',
|
||||
context_mode: 'isolated',
|
||||
next_run: '2025-06-01T00:00:00.000Z',
|
||||
status: 'paused',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('main group can resume any task', async () => {
|
||||
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);
|
||||
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);
|
||||
expect(getTaskById('task-paused')!.status).toBe('paused');
|
||||
});
|
||||
});
|
||||
|
||||
// --- cancel_task authorization ---
|
||||
|
||||
describe('cancel_task authorization', () => {
|
||||
it('main group can cancel any task', async () => {
|
||||
createTask({
|
||||
id: 'task-to-cancel',
|
||||
group_folder: 'other-group',
|
||||
chat_jid: 'other@g.us',
|
||||
prompt: 'cancel me',
|
||||
schedule_type: 'once',
|
||||
schedule_value: '2025-06-01T00:00:00',
|
||||
context_mode: 'isolated',
|
||||
next_run: null,
|
||||
status: 'active',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await processTaskIpc({ type: 'cancel_task', taskId: 'task-to-cancel' }, 'whatsapp_main', true, deps);
|
||||
expect(getTaskById('task-to-cancel')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('non-main group can cancel its own task', async () => {
|
||||
createTask({
|
||||
id: 'task-own',
|
||||
group_folder: 'other-group',
|
||||
chat_jid: 'other@g.us',
|
||||
prompt: 'my task',
|
||||
schedule_type: 'once',
|
||||
schedule_value: '2025-06-01T00:00:00',
|
||||
context_mode: 'isolated',
|
||||
next_run: null,
|
||||
status: 'active',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await processTaskIpc({ type: 'cancel_task', taskId: 'task-own' }, 'other-group', false, deps);
|
||||
expect(getTaskById('task-own')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('non-main group cannot cancel another groups task', async () => {
|
||||
createTask({
|
||||
id: 'task-foreign',
|
||||
group_folder: 'whatsapp_main',
|
||||
chat_jid: 'main@g.us',
|
||||
prompt: 'not yours',
|
||||
schedule_type: 'once',
|
||||
schedule_value: '2025-06-01T00:00:00',
|
||||
context_mode: 'isolated',
|
||||
next_run: null,
|
||||
status: 'active',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await processTaskIpc({ type: 'cancel_task', taskId: 'task-foreign' }, 'other-group', false, deps);
|
||||
expect(getTaskById('task-foreign')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// --- register_group authorization ---
|
||||
|
||||
describe('register_group authorization', () => {
|
||||
it('non-main group cannot register a group', async () => {
|
||||
await processTaskIpc(
|
||||
{
|
||||
type: 'register_group',
|
||||
jid: 'new@g.us',
|
||||
name: 'New Group',
|
||||
folder: 'new-group',
|
||||
trigger: '@Andy',
|
||||
},
|
||||
'other-group',
|
||||
false,
|
||||
deps,
|
||||
);
|
||||
|
||||
// registeredGroups should not have changed
|
||||
expect(groups['new@g.us']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('main group cannot register with unsafe folder path', async () => {
|
||||
await processTaskIpc(
|
||||
{
|
||||
type: 'register_group',
|
||||
jid: 'new@g.us',
|
||||
name: 'New Group',
|
||||
folder: '../../outside',
|
||||
trigger: '@Andy',
|
||||
},
|
||||
'whatsapp_main',
|
||||
true,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(groups['new@g.us']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// --- refresh_groups 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);
|
||||
// If we got here without error, the auth gate worked
|
||||
});
|
||||
});
|
||||
|
||||
// --- IPC message authorization ---
|
||||
// Tests the authorization pattern from startIpcWatcher (ipc.ts).
|
||||
// The logic: isMain || (targetGroup && targetGroup.folder === sourceGroup)
|
||||
|
||||
describe('IPC message authorization', () => {
|
||||
// Replicate the exact check from the IPC watcher
|
||||
function isMessageAuthorized(
|
||||
sourceGroup: string,
|
||||
isMain: boolean,
|
||||
targetChatJid: string,
|
||||
registeredGroups: Record<string, RegisteredGroup>,
|
||||
): boolean {
|
||||
const targetGroup = registeredGroups[targetChatJid];
|
||||
return isMain || (!!targetGroup && targetGroup.folder === sourceGroup);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('non-main group can send to its own chat', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
it('non-main group cannot send to unregistered JID', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// --- schedule_task with cron and interval types ---
|
||||
|
||||
describe('schedule_task schedule types', () => {
|
||||
it('creates task with cron schedule and computes next_run', async () => {
|
||||
await processTaskIpc(
|
||||
{
|
||||
type: 'schedule_task',
|
||||
prompt: 'cron task',
|
||||
schedule_type: 'cron',
|
||||
schedule_value: '0 9 * * *', // every day at 9am
|
||||
targetJid: 'other@g.us',
|
||||
},
|
||||
'whatsapp_main',
|
||||
true,
|
||||
deps,
|
||||
);
|
||||
|
||||
const tasks = getAllTasks();
|
||||
expect(tasks).toHaveLength(1);
|
||||
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);
|
||||
});
|
||||
|
||||
it('rejects invalid cron expression', async () => {
|
||||
await processTaskIpc(
|
||||
{
|
||||
type: 'schedule_task',
|
||||
prompt: 'bad cron',
|
||||
schedule_type: 'cron',
|
||||
schedule_value: 'not a cron',
|
||||
targetJid: 'other@g.us',
|
||||
},
|
||||
'whatsapp_main',
|
||||
true,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(getAllTasks()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('creates task with interval schedule', async () => {
|
||||
const before = Date.now();
|
||||
|
||||
await processTaskIpc(
|
||||
{
|
||||
type: 'schedule_task',
|
||||
prompt: 'interval task',
|
||||
schedule_type: 'interval',
|
||||
schedule_value: '3600000', // 1 hour
|
||||
targetJid: 'other@g.us',
|
||||
},
|
||||
'whatsapp_main',
|
||||
true,
|
||||
deps,
|
||||
);
|
||||
|
||||
const tasks = getAllTasks();
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].schedule_type).toBe('interval');
|
||||
// next_run should be ~1 hour from now
|
||||
const nextRun = new Date(tasks[0].next_run!).getTime();
|
||||
expect(nextRun).toBeGreaterThanOrEqual(before + 3600000 - 1000);
|
||||
expect(nextRun).toBeLessThanOrEqual(Date.now() + 3600000 + 1000);
|
||||
});
|
||||
|
||||
it('rejects invalid interval (non-numeric)', async () => {
|
||||
await processTaskIpc(
|
||||
{
|
||||
type: 'schedule_task',
|
||||
prompt: 'bad interval',
|
||||
schedule_type: 'interval',
|
||||
schedule_value: 'abc',
|
||||
targetJid: 'other@g.us',
|
||||
},
|
||||
'whatsapp_main',
|
||||
true,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(getAllTasks()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects invalid interval (zero)', async () => {
|
||||
await processTaskIpc(
|
||||
{
|
||||
type: 'schedule_task',
|
||||
prompt: 'zero interval',
|
||||
schedule_type: 'interval',
|
||||
schedule_value: '0',
|
||||
targetJid: 'other@g.us',
|
||||
},
|
||||
'whatsapp_main',
|
||||
true,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(getAllTasks()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects invalid once timestamp', async () => {
|
||||
await processTaskIpc(
|
||||
{
|
||||
type: 'schedule_task',
|
||||
prompt: 'bad once',
|
||||
schedule_type: 'once',
|
||||
schedule_value: 'not-a-date',
|
||||
targetJid: 'other@g.us',
|
||||
},
|
||||
'whatsapp_main',
|
||||
true,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(getAllTasks()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- context_mode defaulting ---
|
||||
|
||||
describe('schedule_task context_mode', () => {
|
||||
it('accepts context_mode=group', async () => {
|
||||
await processTaskIpc(
|
||||
{
|
||||
type: 'schedule_task',
|
||||
prompt: 'group context',
|
||||
schedule_type: 'once',
|
||||
schedule_value: '2025-06-01T00:00:00',
|
||||
context_mode: 'group',
|
||||
targetJid: 'other@g.us',
|
||||
},
|
||||
'whatsapp_main',
|
||||
true,
|
||||
deps,
|
||||
);
|
||||
|
||||
const tasks = getAllTasks();
|
||||
expect(tasks[0].context_mode).toBe('group');
|
||||
});
|
||||
|
||||
it('accepts context_mode=isolated', async () => {
|
||||
await processTaskIpc(
|
||||
{
|
||||
type: 'schedule_task',
|
||||
prompt: 'isolated context',
|
||||
schedule_type: 'once',
|
||||
schedule_value: '2025-06-01T00:00:00',
|
||||
context_mode: 'isolated',
|
||||
targetJid: 'other@g.us',
|
||||
},
|
||||
'whatsapp_main',
|
||||
true,
|
||||
deps,
|
||||
);
|
||||
|
||||
const tasks = getAllTasks();
|
||||
expect(tasks[0].context_mode).toBe('isolated');
|
||||
});
|
||||
|
||||
it('defaults invalid context_mode to isolated', async () => {
|
||||
await processTaskIpc(
|
||||
{
|
||||
type: 'schedule_task',
|
||||
prompt: 'bad context',
|
||||
schedule_type: 'once',
|
||||
schedule_value: '2025-06-01T00:00:00',
|
||||
context_mode: 'bogus' as any,
|
||||
targetJid: 'other@g.us',
|
||||
},
|
||||
'whatsapp_main',
|
||||
true,
|
||||
deps,
|
||||
);
|
||||
|
||||
const tasks = getAllTasks();
|
||||
expect(tasks[0].context_mode).toBe('isolated');
|
||||
});
|
||||
|
||||
it('defaults missing context_mode to isolated', async () => {
|
||||
await processTaskIpc(
|
||||
{
|
||||
type: 'schedule_task',
|
||||
prompt: 'no context mode',
|
||||
schedule_type: 'once',
|
||||
schedule_value: '2025-06-01T00:00:00',
|
||||
targetJid: 'other@g.us',
|
||||
},
|
||||
'whatsapp_main',
|
||||
true,
|
||||
deps,
|
||||
);
|
||||
|
||||
const tasks = getAllTasks();
|
||||
expect(tasks[0].context_mode).toBe('isolated');
|
||||
});
|
||||
});
|
||||
|
||||
// --- register_group success path ---
|
||||
|
||||
describe('register_group success', () => {
|
||||
it('main group can register a new group', async () => {
|
||||
await processTaskIpc(
|
||||
{
|
||||
type: 'register_group',
|
||||
jid: 'new@g.us',
|
||||
name: 'New Group',
|
||||
folder: 'new-group',
|
||||
trigger: '@Andy',
|
||||
},
|
||||
'whatsapp_main',
|
||||
true,
|
||||
deps,
|
||||
);
|
||||
|
||||
// Verify group was registered in DB
|
||||
const group = getRegisteredGroup('new@g.us');
|
||||
expect(group).toBeDefined();
|
||||
expect(group!.name).toBe('New Group');
|
||||
expect(group!.folder).toBe('new-group');
|
||||
expect(group!.trigger).toBe('@Andy');
|
||||
});
|
||||
|
||||
it('register_group rejects request with missing fields', async () => {
|
||||
await processTaskIpc(
|
||||
{
|
||||
type: 'register_group',
|
||||
jid: 'partial@g.us',
|
||||
name: 'Partial',
|
||||
// missing folder and trigger
|
||||
},
|
||||
'whatsapp_main',
|
||||
true,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(getRegisteredGroup('partial@g.us')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
356
src/v1/ipc.ts
356
src/v1/ipc.ts
@@ -1,356 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
|
||||
import { DATA_DIR, IPC_POLL_INTERVAL, TIMEZONE } from './config.js';
|
||||
import { AvailableGroup } from './container-runner.js';
|
||||
import { createTask, deleteTask, getTaskById, updateTask } from './db.js';
|
||||
import { isValidGroupFolder } from './group-folder.js';
|
||||
import { logger } from './logger.js';
|
||||
import { RegisteredGroup } from './types.js';
|
||||
|
||||
export interface IpcDeps {
|
||||
sendMessage: (jid: string, text: string) => Promise<void>;
|
||||
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||
registerGroup: (jid: string, group: RegisteredGroup) => void;
|
||||
syncGroups: (force: boolean) => Promise<void>;
|
||||
getAvailableGroups: () => AvailableGroup[];
|
||||
writeGroupsSnapshot: (
|
||||
groupFolder: string,
|
||||
isMain: boolean,
|
||||
availableGroups: AvailableGroup[],
|
||||
registeredJids: Set<string>,
|
||||
) => void;
|
||||
onTasksChanged: () => void;
|
||||
}
|
||||
|
||||
let ipcWatcherRunning = false;
|
||||
|
||||
export function startIpcWatcher(deps: IpcDeps): void {
|
||||
if (ipcWatcherRunning) {
|
||||
logger.debug('IPC watcher already running, skipping duplicate start');
|
||||
return;
|
||||
}
|
||||
ipcWatcherRunning = true;
|
||||
|
||||
const ipcBaseDir = path.join(DATA_DIR, 'ipc');
|
||||
fs.mkdirSync(ipcBaseDir, { recursive: true });
|
||||
|
||||
const processIpcFiles = async () => {
|
||||
// Scan all group IPC directories (identity determined by directory)
|
||||
let groupFolders: string[];
|
||||
try {
|
||||
groupFolders = fs.readdirSync(ipcBaseDir).filter((f) => {
|
||||
const stat = fs.statSync(path.join(ipcBaseDir, f));
|
||||
return stat.isDirectory() && f !== 'errors';
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Error reading IPC base directory');
|
||||
setTimeout(processIpcFiles, IPC_POLL_INTERVAL);
|
||||
return;
|
||||
}
|
||||
|
||||
const registeredGroups = deps.registeredGroups();
|
||||
|
||||
// Build folder→isMain lookup from registered groups
|
||||
const folderIsMain = new Map<string, boolean>();
|
||||
for (const group of Object.values(registeredGroups)) {
|
||||
if (group.isMain) folderIsMain.set(group.folder, true);
|
||||
}
|
||||
|
||||
for (const sourceGroup of groupFolders) {
|
||||
const isMain = folderIsMain.get(sourceGroup) === true;
|
||||
const messagesDir = path.join(ipcBaseDir, sourceGroup, 'messages');
|
||||
const tasksDir = path.join(ipcBaseDir, sourceGroup, 'tasks');
|
||||
|
||||
// Process messages from this group's IPC directory
|
||||
try {
|
||||
if (fs.existsSync(messagesDir)) {
|
||||
const messageFiles = fs.readdirSync(messagesDir).filter((f) => f.endsWith('.json'));
|
||||
for (const file of messageFiles) {
|
||||
const filePath = path.join(messagesDir, file);
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
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)) {
|
||||
await deps.sendMessage(data.chatJid, data.text);
|
||||
logger.info({ chatJid: data.chatJid, sourceGroup }, 'IPC message sent');
|
||||
} else {
|
||||
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');
|
||||
const errorDir = path.join(ipcBaseDir, 'errors');
|
||||
fs.mkdirSync(errorDir, { recursive: true });
|
||||
fs.renameSync(filePath, path.join(errorDir, `${sourceGroup}-${file}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
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'));
|
||||
for (const file of taskFiles) {
|
||||
const filePath = path.join(tasksDir, file);
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
// Pass source group identity to processTaskIpc for authorization
|
||||
await processTaskIpc(data, sourceGroup, isMain, deps);
|
||||
fs.unlinkSync(filePath);
|
||||
} catch (err) {
|
||||
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}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err, sourceGroup }, 'Error reading IPC tasks directory');
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(processIpcFiles, IPC_POLL_INTERVAL);
|
||||
};
|
||||
|
||||
processIpcFiles();
|
||||
logger.info('IPC watcher started (per-group namespaces)');
|
||||
}
|
||||
|
||||
export async function processTaskIpc(
|
||||
data: {
|
||||
type: string;
|
||||
taskId?: string;
|
||||
prompt?: string;
|
||||
schedule_type?: string;
|
||||
schedule_value?: string;
|
||||
context_mode?: string;
|
||||
script?: string;
|
||||
groupFolder?: string;
|
||||
chatJid?: string;
|
||||
targetJid?: string;
|
||||
// For register_group
|
||||
jid?: string;
|
||||
name?: string;
|
||||
folder?: string;
|
||||
trigger?: string;
|
||||
requiresTrigger?: boolean;
|
||||
containerConfig?: RegisteredGroup['containerConfig'];
|
||||
},
|
||||
sourceGroup: string, // Verified identity from IPC directory
|
||||
isMain: boolean, // Verified from directory path
|
||||
deps: IpcDeps,
|
||||
): Promise<void> {
|
||||
const registeredGroups = deps.registeredGroups();
|
||||
|
||||
switch (data.type) {
|
||||
case 'schedule_task':
|
||||
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');
|
||||
break;
|
||||
}
|
||||
|
||||
const targetFolder = targetGroupEntry.folder;
|
||||
|
||||
// Authorization: non-main groups can only schedule for themselves
|
||||
if (!isMain && targetFolder !== sourceGroup) {
|
||||
logger.warn({ sourceGroup, targetFolder }, 'Unauthorized schedule_task attempt blocked');
|
||||
break;
|
||||
}
|
||||
|
||||
const scheduleType = data.schedule_type as 'cron' | 'interval' | 'once';
|
||||
|
||||
let nextRun: string | null = null;
|
||||
if (scheduleType === 'cron') {
|
||||
try {
|
||||
const interval = CronExpressionParser.parse(data.schedule_value, {
|
||||
tz: TIMEZONE,
|
||||
});
|
||||
nextRun = interval.next().toISOString();
|
||||
} catch {
|
||||
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');
|
||||
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');
|
||||
break;
|
||||
}
|
||||
nextRun = date.toISOString();
|
||||
}
|
||||
|
||||
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';
|
||||
createTask({
|
||||
id: taskId,
|
||||
group_folder: targetFolder,
|
||||
chat_jid: targetJid,
|
||||
prompt: data.prompt,
|
||||
script: data.script || null,
|
||||
schedule_type: scheduleType,
|
||||
schedule_value: data.schedule_value,
|
||||
context_mode: contextMode,
|
||||
next_run: nextRun,
|
||||
status: 'active',
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
logger.info({ taskId, sourceGroup, targetFolder, contextMode }, 'Task created via IPC');
|
||||
deps.onTasksChanged();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'pause_task':
|
||||
if (data.taskId) {
|
||||
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');
|
||||
deps.onTasksChanged();
|
||||
} else {
|
||||
logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task pause attempt');
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'resume_task':
|
||||
if (data.taskId) {
|
||||
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');
|
||||
deps.onTasksChanged();
|
||||
} else {
|
||||
logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task resume attempt');
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'cancel_task':
|
||||
if (data.taskId) {
|
||||
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');
|
||||
deps.onTasksChanged();
|
||||
} else {
|
||||
logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task cancel attempt');
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'update_task':
|
||||
if (data.taskId) {
|
||||
const task = getTaskById(data.taskId);
|
||||
if (!task) {
|
||||
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');
|
||||
break;
|
||||
}
|
||||
|
||||
const updates: Parameters<typeof updateTask>[1] = {};
|
||||
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;
|
||||
|
||||
// Recompute next_run if schedule changed
|
||||
if (data.schedule_type || data.schedule_value) {
|
||||
const updatedTask = {
|
||||
...task,
|
||||
...updates,
|
||||
};
|
||||
if (updatedTask.schedule_type === 'cron') {
|
||||
try {
|
||||
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');
|
||||
break;
|
||||
}
|
||||
} else if (updatedTask.schedule_type === 'interval') {
|
||||
const ms = parseInt(updatedTask.schedule_value, 10);
|
||||
if (!isNaN(ms) && ms > 0) {
|
||||
updates.next_run = new Date(Date.now() + ms).toISOString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateTask(data.taskId, updates);
|
||||
logger.info({ taskId: data.taskId, sourceGroup, updates }, 'Task updated via IPC');
|
||||
deps.onTasksChanged();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'refresh_groups':
|
||||
// Only main group can request a refresh
|
||||
if (isMain) {
|
||||
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)));
|
||||
} else {
|
||||
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');
|
||||
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');
|
||||
break;
|
||||
}
|
||||
// Defense in depth: agent cannot set isMain via IPC.
|
||||
// Preserve isMain from the existing registration so IPC config
|
||||
// updates (e.g. adding additionalMounts) don't strip the flag.
|
||||
const existingGroup = registeredGroups[data.jid];
|
||||
deps.registerGroup(data.jid, {
|
||||
name: data.name,
|
||||
folder: data.folder,
|
||||
trigger: data.trigger,
|
||||
added_at: new Date().toISOString(),
|
||||
containerConfig: data.containerConfig,
|
||||
requiresTrigger: data.requiresTrigger,
|
||||
isMain: existingGroup?.isMain,
|
||||
});
|
||||
} else {
|
||||
logger.warn({ data }, 'Invalid register_group request - missing required fields');
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.warn({ type: data.type }, 'Unknown IPC task type');
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
const LEVELS = { debug: 20, info: 30, warn: 40, error: 50, fatal: 60 } as const;
|
||||
type Level = keyof typeof LEVELS;
|
||||
|
||||
const COLORS: Record<Level, string> = {
|
||||
debug: '\x1b[34m',
|
||||
info: '\x1b[32m',
|
||||
warn: '\x1b[33m',
|
||||
error: '\x1b[31m',
|
||||
fatal: '\x1b[41m\x1b[37m',
|
||||
};
|
||||
const KEY_COLOR = '\x1b[35m';
|
||||
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;
|
||||
|
||||
function formatErr(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
return `{\n "type": "${err.constructor.name}",\n "message": "${err.message}",\n "stack":\n ${err.stack}\n }`;
|
||||
}
|
||||
return JSON.stringify(err);
|
||||
}
|
||||
|
||||
function formatData(data: Record<string, unknown>): string {
|
||||
let out = '';
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (k === 'err') {
|
||||
out += `\n ${KEY_COLOR}err${RESET}: ${formatErr(v)}`;
|
||||
} else {
|
||||
out += `\n ${KEY_COLOR}${k}${RESET}: ${JSON.stringify(v)}`;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function ts(): string {
|
||||
const d = new Date();
|
||||
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 {
|
||||
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`);
|
||||
} else {
|
||||
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),
|
||||
};
|
||||
|
||||
// Route uncaught errors through logger so they get timestamps in stderr
|
||||
process.on('uncaughtException', (err) => {
|
||||
logger.fatal({ err }, 'Uncaught exception');
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logger.error({ err: reason }, 'Unhandled rejection');
|
||||
});
|
||||
@@ -1,405 +0,0 @@
|
||||
/**
|
||||
* Mount Security Module for NanoClaw
|
||||
*
|
||||
* Validates additional mounts against an allowlist stored OUTSIDE the project root.
|
||||
* This prevents container agents from modifying security configuration.
|
||||
*
|
||||
* Allowlist location: ~/.config/nanoclaw/mount-allowlist.json
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { MOUNT_ALLOWLIST_PATH } from './config.js';
|
||||
import { logger } from './logger.js';
|
||||
import { AdditionalMount, AllowedRoot, MountAllowlist } from './types.js';
|
||||
|
||||
// Cache the allowlist in memory - only reloads on process restart
|
||||
let cachedAllowlist: MountAllowlist | null = null;
|
||||
let allowlistLoadError: string | null = null;
|
||||
|
||||
/**
|
||||
* Default blocked patterns - paths that should never be mounted
|
||||
*/
|
||||
const DEFAULT_BLOCKED_PATTERNS = [
|
||||
'.ssh',
|
||||
'.gnupg',
|
||||
'.gpg',
|
||||
'.aws',
|
||||
'.azure',
|
||||
'.gcloud',
|
||||
'.kube',
|
||||
'.docker',
|
||||
'credentials',
|
||||
'.env',
|
||||
'.netrc',
|
||||
'.npmrc',
|
||||
'.pypirc',
|
||||
'id_rsa',
|
||||
'id_ed25519',
|
||||
'private_key',
|
||||
'.secret',
|
||||
];
|
||||
|
||||
/**
|
||||
* Load the mount allowlist from the external config location.
|
||||
* Returns null if the file doesn't exist or is invalid.
|
||||
* Result is cached in memory for the lifetime of the process.
|
||||
*/
|
||||
export function loadMountAllowlist(): MountAllowlist | null {
|
||||
if (cachedAllowlist !== null) {
|
||||
return cachedAllowlist;
|
||||
}
|
||||
|
||||
if (allowlistLoadError !== null) {
|
||||
// Already tried and failed, don't spam logs
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(MOUNT_ALLOWLIST_PATH)) {
|
||||
// Do NOT cache this as an error — file may be created later without restart.
|
||||
// Only parse/structural errors are permanently cached.
|
||||
logger.warn(
|
||||
{ path: MOUNT_ALLOWLIST_PATH },
|
||||
'Mount allowlist not found - additional mounts will be BLOCKED. ' +
|
||||
'Create the file to enable additional mounts.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(MOUNT_ALLOWLIST_PATH, 'utf-8');
|
||||
const allowlist = JSON.parse(content) as MountAllowlist;
|
||||
|
||||
// Validate structure
|
||||
if (!Array.isArray(allowlist.allowedRoots)) {
|
||||
throw new Error('allowedRoots must be an array');
|
||||
}
|
||||
|
||||
if (!Array.isArray(allowlist.blockedPatterns)) {
|
||||
throw new Error('blockedPatterns must be an array');
|
||||
}
|
||||
|
||||
if (typeof allowlist.nonMainReadOnly !== 'boolean') {
|
||||
throw new Error('nonMainReadOnly must be a boolean');
|
||||
}
|
||||
|
||||
// Merge with default blocked patterns
|
||||
const mergedBlockedPatterns = [...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns])];
|
||||
allowlist.blockedPatterns = mergedBlockedPatterns;
|
||||
|
||||
cachedAllowlist = allowlist;
|
||||
logger.info(
|
||||
{
|
||||
path: MOUNT_ALLOWLIST_PATH,
|
||||
allowedRoots: allowlist.allowedRoots.length,
|
||||
blockedPatterns: allowlist.blockedPatterns.length,
|
||||
},
|
||||
'Mount allowlist loaded successfully',
|
||||
);
|
||||
|
||||
return cachedAllowlist;
|
||||
} catch (err) {
|
||||
allowlistLoadError = err instanceof Error ? err.message : String(err);
|
||||
logger.error(
|
||||
{
|
||||
path: MOUNT_ALLOWLIST_PATH,
|
||||
error: allowlistLoadError,
|
||||
},
|
||||
'Failed to load mount allowlist - additional mounts will be BLOCKED',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand ~ to home directory and resolve to absolute path
|
||||
*/
|
||||
function expandPath(p: string): string {
|
||||
const homeDir = process.env.HOME || os.homedir();
|
||||
if (p.startsWith('~/')) {
|
||||
return path.join(homeDir, p.slice(2));
|
||||
}
|
||||
if (p === '~') {
|
||||
return homeDir;
|
||||
}
|
||||
return path.resolve(p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the real path, resolving symlinks.
|
||||
* Returns null if the path doesn't exist.
|
||||
*/
|
||||
function getRealPath(p: string): string | null {
|
||||
try {
|
||||
return fs.realpathSync(p);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path matches any blocked pattern
|
||||
*/
|
||||
function matchesBlockedPattern(realPath: string, blockedPatterns: string[]): string | null {
|
||||
const pathParts = realPath.split(path.sep);
|
||||
|
||||
for (const pattern of blockedPatterns) {
|
||||
// Check if any path component matches the pattern
|
||||
for (const part of pathParts) {
|
||||
if (part === pattern || part.includes(pattern)) {
|
||||
return pattern;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check if the full path contains the pattern
|
||||
if (realPath.includes(pattern)) {
|
||||
return pattern;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a real path is under an allowed root
|
||||
*/
|
||||
function findAllowedRoot(realPath: string, allowedRoots: AllowedRoot[]): AllowedRoot | null {
|
||||
for (const root of allowedRoots) {
|
||||
const expandedRoot = expandPath(root.path);
|
||||
const realRoot = getRealPath(expandedRoot);
|
||||
|
||||
if (realRoot === null) {
|
||||
// Allowed root doesn't exist, skip it
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if realPath is under realRoot
|
||||
const relative = path.relative(realRoot, realPath);
|
||||
if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
|
||||
return root;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the container path to prevent escaping /workspace/extra/
|
||||
*/
|
||||
function isValidContainerPath(containerPath: string): boolean {
|
||||
// Must not contain .. to prevent path traversal
|
||||
if (containerPath.includes('..')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must not be absolute (it will be prefixed with /workspace/extra/)
|
||||
if (containerPath.startsWith('/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must not be empty
|
||||
if (!containerPath || containerPath.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must not contain colons — prevents Docker -v option injection (e.g., "repo:rw")
|
||||
if (containerPath.includes(':')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface MountValidationResult {
|
||||
allowed: boolean;
|
||||
reason: string;
|
||||
realHostPath?: string;
|
||||
resolvedContainerPath?: string;
|
||||
effectiveReadonly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single additional mount against the allowlist.
|
||||
* Returns validation result with reason.
|
||||
*/
|
||||
export function validateMount(mount: AdditionalMount, isMain: boolean): MountValidationResult {
|
||||
const allowlist = loadMountAllowlist();
|
||||
|
||||
// If no allowlist, block all additional mounts
|
||||
if (allowlist === null) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `No mount allowlist configured at ${MOUNT_ALLOWLIST_PATH}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Derive containerPath from hostPath basename if not specified
|
||||
const containerPath = mount.containerPath || path.basename(mount.hostPath);
|
||||
|
||||
// Validate container path (cheap check)
|
||||
if (!isValidContainerPath(containerPath)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Invalid container path: "${containerPath}" - must be relative, non-empty, and not contain ".."`,
|
||||
};
|
||||
}
|
||||
|
||||
// Expand and resolve the host path
|
||||
const expandedPath = expandPath(mount.hostPath);
|
||||
const realPath = getRealPath(expandedPath);
|
||||
|
||||
if (realPath === null) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Host path does not exist: "${mount.hostPath}" (expanded: "${expandedPath}")`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check against blocked patterns
|
||||
const blockedMatch = matchesBlockedPattern(realPath, allowlist.blockedPatterns);
|
||||
if (blockedMatch !== null) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Path matches blocked pattern "${blockedMatch}": "${realPath}"`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if under an allowed root
|
||||
const allowedRoot = findAllowedRoot(realPath, allowlist.allowedRoots);
|
||||
if (allowedRoot === null) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Path "${realPath}" is not under any allowed root. Allowed roots: ${allowlist.allowedRoots
|
||||
.map((r) => expandPath(r.path))
|
||||
.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Determine effective readonly status
|
||||
const requestedReadWrite = mount.readonly === false;
|
||||
let effectiveReadonly = true; // Default to readonly
|
||||
|
||||
if (requestedReadWrite) {
|
||||
if (!isMain && allowlist.nonMainReadOnly) {
|
||||
// Non-main groups forced to read-only
|
||||
effectiveReadonly = true;
|
||||
logger.info(
|
||||
{
|
||||
mount: mount.hostPath,
|
||||
},
|
||||
'Mount forced to read-only for non-main group',
|
||||
);
|
||||
} else if (!allowedRoot.allowReadWrite) {
|
||||
// Root doesn't allow read-write
|
||||
effectiveReadonly = true;
|
||||
logger.info(
|
||||
{
|
||||
mount: mount.hostPath,
|
||||
root: allowedRoot.path,
|
||||
},
|
||||
'Mount forced to read-only - root does not allow read-write',
|
||||
);
|
||||
} else {
|
||||
// Read-write allowed
|
||||
effectiveReadonly = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
reason: `Allowed under root "${allowedRoot.path}"${allowedRoot.description ? ` (${allowedRoot.description})` : ''}`,
|
||||
realHostPath: realPath,
|
||||
resolvedContainerPath: containerPath,
|
||||
effectiveReadonly,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all additional mounts for a group.
|
||||
* Returns array of validated mounts (only those that passed validation).
|
||||
* Logs warnings for rejected mounts.
|
||||
*/
|
||||
export function validateAdditionalMounts(
|
||||
mounts: AdditionalMount[],
|
||||
groupName: string,
|
||||
isMain: boolean,
|
||||
): Array<{
|
||||
hostPath: string;
|
||||
containerPath: string;
|
||||
readonly: boolean;
|
||||
}> {
|
||||
const validatedMounts: Array<{
|
||||
hostPath: string;
|
||||
containerPath: string;
|
||||
readonly: boolean;
|
||||
}> = [];
|
||||
|
||||
for (const mount of mounts) {
|
||||
const result = validateMount(mount, isMain);
|
||||
|
||||
if (result.allowed) {
|
||||
validatedMounts.push({
|
||||
hostPath: result.realHostPath!,
|
||||
containerPath: `/workspace/extra/${result.resolvedContainerPath}`,
|
||||
readonly: result.effectiveReadonly!,
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
group: groupName,
|
||||
hostPath: result.realHostPath,
|
||||
containerPath: result.resolvedContainerPath,
|
||||
readonly: result.effectiveReadonly,
|
||||
reason: result.reason,
|
||||
},
|
||||
'Mount validated successfully',
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
{
|
||||
group: groupName,
|
||||
requestedPath: mount.hostPath,
|
||||
containerPath: mount.containerPath,
|
||||
reason: result.reason,
|
||||
},
|
||||
'Additional mount REJECTED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return validatedMounts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a template allowlist file for users to customize
|
||||
*/
|
||||
export function generateAllowlistTemplate(): string {
|
||||
const template: MountAllowlist = {
|
||||
allowedRoots: [
|
||||
{
|
||||
path: '~/projects',
|
||||
allowReadWrite: true,
|
||||
description: 'Development projects',
|
||||
},
|
||||
{
|
||||
path: '~/repos',
|
||||
allowReadWrite: true,
|
||||
description: 'Git repositories',
|
||||
},
|
||||
{
|
||||
path: '~/Documents/work',
|
||||
allowReadWrite: false,
|
||||
description: 'Work documents (read-only)',
|
||||
},
|
||||
],
|
||||
blockedPatterns: [
|
||||
// Additional patterns beyond defaults
|
||||
'password',
|
||||
'secret',
|
||||
'token',
|
||||
],
|
||||
nonMainReadOnly: true,
|
||||
};
|
||||
|
||||
return JSON.stringify(template, null, 2);
|
||||
}
|
||||
@@ -1,379 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
// Mock config before importing the module under test
|
||||
vi.mock('./config.js', () => ({
|
||||
DATA_DIR: '/tmp/nanoclaw-rc-test',
|
||||
}));
|
||||
|
||||
// Mock child_process
|
||||
const spawnMock = vi.fn();
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: (...args: any[]) => spawnMock(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
startRemoteControl,
|
||||
stopRemoteControl,
|
||||
restoreRemoteControl,
|
||||
getActiveSession,
|
||||
_resetForTesting,
|
||||
_getStateFilePath,
|
||||
} from './remote-control.js';
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function createMockProcess(pid = 12345) {
|
||||
return {
|
||||
pid,
|
||||
unref: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
stdin: { write: vi.fn(), end: vi.fn() },
|
||||
};
|
||||
}
|
||||
|
||||
describe('remote-control', () => {
|
||||
const STATE_FILE = _getStateFilePath();
|
||||
let readFileSyncSpy: ReturnType<typeof vi.spyOn>;
|
||||
let writeFileSyncSpy: ReturnType<typeof vi.spyOn>;
|
||||
let unlinkSyncSpy: ReturnType<typeof vi.spyOn>;
|
||||
let _mkdirSyncSpy: ReturnType<typeof vi.spyOn>;
|
||||
let openSyncSpy: ReturnType<typeof vi.spyOn>;
|
||||
let closeSyncSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
// Track what readFileSync should return for the stdout file
|
||||
let stdoutFileContent: string;
|
||||
|
||||
beforeEach(() => {
|
||||
_resetForTesting();
|
||||
spawnMock.mockReset();
|
||||
stdoutFileContent = '';
|
||||
|
||||
// Default fs mocks
|
||||
_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) => {
|
||||
if (p.endsWith('remote-control.stdout')) return stdoutFileContent;
|
||||
if (p.endsWith('remote-control.json')) {
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
}
|
||||
return '';
|
||||
}) as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
_resetForTesting();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// --- startRemoteControl ---
|
||||
|
||||
describe('startRemoteControl', () => {
|
||||
it('spawns claude remote-control and returns the URL', async () => {
|
||||
const proc = createMockProcess();
|
||||
spawnMock.mockReturnValue(proc);
|
||||
|
||||
// Simulate URL appearing in stdout file on first poll
|
||||
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');
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
url: 'https://claude.ai/code?bridge=env_abc123',
|
||||
});
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'claude',
|
||||
['remote-control', '--name', 'NanoClaw Remote'],
|
||||
expect.objectContaining({ cwd: '/project', detached: true }),
|
||||
);
|
||||
expect(proc.unref).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses file descriptors for stdout/stderr (not pipes)', async () => {
|
||||
const proc = createMockProcess();
|
||||
spawnMock.mockReturnValue(proc);
|
||||
stdoutFileContent = 'https://claude.ai/code?bridge=env_test\n';
|
||||
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
|
||||
|
||||
await startRemoteControl('user1', 'tg:123', '/project');
|
||||
|
||||
const spawnCall = spawnMock.mock.calls[0];
|
||||
const options = spawnCall[2];
|
||||
// stdio[0] is 'pipe' so we can write 'y' to accept the prompt
|
||||
expect(options.stdio[0]).toBe('pipe');
|
||||
expect(typeof options.stdio[1]).toBe('number');
|
||||
expect(typeof options.stdio[2]).toBe('number');
|
||||
});
|
||||
|
||||
it('closes file descriptors in parent after spawn', async () => {
|
||||
const proc = createMockProcess();
|
||||
spawnMock.mockReturnValue(proc);
|
||||
stdoutFileContent = 'https://claude.ai/code?bridge=env_test\n';
|
||||
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
|
||||
|
||||
await startRemoteControl('user1', 'tg:123', '/project');
|
||||
|
||||
// Two openSync calls (stdout + stderr), two closeSync calls
|
||||
expect(openSyncSpy).toHaveBeenCalledTimes(2);
|
||||
expect(closeSyncSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('saves state to disk after capturing URL', async () => {
|
||||
const proc = createMockProcess(99999);
|
||||
spawnMock.mockReturnValue(proc);
|
||||
stdoutFileContent = 'https://claude.ai/code?bridge=env_save\n';
|
||||
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
|
||||
|
||||
await startRemoteControl('user1', 'tg:123', '/project');
|
||||
|
||||
expect(writeFileSyncSpy).toHaveBeenCalledWith(STATE_FILE, expect.stringContaining('"pid":99999'));
|
||||
});
|
||||
|
||||
it('returns existing URL if session is already active', async () => {
|
||||
const proc = createMockProcess();
|
||||
spawnMock.mockReturnValue(proc);
|
||||
stdoutFileContent = 'https://claude.ai/code?bridge=env_existing\n';
|
||||
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
|
||||
|
||||
await startRemoteControl('user1', 'tg:123', '/project');
|
||||
|
||||
// Second call should return existing URL without spawning
|
||||
const result = await startRemoteControl('user2', 'tg:456', '/project');
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
url: 'https://claude.ai/code?bridge=env_existing',
|
||||
});
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('starts new session if existing process is dead', async () => {
|
||||
const proc1 = createMockProcess(11111);
|
||||
const proc2 = createMockProcess(22222);
|
||||
spawnMock.mockReturnValueOnce(proc1).mockReturnValueOnce(proc2);
|
||||
|
||||
// First start: process alive, URL found
|
||||
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');
|
||||
|
||||
// Old process (11111) is dead, new process (22222) is alive
|
||||
killSpy.mockImplementation(((pid: number, sig: any) => {
|
||||
if (pid === 11111 && (sig === 0 || sig === undefined)) {
|
||||
throw new Error('ESRCH');
|
||||
}
|
||||
return true;
|
||||
}) as any);
|
||||
|
||||
stdoutFileContent = 'https://claude.ai/code?bridge=env_second\n';
|
||||
const result = await startRemoteControl('user1', 'tg:123', '/project');
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
url: 'https://claude.ai/code?bridge=env_second',
|
||||
});
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns error if process exits before URL', async () => {
|
||||
const proc = createMockProcess(33333);
|
||||
spawnMock.mockReturnValue(proc);
|
||||
stdoutFileContent = '';
|
||||
|
||||
// Process is dead (poll will detect this)
|
||||
vi.spyOn(process, 'kill').mockImplementation((() => {
|
||||
throw new Error('ESRCH');
|
||||
}) as any);
|
||||
|
||||
const result = await startRemoteControl('user1', 'tg:123', '/project');
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: 'Process exited before producing URL',
|
||||
});
|
||||
});
|
||||
|
||||
it('times out if URL never appears', async () => {
|
||||
vi.useFakeTimers();
|
||||
const proc = createMockProcess(44444);
|
||||
spawnMock.mockReturnValue(proc);
|
||||
stdoutFileContent = 'no url here';
|
||||
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
|
||||
|
||||
const promise = startRemoteControl('user1', 'tg:123', '/project');
|
||||
|
||||
// Advance past URL_TIMEOUT_MS (30s), with enough steps for polls
|
||||
for (let i = 0; i < 160; i++) {
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
}
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: 'Timed out waiting for Remote Control URL',
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns error if spawn throws', async () => {
|
||||
spawnMock.mockImplementation(() => {
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
const result = await startRemoteControl('user1', 'tg:123', '/project');
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: 'Failed to start: ENOENT',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- stopRemoteControl ---
|
||||
|
||||
describe('stopRemoteControl', () => {
|
||||
it('kills the process and clears state', async () => {
|
||||
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);
|
||||
|
||||
await startRemoteControl('user1', 'tg:123', '/project');
|
||||
|
||||
const result = stopRemoteControl();
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(killSpy).toHaveBeenCalledWith(55555, 'SIGTERM');
|
||||
expect(unlinkSyncSpy).toHaveBeenCalledWith(STATE_FILE);
|
||||
expect(getActiveSession()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns error when no session is active', () => {
|
||||
const result = stopRemoteControl();
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: 'No active Remote Control session',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- restoreRemoteControl ---
|
||||
|
||||
describe('restoreRemoteControl', () => {
|
||||
it('restores session if state file exists and process is alive', () => {
|
||||
const session = {
|
||||
pid: 77777,
|
||||
url: 'https://claude.ai/code?bridge=env_restored',
|
||||
startedBy: 'user1',
|
||||
startedInChat: 'tg:123',
|
||||
startedAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
readFileSyncSpy.mockImplementation(((p: string) => {
|
||||
if (p.endsWith('remote-control.json')) return JSON.stringify(session);
|
||||
return '';
|
||||
}) as any);
|
||||
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
|
||||
|
||||
restoreRemoteControl();
|
||||
|
||||
const active = getActiveSession();
|
||||
expect(active).not.toBeNull();
|
||||
expect(active!.pid).toBe(77777);
|
||||
expect(active!.url).toBe('https://claude.ai/code?bridge=env_restored');
|
||||
});
|
||||
|
||||
it('clears state if process is dead', () => {
|
||||
const session = {
|
||||
pid: 88888,
|
||||
url: 'https://claude.ai/code?bridge=env_dead',
|
||||
startedBy: 'user1',
|
||||
startedInChat: 'tg:123',
|
||||
startedAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
readFileSyncSpy.mockImplementation(((p: string) => {
|
||||
if (p.endsWith('remote-control.json')) return JSON.stringify(session);
|
||||
return '';
|
||||
}) as any);
|
||||
vi.spyOn(process, 'kill').mockImplementation((() => {
|
||||
throw new Error('ESRCH');
|
||||
}) as any);
|
||||
|
||||
restoreRemoteControl();
|
||||
|
||||
expect(getActiveSession()).toBeNull();
|
||||
expect(unlinkSyncSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing if no state file exists', () => {
|
||||
// readFileSyncSpy default throws ENOENT for .json
|
||||
restoreRemoteControl();
|
||||
expect(getActiveSession()).toBeNull();
|
||||
});
|
||||
|
||||
it('clears state on corrupted JSON', () => {
|
||||
readFileSyncSpy.mockImplementation(((p: string) => {
|
||||
if (p.endsWith('remote-control.json')) return 'not json{{{';
|
||||
return '';
|
||||
}) as any);
|
||||
|
||||
restoreRemoteControl();
|
||||
|
||||
expect(getActiveSession()).toBeNull();
|
||||
expect(unlinkSyncSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ** This is the key integration test: restore → stop must work **
|
||||
it('stopRemoteControl works after restoreRemoteControl', () => {
|
||||
const session = {
|
||||
pid: 77777,
|
||||
url: 'https://claude.ai/code?bridge=env_restored',
|
||||
startedBy: 'user1',
|
||||
startedInChat: 'tg:123',
|
||||
startedAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
readFileSyncSpy.mockImplementation(((p: string) => {
|
||||
if (p.endsWith('remote-control.json')) return JSON.stringify(session);
|
||||
return '';
|
||||
}) as any);
|
||||
const killSpy = vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
|
||||
|
||||
restoreRemoteControl();
|
||||
expect(getActiveSession()).not.toBeNull();
|
||||
|
||||
const result = stopRemoteControl();
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(killSpy).toHaveBeenCalledWith(77777, 'SIGTERM');
|
||||
expect(unlinkSyncSpy).toHaveBeenCalled();
|
||||
expect(getActiveSession()).toBeNull();
|
||||
});
|
||||
|
||||
it('startRemoteControl returns restored URL without spawning', () => {
|
||||
const session = {
|
||||
pid: 77777,
|
||||
url: 'https://claude.ai/code?bridge=env_restored',
|
||||
startedBy: 'user1',
|
||||
startedInChat: 'tg:123',
|
||||
startedAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
readFileSyncSpy.mockImplementation(((p: string) => {
|
||||
if (p.endsWith('remote-control.json')) return JSON.stringify(session);
|
||||
return '';
|
||||
}) as any);
|
||||
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,218 +0,0 @@
|
||||
import { spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR } from './config.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
interface RemoteControlSession {
|
||||
pid: number;
|
||||
url: string;
|
||||
startedBy: string;
|
||||
startedInChat: string;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
let activeSession: RemoteControlSession | null = null;
|
||||
|
||||
const URL_REGEX = /https:\/\/claude\.ai\/code\S+/;
|
||||
const URL_TIMEOUT_MS = 30_000;
|
||||
const URL_POLL_MS = 200;
|
||||
const STATE_FILE = path.join(DATA_DIR, 'remote-control.json');
|
||||
const STDOUT_FILE = path.join(DATA_DIR, 'remote-control.stdout');
|
||||
const STDERR_FILE = path.join(DATA_DIR, 'remote-control.stderr');
|
||||
|
||||
function saveState(session: RemoteControlSession): void {
|
||||
fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
|
||||
fs.writeFileSync(STATE_FILE, JSON.stringify(session));
|
||||
}
|
||||
|
||||
function clearState(): void {
|
||||
try {
|
||||
fs.unlinkSync(STATE_FILE);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore session from disk on startup.
|
||||
* If the process is still alive, adopt it. Otherwise, clean up.
|
||||
*/
|
||||
export function restoreRemoteControl(): void {
|
||||
let data: string;
|
||||
try {
|
||||
data = fs.readFileSync(STATE_FILE, 'utf-8');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
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');
|
||||
} else {
|
||||
clearState();
|
||||
}
|
||||
} catch {
|
||||
clearState();
|
||||
}
|
||||
}
|
||||
|
||||
export function getActiveSession(): RemoteControlSession | null {
|
||||
return activeSession;
|
||||
}
|
||||
|
||||
/** @internal — exported for testing only */
|
||||
export function _resetForTesting(): void {
|
||||
activeSession = null;
|
||||
}
|
||||
|
||||
/** @internal — exported for testing only */
|
||||
export function _getStateFilePath(): string {
|
||||
return STATE_FILE;
|
||||
}
|
||||
|
||||
export async function startRemoteControl(
|
||||
sender: string,
|
||||
chatJid: string,
|
||||
cwd: string,
|
||||
): Promise<{ ok: true; url: string } | { ok: false; error: string }> {
|
||||
if (activeSession) {
|
||||
// Verify the process is still alive
|
||||
if (isProcessAlive(activeSession.pid)) {
|
||||
return { ok: true, url: activeSession.url };
|
||||
}
|
||||
// Process died — clean up and start a new one
|
||||
activeSession = null;
|
||||
clearState();
|
||||
}
|
||||
|
||||
// Redirect stdout/stderr to files so the process has no pipes to the parent.
|
||||
// This prevents SIGPIPE when NanoClaw restarts.
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const stdoutFd = fs.openSync(STDOUT_FILE, 'w');
|
||||
const stderrFd = fs.openSync(STDERR_FILE, 'w');
|
||||
|
||||
let proc;
|
||||
try {
|
||||
proc = spawn('claude', ['remote-control', '--name', 'NanoClaw Remote'], {
|
||||
cwd,
|
||||
stdio: ['pipe', stdoutFd, stderrFd],
|
||||
detached: true,
|
||||
});
|
||||
} catch (err: any) {
|
||||
fs.closeSync(stdoutFd);
|
||||
fs.closeSync(stderrFd);
|
||||
return { ok: false, error: `Failed to start: ${err.message}` };
|
||||
}
|
||||
|
||||
// Auto-accept the "Enable Remote Control?" prompt
|
||||
if (proc.stdin) {
|
||||
proc.stdin.write('y\n');
|
||||
proc.stdin.end();
|
||||
}
|
||||
|
||||
// Close FDs in the parent — the child inherited copies
|
||||
fs.closeSync(stdoutFd);
|
||||
fs.closeSync(stderrFd);
|
||||
|
||||
// Fully detach from parent
|
||||
proc.unref();
|
||||
|
||||
const pid = proc.pid;
|
||||
if (!pid) {
|
||||
return { ok: false, error: 'Failed to get process PID' };
|
||||
}
|
||||
|
||||
// Poll the stdout file for the URL
|
||||
return new Promise((resolve) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
const poll = () => {
|
||||
// Check if process died
|
||||
if (!isProcessAlive(pid)) {
|
||||
resolve({ ok: false, error: 'Process exited before producing URL' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for URL in stdout file
|
||||
let content = '';
|
||||
try {
|
||||
content = fs.readFileSync(STDOUT_FILE, 'utf-8');
|
||||
} catch {
|
||||
// File might not have content yet
|
||||
}
|
||||
|
||||
const match = content.match(URL_REGEX);
|
||||
if (match) {
|
||||
const session: RemoteControlSession = {
|
||||
pid,
|
||||
url: match[0],
|
||||
startedBy: sender,
|
||||
startedInChat: chatJid,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
activeSession = session;
|
||||
saveState(session);
|
||||
|
||||
logger.info({ url: match[0], pid, sender, chatJid }, 'Remote Control session started');
|
||||
resolve({ ok: true, url: match[0] });
|
||||
return;
|
||||
}
|
||||
|
||||
// Timeout check
|
||||
if (Date.now() - startTime >= URL_TIMEOUT_MS) {
|
||||
try {
|
||||
process.kill(-pid, 'SIGTERM');
|
||||
} catch {
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
} catch {
|
||||
// already dead
|
||||
}
|
||||
}
|
||||
resolve({
|
||||
ok: false,
|
||||
error: 'Timed out waiting for Remote Control URL',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(poll, URL_POLL_MS);
|
||||
};
|
||||
|
||||
poll();
|
||||
});
|
||||
}
|
||||
|
||||
export function stopRemoteControl():
|
||||
| {
|
||||
ok: true;
|
||||
}
|
||||
| { ok: false; error: string } {
|
||||
if (!activeSession) {
|
||||
return { ok: false, error: 'No active Remote Control session' };
|
||||
}
|
||||
|
||||
const { pid } = activeSession;
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
} catch {
|
||||
// already dead
|
||||
}
|
||||
activeSession = null;
|
||||
clearState();
|
||||
logger.info({ pid }, 'Remote Control session stopped');
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Channel, NewMessage } from './types.js';
|
||||
import { formatLocalTime } from './timezone.js';
|
||||
|
||||
export function escapeXml(s: string): string {
|
||||
if (!s) return '';
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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 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>`
|
||||
: '';
|
||||
return `<message sender="${escapeXml(m.sender_name)}" time="${escapeXml(displayTime)}"${replyAttr}>${replySnippet}${escapeXml(m.content)}</message>`;
|
||||
});
|
||||
|
||||
const header = `<context timezone="${escapeXml(timezone)}" />\n`;
|
||||
|
||||
return `${header}<messages>\n${lines.join('\n')}\n</messages>`;
|
||||
}
|
||||
|
||||
export function stripInternalTags(text: string): string {
|
||||
return text.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
|
||||
}
|
||||
|
||||
export function formatOutbound(rawText: string): string {
|
||||
const text = stripInternalTags(rawText);
|
||||
if (!text) return '';
|
||||
return text;
|
||||
}
|
||||
|
||||
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 {
|
||||
return channels.find((c) => c.ownsJid(jid));
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
import { _initTestDatabase, storeChatMetadata } from './db.js';
|
||||
import { getAvailableGroups, _setRegisteredGroups } from './index.js';
|
||||
|
||||
beforeEach(() => {
|
||||
_initTestDatabase();
|
||||
_setRegisteredGroups({});
|
||||
});
|
||||
|
||||
// --- JID ownership patterns ---
|
||||
|
||||
describe('JID ownership patterns', () => {
|
||||
// These test the patterns that will become ownsJid() on the Channel interface
|
||||
|
||||
it('WhatsApp group JID: ends with @g.us', () => {
|
||||
const jid = '12345678@g.us';
|
||||
expect(jid.endsWith('@g.us')).toBe(true);
|
||||
});
|
||||
|
||||
it('WhatsApp DM JID: ends with @s.whatsapp.net', () => {
|
||||
const jid = '12345678@s.whatsapp.net';
|
||||
expect(jid.endsWith('@s.whatsapp.net')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// --- getAvailableGroups ---
|
||||
|
||||
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);
|
||||
|
||||
const groups = getAvailableGroups();
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups.map((g) => g.jid)).toContain('group1@g.us');
|
||||
expect(groups.map((g) => g.jid)).toContain('group2@g.us');
|
||||
expect(groups.map((g) => g.jid)).not.toContain('user@s.whatsapp.net');
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const groups = getAvailableGroups();
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].jid).toBe('group@g.us');
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
_setRegisteredGroups({
|
||||
'reg@g.us': {
|
||||
name: 'Registered',
|
||||
folder: 'registered',
|
||||
trigger: '@Andy',
|
||||
added_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
const groups = getAvailableGroups();
|
||||
const reg = groups.find((g) => g.jid === 'reg@g.us');
|
||||
const unreg = groups.find((g) => g.jid === 'unreg@g.us');
|
||||
|
||||
expect(reg?.isRegistered).toBe(true);
|
||||
expect(unreg?.isRegistered).toBe(false);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const groups = getAvailableGroups();
|
||||
expect(groups[0].jid).toBe('new@g.us');
|
||||
expect(groups[1].jid).toBe('mid@g.us');
|
||||
expect(groups[2].jid).toBe('old@g.us');
|
||||
});
|
||||
|
||||
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');
|
||||
// Explicitly non-group with unusual JID
|
||||
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);
|
||||
|
||||
const groups = getAvailableGroups();
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].jid).toBe('group@g.us');
|
||||
});
|
||||
|
||||
it('returns empty array when no chats exist', () => {
|
||||
const groups = getAvailableGroups();
|
||||
expect(groups).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,216 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
isSenderAllowed,
|
||||
isTriggerAllowed,
|
||||
loadSenderAllowlist,
|
||||
SenderAllowlistConfig,
|
||||
shouldDropMessage,
|
||||
} from './sender-allowlist.js';
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
function cfgPath(name = 'sender-allowlist.json'): string {
|
||||
return path.join(tmpDir, name);
|
||||
}
|
||||
|
||||
function writeConfig(config: unknown, name?: string): string {
|
||||
const p = cfgPath(name);
|
||||
fs.writeFileSync(p, JSON.stringify(config));
|
||||
return p;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'allowlist-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('loadSenderAllowlist', () => {
|
||||
it('returns allow-all defaults when file is missing', () => {
|
||||
const cfg = loadSenderAllowlist(cfgPath());
|
||||
expect(cfg.default.allow).toBe('*');
|
||||
expect(cfg.default.mode).toBe('trigger');
|
||||
expect(cfg.logDenied).toBe(true);
|
||||
});
|
||||
|
||||
it('loads allow=* config', () => {
|
||||
const p = writeConfig({
|
||||
default: { allow: '*', mode: 'trigger' },
|
||||
chats: {},
|
||||
logDenied: false,
|
||||
});
|
||||
const cfg = loadSenderAllowlist(p);
|
||||
expect(cfg.default.allow).toBe('*');
|
||||
expect(cfg.logDenied).toBe(false);
|
||||
});
|
||||
|
||||
it('loads allow=[] (deny all)', () => {
|
||||
const p = writeConfig({
|
||||
default: { allow: [], mode: 'trigger' },
|
||||
chats: {},
|
||||
});
|
||||
const cfg = loadSenderAllowlist(p);
|
||||
expect(cfg.default.allow).toEqual([]);
|
||||
});
|
||||
|
||||
it('loads allow=[list]', () => {
|
||||
const p = writeConfig({
|
||||
default: { allow: ['alice', 'bob'], mode: 'drop' },
|
||||
chats: {},
|
||||
});
|
||||
const cfg = loadSenderAllowlist(p);
|
||||
expect(cfg.default.allow).toEqual(['alice', 'bob']);
|
||||
expect(cfg.default.mode).toBe('drop');
|
||||
});
|
||||
|
||||
it('per-chat override beats default', () => {
|
||||
const p = writeConfig({
|
||||
default: { allow: '*', mode: 'trigger' },
|
||||
chats: { 'group-a': { allow: ['alice'], mode: 'drop' } },
|
||||
});
|
||||
const cfg = loadSenderAllowlist(p);
|
||||
expect(cfg.chats['group-a'].allow).toEqual(['alice']);
|
||||
expect(cfg.chats['group-a'].mode).toBe('drop');
|
||||
});
|
||||
|
||||
it('returns allow-all on invalid JSON', () => {
|
||||
const p = cfgPath();
|
||||
fs.writeFileSync(p, '{ not valid json }}}');
|
||||
const cfg = loadSenderAllowlist(p);
|
||||
expect(cfg.default.allow).toBe('*');
|
||||
});
|
||||
|
||||
it('returns allow-all on invalid schema', () => {
|
||||
const p = writeConfig({ default: { oops: true } });
|
||||
const cfg = loadSenderAllowlist(p);
|
||||
expect(cfg.default.allow).toBe('*');
|
||||
});
|
||||
|
||||
it('rejects non-string allow array items', () => {
|
||||
const p = writeConfig({
|
||||
default: { allow: [123, null, true], mode: 'trigger' },
|
||||
chats: {},
|
||||
});
|
||||
const cfg = loadSenderAllowlist(p);
|
||||
expect(cfg.default.allow).toBe('*'); // falls back to default
|
||||
});
|
||||
|
||||
it('skips invalid per-chat entries', () => {
|
||||
const p = writeConfig({
|
||||
default: { allow: '*', mode: 'trigger' },
|
||||
chats: {
|
||||
good: { allow: ['alice'], mode: 'trigger' },
|
||||
bad: { allow: 123 },
|
||||
},
|
||||
});
|
||||
const cfg = loadSenderAllowlist(p);
|
||||
expect(cfg.chats['good']).toBeDefined();
|
||||
expect(cfg.chats['bad']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSenderAllowed', () => {
|
||||
it('allow=* allows any sender', () => {
|
||||
const cfg: SenderAllowlistConfig = {
|
||||
default: { allow: '*', mode: 'trigger' },
|
||||
chats: {},
|
||||
logDenied: true,
|
||||
};
|
||||
expect(isSenderAllowed('g1', 'anyone', cfg)).toBe(true);
|
||||
});
|
||||
|
||||
it('allow=[] denies any sender', () => {
|
||||
const cfg: SenderAllowlistConfig = {
|
||||
default: { allow: [], mode: 'trigger' },
|
||||
chats: {},
|
||||
logDenied: true,
|
||||
};
|
||||
expect(isSenderAllowed('g1', 'anyone', cfg)).toBe(false);
|
||||
});
|
||||
|
||||
it('allow=[list] allows exact match only', () => {
|
||||
const cfg: SenderAllowlistConfig = {
|
||||
default: { allow: ['alice', 'bob'], mode: 'trigger' },
|
||||
chats: {},
|
||||
logDenied: true,
|
||||
};
|
||||
expect(isSenderAllowed('g1', 'alice', cfg)).toBe(true);
|
||||
expect(isSenderAllowed('g1', 'eve', cfg)).toBe(false);
|
||||
});
|
||||
|
||||
it('uses per-chat entry over default', () => {
|
||||
const cfg: SenderAllowlistConfig = {
|
||||
default: { allow: '*', mode: 'trigger' },
|
||||
chats: { g1: { allow: ['alice'], mode: 'trigger' } },
|
||||
logDenied: true,
|
||||
};
|
||||
expect(isSenderAllowed('g1', 'bob', cfg)).toBe(false);
|
||||
expect(isSenderAllowed('g2', 'bob', cfg)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldDropMessage', () => {
|
||||
it('returns false for trigger mode', () => {
|
||||
const cfg: SenderAllowlistConfig = {
|
||||
default: { allow: '*', mode: 'trigger' },
|
||||
chats: {},
|
||||
logDenied: true,
|
||||
};
|
||||
expect(shouldDropMessage('g1', cfg)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for drop mode', () => {
|
||||
const cfg: SenderAllowlistConfig = {
|
||||
default: { allow: '*', mode: 'drop' },
|
||||
chats: {},
|
||||
logDenied: true,
|
||||
};
|
||||
expect(shouldDropMessage('g1', cfg)).toBe(true);
|
||||
});
|
||||
|
||||
it('per-chat mode override', () => {
|
||||
const cfg: SenderAllowlistConfig = {
|
||||
default: { allow: '*', mode: 'trigger' },
|
||||
chats: { g1: { allow: '*', mode: 'drop' } },
|
||||
logDenied: true,
|
||||
};
|
||||
expect(shouldDropMessage('g1', cfg)).toBe(true);
|
||||
expect(shouldDropMessage('g2', cfg)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTriggerAllowed', () => {
|
||||
it('allows trigger for allowed sender', () => {
|
||||
const cfg: SenderAllowlistConfig = {
|
||||
default: { allow: ['alice'], mode: 'trigger' },
|
||||
chats: {},
|
||||
logDenied: false,
|
||||
};
|
||||
expect(isTriggerAllowed('g1', 'alice', cfg)).toBe(true);
|
||||
});
|
||||
|
||||
it('denies trigger for disallowed sender', () => {
|
||||
const cfg: SenderAllowlistConfig = {
|
||||
default: { allow: ['alice'], mode: 'trigger' },
|
||||
chats: {},
|
||||
logDenied: false,
|
||||
};
|
||||
expect(isTriggerAllowed('g1', 'eve', cfg)).toBe(false);
|
||||
});
|
||||
|
||||
it('logs when logDenied is true', () => {
|
||||
const cfg: SenderAllowlistConfig = {
|
||||
default: { allow: ['alice'], mode: 'trigger' },
|
||||
chats: {},
|
||||
logDenied: true,
|
||||
};
|
||||
isTriggerAllowed('g1', 'eve', cfg);
|
||||
// Logger.debug is called — we just verify no crash; logger is a real pino instance
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
import fs from 'fs';
|
||||
|
||||
import { SENDER_ALLOWLIST_PATH } from './config.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
export interface ChatAllowlistEntry {
|
||||
allow: '*' | string[];
|
||||
mode: 'trigger' | 'drop';
|
||||
}
|
||||
|
||||
export interface SenderAllowlistConfig {
|
||||
default: ChatAllowlistEntry;
|
||||
chats: Record<string, ChatAllowlistEntry>;
|
||||
logDenied: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: SenderAllowlistConfig = {
|
||||
default: { allow: '*', mode: 'trigger' },
|
||||
chats: {},
|
||||
logDenied: true,
|
||||
};
|
||||
|
||||
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 validMode = e.mode === 'trigger' || e.mode === 'drop';
|
||||
return validAllow && validMode;
|
||||
}
|
||||
|
||||
export function loadSenderAllowlist(pathOverride?: string): SenderAllowlistConfig {
|
||||
const filePath = pathOverride ?? SENDER_ALLOWLIST_PATH;
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
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');
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
logger.warn({ path: filePath }, 'sender-allowlist: invalid JSON');
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
if (!isValidEntry(obj.default)) {
|
||||
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>)) {
|
||||
if (isValidEntry(entry)) {
|
||||
chats[jid] = entry;
|
||||
} else {
|
||||
logger.warn({ jid, path: filePath }, 'sender-allowlist: skipping invalid chat entry');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
default: obj.default as ChatAllowlistEntry,
|
||||
chats,
|
||||
logDenied: obj.logDenied !== false,
|
||||
};
|
||||
}
|
||||
|
||||
function getEntry(chatJid: string, cfg: SenderAllowlistConfig): ChatAllowlistEntry {
|
||||
return cfg.chats[chatJid] ?? cfg.default;
|
||||
}
|
||||
|
||||
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 {
|
||||
return getEntry(chatJid, cfg).mode === 'drop';
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { execFile } from 'child_process';
|
||||
import path from 'path';
|
||||
|
||||
import { logger } from './logger.js';
|
||||
|
||||
const CLEANUP_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const SCRIPT_PATH = path.resolve(process.cwd(), 'scripts/cleanup-sessions.sh');
|
||||
|
||||
function runCleanup(): void {
|
||||
execFile('/bin/bash', [SCRIPT_PATH], { timeout: 60_000 }, (err, stdout) => {
|
||||
if (err) {
|
||||
logger.error({ err }, 'Session cleanup failed');
|
||||
return;
|
||||
}
|
||||
const summary = stdout.trim().split('\n').pop();
|
||||
if (summary) logger.info(summary);
|
||||
});
|
||||
}
|
||||
|
||||
export function startSessionCleanup(): void {
|
||||
// Run once at startup (delayed 30s to not compete with init)
|
||||
setTimeout(runCleanup, 30_000);
|
||||
// Then every 24 hours
|
||||
setInterval(runCleanup, CLEANUP_INTERVAL);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { _initTestDatabase, createTask, getTaskById } from './db.js';
|
||||
import { _resetSchedulerLoopForTests, computeNextRun, startSchedulerLoop } from './task-scheduler.js';
|
||||
|
||||
describe('task scheduler', () => {
|
||||
beforeEach(() => {
|
||||
_initTestDatabase();
|
||||
_resetSchedulerLoopForTests();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('pauses due tasks with invalid group folders to prevent retry churn', async () => {
|
||||
createTask({
|
||||
id: 'task-invalid-folder',
|
||||
group_folder: '../../outside',
|
||||
chat_jid: 'bad@g.us',
|
||||
prompt: 'run',
|
||||
schedule_type: 'once',
|
||||
schedule_value: '2026-02-22T00:00:00.000Z',
|
||||
context_mode: 'isolated',
|
||||
next_run: new Date(Date.now() - 60_000).toISOString(),
|
||||
status: 'active',
|
||||
created_at: '2026-02-22T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const enqueueTask = vi.fn((_groupJid: string, _taskId: string, fn: () => Promise<void>) => {
|
||||
void fn();
|
||||
});
|
||||
|
||||
startSchedulerLoop({
|
||||
registeredGroups: () => ({}),
|
||||
getSessions: () => ({}),
|
||||
queue: { enqueueTask } as any,
|
||||
onProcess: () => {},
|
||||
sendMessage: async () => {},
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
const task = getTaskById('task-invalid-folder');
|
||||
expect(task?.status).toBe('paused');
|
||||
});
|
||||
|
||||
it('computeNextRun anchors interval tasks to scheduled time to prevent drift', () => {
|
||||
const scheduledTime = new Date(Date.now() - 2000).toISOString(); // 2s ago
|
||||
const task = {
|
||||
id: 'drift-test',
|
||||
group_folder: 'test',
|
||||
chat_jid: 'test@g.us',
|
||||
prompt: 'test',
|
||||
schedule_type: 'interval' as const,
|
||||
schedule_value: '60000', // 1 minute
|
||||
context_mode: 'isolated' as const,
|
||||
next_run: scheduledTime,
|
||||
last_run: null,
|
||||
last_result: null,
|
||||
status: 'active' as const,
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
const nextRun = computeNextRun(task);
|
||||
expect(nextRun).not.toBeNull();
|
||||
|
||||
// Should be anchored to scheduledTime + 60s, NOT Date.now() + 60s
|
||||
const expected = new Date(scheduledTime).getTime() + 60000;
|
||||
expect(new Date(nextRun!).getTime()).toBe(expected);
|
||||
});
|
||||
|
||||
it('computeNextRun returns null for once-tasks', () => {
|
||||
const task = {
|
||||
id: 'once-test',
|
||||
group_folder: 'test',
|
||||
chat_jid: 'test@g.us',
|
||||
prompt: 'test',
|
||||
schedule_type: 'once' as const,
|
||||
schedule_value: '2026-01-01T00:00:00.000Z',
|
||||
context_mode: 'isolated' as const,
|
||||
next_run: new Date(Date.now() - 1000).toISOString(),
|
||||
last_run: null,
|
||||
last_result: null,
|
||||
status: 'active' as const,
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
expect(computeNextRun(task)).toBeNull();
|
||||
});
|
||||
|
||||
it('computeNextRun skips missed intervals without infinite loop', () => {
|
||||
// Task was due 10 intervals ago (missed)
|
||||
const ms = 60000;
|
||||
const missedBy = ms * 10;
|
||||
const scheduledTime = new Date(Date.now() - missedBy).toISOString();
|
||||
|
||||
const task = {
|
||||
id: 'skip-test',
|
||||
group_folder: 'test',
|
||||
chat_jid: 'test@g.us',
|
||||
prompt: 'test',
|
||||
schedule_type: 'interval' as const,
|
||||
schedule_value: String(ms),
|
||||
context_mode: 'isolated' as const,
|
||||
next_run: scheduledTime,
|
||||
last_run: null,
|
||||
last_result: null,
|
||||
status: 'active' as const,
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
const nextRun = computeNextRun(task);
|
||||
expect(nextRun).not.toBeNull();
|
||||
// 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;
|
||||
expect(offset).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,240 +0,0 @@
|
||||
import { ChildProcess } from 'child_process';
|
||||
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 { GroupQueue } from './group-queue.js';
|
||||
import { resolveGroupFolderPath } from './group-folder.js';
|
||||
import { logger } from './logger.js';
|
||||
import { RegisteredGroup, ScheduledTask } from './types.js';
|
||||
|
||||
/**
|
||||
* Compute the next run time for a recurring task, anchored to the
|
||||
* task's scheduled time rather than Date.now() to prevent cumulative
|
||||
* drift on interval-based tasks.
|
||||
*
|
||||
* Co-authored-by: @community-pr-601
|
||||
*/
|
||||
export function computeNextRun(task: ScheduledTask): string | null {
|
||||
if (task.schedule_type === 'once') return null;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (task.schedule_type === 'cron') {
|
||||
const interval = CronExpressionParser.parse(task.schedule_value, {
|
||||
tz: TIMEZONE,
|
||||
});
|
||||
return interval.next().toISOString();
|
||||
}
|
||||
|
||||
if (task.schedule_type === 'interval') {
|
||||
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');
|
||||
return new Date(now + 60_000).toISOString();
|
||||
}
|
||||
// Anchor to the scheduled time, not now, to prevent drift.
|
||||
// Skip past any missed intervals so we always land in the future.
|
||||
let next = new Date(task.next_run!).getTime() + ms;
|
||||
while (next <= now) {
|
||||
next += ms;
|
||||
}
|
||||
return new Date(next).toISOString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface SchedulerDependencies {
|
||||
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||
getSessions: () => Record<string, string>;
|
||||
queue: GroupQueue;
|
||||
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> {
|
||||
const startTime = Date.now();
|
||||
let groupDir: string;
|
||||
try {
|
||||
groupDir = resolveGroupFolderPath(task.group_folder);
|
||||
} catch (err) {
|
||||
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');
|
||||
logTaskRun({
|
||||
task_id: task.id,
|
||||
run_at: new Date().toISOString(),
|
||||
duration_ms: Date.now() - startTime,
|
||||
status: 'error',
|
||||
result: null,
|
||||
error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
fs.mkdirSync(groupDir, { recursive: true });
|
||||
|
||||
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);
|
||||
|
||||
if (!group) {
|
||||
logger.error({ taskId: task.id, groupFolder: task.group_folder }, 'Group not found for task');
|
||||
logTaskRun({
|
||||
task_id: task.id,
|
||||
run_at: new Date().toISOString(),
|
||||
duration_ms: Date.now() - startTime,
|
||||
status: 'error',
|
||||
result: null,
|
||||
error: `Group not found: ${task.group_folder}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Update tasks snapshot for container to read (filtered by group)
|
||||
const isMain = group.isMain === true;
|
||||
const tasks = getAllTasks();
|
||||
writeTasksSnapshot(
|
||||
task.group_folder,
|
||||
isMain,
|
||||
tasks.map((t) => ({
|
||||
id: t.id,
|
||||
groupFolder: t.group_folder,
|
||||
prompt: t.prompt,
|
||||
script: t.script,
|
||||
schedule_type: t.schedule_type,
|
||||
schedule_value: t.schedule_value,
|
||||
status: t.status,
|
||||
next_run: t.next_run,
|
||||
})),
|
||||
);
|
||||
|
||||
let result: string | null = null;
|
||||
let error: string | null = null;
|
||||
|
||||
// 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;
|
||||
|
||||
// After the task produces a result, close the container promptly.
|
||||
// Tasks are single-turn — no need to wait IDLE_TIMEOUT (30 min) for the
|
||||
// query loop to time out. A short delay handles any final MCP calls.
|
||||
const TASK_CLOSE_DELAY_MS = 10000;
|
||||
let closeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const scheduleClose = () => {
|
||||
if (closeTimer) return; // already scheduled
|
||||
closeTimer = setTimeout(() => {
|
||||
logger.debug({ taskId: task.id }, 'Closing task container after result');
|
||||
deps.queue.closeStdin(task.chat_jid);
|
||||
}, TASK_CLOSE_DELAY_MS);
|
||||
};
|
||||
|
||||
try {
|
||||
const output = await runContainerAgent(
|
||||
group,
|
||||
{
|
||||
prompt: task.prompt,
|
||||
sessionId,
|
||||
groupFolder: task.group_folder,
|
||||
chatJid: task.chat_jid,
|
||||
isMain,
|
||||
isScheduledTask: true,
|
||||
assistantName: ASSISTANT_NAME,
|
||||
script: task.script || undefined,
|
||||
},
|
||||
(proc, containerName) => deps.onProcess(task.chat_jid, proc, containerName, task.group_folder),
|
||||
async (streamedOutput: ContainerOutput) => {
|
||||
if (streamedOutput.result) {
|
||||
result = streamedOutput.result;
|
||||
// Forward result to user (sendMessage handles formatting)
|
||||
await deps.sendMessage(task.chat_jid, streamedOutput.result);
|
||||
scheduleClose();
|
||||
}
|
||||
if (streamedOutput.status === 'success') {
|
||||
deps.queue.notifyIdle(task.chat_jid);
|
||||
scheduleClose(); // Close promptly even when result is null (e.g. IPC-only tasks)
|
||||
}
|
||||
if (streamedOutput.status === 'error') {
|
||||
error = streamedOutput.error || 'Unknown error';
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (closeTimer) clearTimeout(closeTimer);
|
||||
|
||||
if (output.status === 'error') {
|
||||
error = output.error || 'Unknown error';
|
||||
} else if (output.result) {
|
||||
// Result was already forwarded to the user via the streaming callback above
|
||||
result = output.result;
|
||||
}
|
||||
|
||||
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);
|
||||
logger.error({ taskId: task.id, error }, 'Task failed');
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
logTaskRun({
|
||||
task_id: task.id,
|
||||
run_at: new Date().toISOString(),
|
||||
duration_ms: durationMs,
|
||||
status: error ? 'error' : 'success',
|
||||
result,
|
||||
error,
|
||||
});
|
||||
|
||||
const nextRun = computeNextRun(task);
|
||||
const resultSummary = error ? `Error: ${error}` : result ? result.slice(0, 200) : 'Completed';
|
||||
updateTaskAfterRun(task.id, nextRun, resultSummary);
|
||||
}
|
||||
|
||||
let schedulerRunning = false;
|
||||
|
||||
export function startSchedulerLoop(deps: SchedulerDependencies): void {
|
||||
if (schedulerRunning) {
|
||||
logger.debug('Scheduler loop already running, skipping duplicate start');
|
||||
return;
|
||||
}
|
||||
schedulerRunning = true;
|
||||
logger.info('Scheduler loop started');
|
||||
|
||||
const loop = async () => {
|
||||
try {
|
||||
const dueTasks = getDueTasks();
|
||||
if (dueTasks.length > 0) {
|
||||
logger.info({ count: dueTasks.length }, 'Found due tasks');
|
||||
}
|
||||
|
||||
for (const task of dueTasks) {
|
||||
// Re-check task status in case it was paused/cancelled
|
||||
const currentTask = getTaskById(task.id);
|
||||
if (!currentTask || currentTask.status !== 'active') {
|
||||
continue;
|
||||
}
|
||||
|
||||
deps.queue.enqueueTask(currentTask.chat_jid, currentTask.id, () => runTask(currentTask, deps));
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Error in scheduler loop');
|
||||
}
|
||||
|
||||
setTimeout(loop, SCHEDULER_POLL_INTERVAL);
|
||||
};
|
||||
|
||||
loop();
|
||||
}
|
||||
|
||||
/** @internal - for tests only. */
|
||||
export function _resetSchedulerLoopForTests(): void {
|
||||
schedulerRunning = false;
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
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');
|
||||
expect(result).toContain('1:30');
|
||||
expect(result).toContain('PM');
|
||||
expect(result).toContain('Feb');
|
||||
expect(result).toContain('2026');
|
||||
});
|
||||
|
||||
it('handles different timezones', () => {
|
||||
// Same UTC time should produce different local times
|
||||
const utc = '2026-06-15T12:00:00.000Z';
|
||||
const ny = formatLocalTime(utc, 'America/New_York');
|
||||
const tokyo = formatLocalTime(utc, 'Asia/Tokyo');
|
||||
// NY is UTC-4 in summer (EDT), Tokyo is UTC+9
|
||||
expect(ny).toContain('8:00');
|
||||
expect(tokyo).toContain('9:00');
|
||||
});
|
||||
|
||||
it('does not throw on invalid timezone, falls back to UTC', () => {
|
||||
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');
|
||||
expect(result).toContain('PM');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidTimezone', () => {
|
||||
it('accepts valid IANA identifiers', () => {
|
||||
expect(isValidTimezone('America/New_York')).toBe(true);
|
||||
expect(isValidTimezone('UTC')).toBe(true);
|
||||
expect(isValidTimezone('Asia/Tokyo')).toBe(true);
|
||||
expect(isValidTimezone('Asia/Jerusalem')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid timezone strings', () => {
|
||||
expect(isValidTimezone('IST-2')).toBe(false);
|
||||
expect(isValidTimezone('XYZ+3')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty and garbage strings', () => {
|
||||
expect(isValidTimezone('')).toBe(false);
|
||||
expect(isValidTimezone('NotATimezone')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTimezone', () => {
|
||||
it('returns the timezone if valid', () => {
|
||||
expect(resolveTimezone('America/New_York')).toBe('America/New_York');
|
||||
});
|
||||
|
||||
it('falls back to UTC for invalid timezone', () => {
|
||||
expect(resolveTimezone('IST-2')).toBe('UTC');
|
||||
expect(resolveTimezone('')).toBe('UTC');
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* Check whether a timezone string is a valid IANA identifier
|
||||
* that Intl.DateTimeFormat can use.
|
||||
*/
|
||||
export function isValidTimezone(tz: string): boolean {
|
||||
try {
|
||||
Intl.DateTimeFormat(undefined, { timeZone: tz });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the given timezone if valid IANA, otherwise fall back to UTC.
|
||||
*/
|
||||
export function resolveTimezone(tz: string): string {
|
||||
return isValidTimezone(tz) ? tz : 'UTC';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a UTC ISO timestamp to a localized display string.
|
||||
* Uses the Intl API (no external dependencies).
|
||||
* Falls back to UTC if the timezone is invalid.
|
||||
*/
|
||||
export function formatLocalTime(utcIso: string, timezone: string): string {
|
||||
const date = new Date(utcIso);
|
||||
return date.toLocaleString('en-US', {
|
||||
timeZone: resolveTimezone(timezone),
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
}
|
||||
112
src/v1/types.ts
112
src/v1/types.ts
@@ -1,112 +0,0 @@
|
||||
export interface AdditionalMount {
|
||||
hostPath: string; // Absolute path on host (supports ~ for home)
|
||||
containerPath?: string; // Optional — defaults to basename of hostPath. Mounted at /workspace/extra/{value}
|
||||
readonly?: boolean; // Default: true for safety
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount Allowlist - Security configuration for additional mounts
|
||||
* This file should be stored at ~/.config/nanoclaw/mount-allowlist.json
|
||||
* and is NOT mounted into any container, making it tamper-proof from agents.
|
||||
*/
|
||||
export interface MountAllowlist {
|
||||
// Directories that can be mounted into containers
|
||||
allowedRoots: AllowedRoot[];
|
||||
// Glob patterns for paths that should never be mounted (e.g., ".ssh", ".gnupg")
|
||||
blockedPatterns: string[];
|
||||
// If true, non-main groups can only mount read-only regardless of config
|
||||
nonMainReadOnly: boolean;
|
||||
}
|
||||
|
||||
export interface AllowedRoot {
|
||||
// Absolute path or ~ for home (e.g., "~/projects", "/var/repos")
|
||||
path: string;
|
||||
// Whether read-write mounts are allowed under this root
|
||||
allowReadWrite: boolean;
|
||||
// Optional description for documentation
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ContainerConfig {
|
||||
additionalMounts?: AdditionalMount[];
|
||||
timeout?: number; // Default: 300000 (5 minutes)
|
||||
}
|
||||
|
||||
export interface RegisteredGroup {
|
||||
name: string;
|
||||
folder: string;
|
||||
trigger: string;
|
||||
added_at: string;
|
||||
containerConfig?: ContainerConfig;
|
||||
requiresTrigger?: boolean; // Default: true for groups, false for solo chats
|
||||
isMain?: boolean; // True for the main control group (no trigger, elevated privileges)
|
||||
}
|
||||
|
||||
export interface NewMessage {
|
||||
id: string;
|
||||
chat_jid: string;
|
||||
sender: string;
|
||||
sender_name: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
is_from_me?: boolean;
|
||||
is_bot_message?: boolean;
|
||||
thread_id?: string;
|
||||
reply_to_message_id?: string;
|
||||
reply_to_message_content?: string;
|
||||
reply_to_sender_name?: string;
|
||||
}
|
||||
|
||||
export interface ScheduledTask {
|
||||
id: string;
|
||||
group_folder: string;
|
||||
chat_jid: string;
|
||||
prompt: string;
|
||||
script?: string | null;
|
||||
schedule_type: 'cron' | 'interval' | 'once';
|
||||
schedule_value: string;
|
||||
context_mode: 'group' | 'isolated';
|
||||
next_run: string | null;
|
||||
last_run: string | null;
|
||||
last_result: string | null;
|
||||
status: 'active' | 'paused' | 'completed';
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TaskRunLog {
|
||||
task_id: string;
|
||||
run_at: string;
|
||||
duration_ms: number;
|
||||
status: 'success' | 'error';
|
||||
result: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// --- Channel abstraction ---
|
||||
|
||||
export interface Channel {
|
||||
name: string;
|
||||
connect(): Promise<void>;
|
||||
sendMessage(jid: string, text: string): Promise<void>;
|
||||
isConnected(): boolean;
|
||||
ownsJid(jid: string): boolean;
|
||||
disconnect(): Promise<void>;
|
||||
// Optional: typing indicator. Channels that support it implement it.
|
||||
setTyping?(jid: string, isTyping: boolean): Promise<void>;
|
||||
// Optional: sync group/chat names from the platform.
|
||||
syncGroups?(force: boolean): Promise<void>;
|
||||
}
|
||||
|
||||
// Callback type that channels use to deliver inbound messages
|
||||
export type OnInboundMessage = (chatJid: string, message: NewMessage) => void;
|
||||
|
||||
// Callback for chat metadata discovery.
|
||||
// name is optional — channels that deliver names inline (Telegram) pass it here;
|
||||
// channels that sync names separately (via syncGroups) omit it.
|
||||
export type OnChatMetadata = (
|
||||
chatJid: string,
|
||||
timestamp: string,
|
||||
name?: string,
|
||||
channel?: string,
|
||||
isGroup?: boolean,
|
||||
) => void;
|
||||
Reference in New Issue
Block a user