From 4a34a9c80202b4d64f4ebf59d3d43a5ce3d97068 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Tue, 10 Mar 2026 20:45:48 +0900 Subject: [PATCH] feat: add Codex CLI support, dual Discord bots, and Discord typing fix - Add Codex as alternative agent type (agentType: 'codex' per group) - Codex container runner with session resume and 100-turn limit - Dual Discord bot support (DISCORD_BOT_TOKEN + DISCORD_CODEX_BOT_TOKEN) - Each bot handles only its agentType's registered groups - Discord typing indicator refreshes every 8s (was single-shot, expired after ~10s) - Agent runner 100-turn limit to prevent infinite bot-to-bot loops - Apple Container runtime support (convert-to-apple-container skill) - Session commands (/compact) support - DB migration for agent_type column --- .env.example | 7 + container/Dockerfile | 12 +- container/Dockerfile.codex | 43 ++ container/agent-runner/src/index.ts | 165 +++++- container/build.sh | 11 +- container/codex-runner/package.json | 16 + container/codex-runner/src/index.ts | 294 ++++++++++ container/codex-runner/tsconfig.json | 15 + package-lock.json | 292 +++++++++- package.json | 1 + src/channels/discord.test.ts | 781 +++++++++++++++++++++++++++ src/channels/discord.ts | 291 ++++++++++ src/channels/index.ts | 1 + src/config.ts | 2 + src/container-runner.ts | 130 +++-- src/container-runtime.test.ts | 72 ++- src/container-runtime.ts | 140 ++--- src/db.test.ts | 28 +- src/db.ts | 29 +- src/group-queue.ts | 7 +- src/index.ts | 85 ++- src/session-commands.test.ts | 214 ++++++++ src/session-commands.ts | 143 +++++ src/types.ts | 3 + 24 files changed, 2606 insertions(+), 176 deletions(-) create mode 100644 container/Dockerfile.codex create mode 100644 container/codex-runner/package.json create mode 100644 container/codex-runner/src/index.ts create mode 100644 container/codex-runner/tsconfig.json create mode 100644 src/channels/discord.test.ts create mode 100644 src/channels/discord.ts create mode 100644 src/session-commands.test.ts create mode 100644 src/session-commands.ts diff --git a/.env.example b/.env.example index 8b13789..2f05389 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,8 @@ + +# Added by skill +DISCORD_BOT_TOKEN= + +# Codex support (optional — only needed for Codex agent type) +OPENAI_API_KEY= +# CODEX_OPENAI_API_KEY= # Use this to override OPENAI_API_KEY for Codex only diff --git a/container/Dockerfile b/container/Dockerfile index e8537c3..65763df 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -7,7 +7,6 @@ FROM node:22-slim RUN apt-get update && apt-get install -y \ chromium \ fonts-liberation \ - fonts-noto-cjk \ fonts-noto-color-emoji \ libgbm1 \ libnss3 \ @@ -52,17 +51,16 @@ RUN npm run build RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/messages /workspace/ipc/tasks /workspace/ipc/input # Create entrypoint script -# Container input (prompt, group info) is passed via stdin JSON. -# Credentials are injected by the host's credential proxy — never passed here. +# Secrets are passed via stdin JSON — temp file is deleted immediately after Node reads it # Follow-up messages arrive via IPC files in /workspace/ipc/input/ -RUN printf '#!/bin/bash\nset -e\ncd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\nln -s /app/node_modules /tmp/dist/node_modules\nchmod -R a-w /tmp/dist\ncat > /tmp/input.json\nnode /tmp/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh +# Apple Container only supports directory mounts (VirtioFS), so .env cannot be +# shadowed with a host-side /dev/null file mount. Instead the entrypoint starts +# as root, uses mount --bind to shadow .env, then drops to the host user via setpriv. +RUN printf '#!/bin/bash\nset -e\n\n# Shadow .env so the agent cannot read host secrets (requires root)\nif [ "$(id -u)" = "0" ] && [ -f /workspace/project/.env ]; then\n mount --bind /dev/null /workspace/project/.env\nfi\n\n# Compile agent-runner\ncd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\nln -s /app/node_modules /tmp/dist/node_modules\nchmod -R a-w /tmp/dist\n\n# Capture stdin (secrets JSON) to temp file\ncat > /tmp/input.json\n\n# Drop privileges if running as root (main-group containers)\nif [ "$(id -u)" = "0" ] && [ -n "$RUN_UID" ]; then\n chown "$RUN_UID:$RUN_GID" /tmp/input.json /tmp/dist\n exec setpriv --reuid="$RUN_UID" --regid="$RUN_GID" --clear-groups -- node /tmp/dist/index.js < /tmp/input.json\nfi\n\nexec node /tmp/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh # Set ownership to node user (non-root) for writable directories RUN chown -R node:node /workspace && chmod 777 /home/node -# Switch to non-root user (required for --dangerously-skip-permissions) -USER node - # Set working directory to group workspace WORKDIR /workspace/group diff --git a/container/Dockerfile.codex b/container/Dockerfile.codex new file mode 100644 index 0000000..b921f29 --- /dev/null +++ b/container/Dockerfile.codex @@ -0,0 +1,43 @@ +# NanoClaw Codex Agent Container +# Runs Codex CLI in isolated Linux VM + +FROM node:22-slim + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + curl \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Install Codex CLI globally +RUN npm install -g @openai/codex + +# Create app directory +WORKDIR /app + +# Copy package files first for better caching +COPY codex-runner/package*.json ./ + +# Install dependencies +RUN npm install + +# Copy source code +COPY codex-runner/ ./ + +# Build TypeScript +RUN npm run build + +# Create workspace directories +RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/messages /workspace/ipc/tasks /workspace/ipc/input + +# Create entrypoint script — use pre-built dist/ from image, +# but if /app/src is mounted with newer source, recompile on the fly. +RUN printf '#!/bin/bash\nset -e\n\n# Use pre-built dist if available, recompile only if source was mounted\nif [ -f /app/dist/index.js ]; then\n RUNNER_DIR=/app/dist\nelse\n cd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\n ln -s /app/node_modules /tmp/dist/node_modules\n RUNNER_DIR=/tmp/dist\nfi\n\n# Capture stdin to temp file\ncat > /tmp/input.json\n\n# Drop privileges if running as root\nif [ "$(id -u)" = "0" ] && [ -n "$RUN_UID" ]; then\n chown "$RUN_UID:$RUN_GID" /tmp/input.json\n exec setpriv --reuid="$RUN_UID" --regid="$RUN_GID" --clear-groups -- node "$RUNNER_DIR/index.js" < /tmp/input.json\nfi\n\nexec node "$RUNNER_DIR/index.js" < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh + +# Set ownership to node user +RUN chown -R node:node /workspace && chmod 777 /home/node + +# Set working directory to group workspace +WORKDIR /workspace/group + +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 96cb4a4..0921d5c 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -16,7 +16,7 @@ import fs from 'fs'; import path from 'path'; -import { query, HookCallback, PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk'; +import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk'; import { fileURLToPath } from 'url'; interface ContainerInput { @@ -27,6 +27,7 @@ interface ContainerInput { isMain: boolean; isScheduledTask?: boolean; assistantName?: string; + secrets?: Record; } interface ContainerOutput { @@ -55,6 +56,7 @@ interface SDKUserMessage { } const IPC_INPUT_DIR = '/workspace/ipc/input'; +const MAX_TURNS = 100; const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close'); const IPC_POLL_MS = 500; @@ -184,6 +186,30 @@ function createPreCompactHook(assistantName?: string): HookCallback { }; } +// Secrets to strip from Bash tool subprocess environments. +// These are needed by claude-code for API auth but should never +// be visible to commands Kit runs. +const SECRET_ENV_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN']; + +function createSanitizeBashHook(): HookCallback { + return async (input, _toolUseId, _context) => { + const preInput = input as PreToolUseHookInput; + const command = (preInput.tool_input as { command?: string })?.command; + if (!command) return {}; + + const unsetPrefix = `unset ${SECRET_ENV_VARS.join(' ')} 2>/dev/null; `; + return { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + updatedInput: { + ...(preInput.tool_input as Record), + command: unsetPrefix + command, + }, + }, + }; + }; +} + function sanitizeFilename(summary: string): string { return summary .toLowerCase() @@ -389,10 +415,29 @@ async function runQuery( log(`Additional directories: ${extraDirs.join(', ')}`); } + // Model and thinking configuration from environment + const model = process.env.CLAUDE_MODEL || undefined; + const thinkingType = process.env.CLAUDE_THINKING || undefined; // 'adaptive' | 'enabled' | 'disabled' + const thinkingBudget = process.env.CLAUDE_THINKING_BUDGET + ? parseInt(process.env.CLAUDE_THINKING_BUDGET, 10) + : undefined; + const effort = (process.env.CLAUDE_EFFORT as 'low' | 'medium' | 'high' | 'max') || undefined; + const thinking = thinkingType === 'adaptive' ? { type: 'adaptive' as const } + : thinkingType === 'enabled' ? { type: 'enabled' as const, budgetTokens: thinkingBudget } + : thinkingType === 'disabled' ? { type: 'disabled' as const } + : undefined; + + if (model) log(`Using model: ${model}`); + if (thinking) log(`Thinking config: ${JSON.stringify(thinking)}`); + if (effort) log(`Effort: ${effort}`); + for await (const message of query({ prompt: stream, options: { cwd: '/workspace/group', + model, + thinking, + effort, additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined, resume: sessionId, resumeSessionAt: resumeAt, @@ -426,6 +471,7 @@ async function runQuery( }, hooks: { PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }], + PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }], }, } })) { @@ -470,6 +516,7 @@ async function main(): Promise { try { const stdinData = await readStdin(); containerInput = JSON.parse(stdinData); + // Delete the temp file the entrypoint wrote — it contains secrets try { fs.unlinkSync('/tmp/input.json'); } catch { /* may not exist */ } log(`Received input for group: ${containerInput.groupFolder}`); } catch (err) { @@ -481,9 +528,12 @@ async function main(): Promise { process.exit(1); } - // Credentials are injected by the host's credential proxy via ANTHROPIC_BASE_URL. - // No real secrets exist in the container environment. + // Build SDK env: merge secrets into process.env for the SDK only. + // Secrets never touch process.env itself, so Bash subprocesses can't see them. const sdkEnv: Record = { ...process.env }; + for (const [key, value] of Object.entries(containerInput.secrets || {})) { + sdkEnv[key] = value; + } const __dirname = path.dirname(fileURLToPath(import.meta.url)); const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js'); @@ -505,11 +555,118 @@ async function main(): Promise { prompt += '\n' + pending.join('\n'); } + // --- Slash command handling --- + // Only known session slash commands are handled here. This prevents + // accidental interception of user prompts that happen to start with '/'. + const KNOWN_SESSION_COMMANDS = new Set(['/compact']); + const trimmedPrompt = prompt.trim(); + const isSessionSlashCommand = KNOWN_SESSION_COMMANDS.has(trimmedPrompt); + + if (isSessionSlashCommand) { + log(`Handling session command: ${trimmedPrompt}`); + let slashSessionId: string | undefined; + let compactBoundarySeen = false; + let hadError = false; + let resultEmitted = false; + + try { + for await (const message of query({ + prompt: trimmedPrompt, + options: { + cwd: '/workspace/group', + resume: sessionId, + systemPrompt: undefined, + allowedTools: [], + env: sdkEnv, + permissionMode: 'bypassPermissions' as const, + allowDangerouslySkipPermissions: true, + settingSources: ['project', 'user'] as const, + hooks: { + PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }], + }, + }, + })) { + const msgType = message.type === 'system' + ? `system/${(message as { subtype?: string }).subtype}` + : message.type; + log(`[slash-cmd] type=${msgType}`); + + if (message.type === 'system' && message.subtype === 'init') { + slashSessionId = message.session_id; + log(`Session after slash command: ${slashSessionId}`); + } + + // Observe compact_boundary to confirm compaction completed + if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') { + compactBoundarySeen = true; + log('Compact boundary observed — compaction completed'); + } + + if (message.type === 'result') { + const resultSubtype = (message as { subtype?: string }).subtype; + const textResult = 'result' in message ? (message as { result?: string }).result : null; + + if (resultSubtype?.startsWith('error')) { + hadError = true; + writeOutput({ + status: 'error', + result: null, + error: textResult || 'Session command failed.', + newSessionId: slashSessionId, + }); + } else { + writeOutput({ + status: 'success', + result: textResult || 'Conversation compacted.', + newSessionId: slashSessionId, + }); + } + resultEmitted = true; + } + } + } catch (err) { + hadError = true; + const errorMsg = err instanceof Error ? err.message : String(err); + log(`Slash command error: ${errorMsg}`); + writeOutput({ status: 'error', result: null, error: errorMsg }); + } + + log(`Slash command done. compactBoundarySeen=${compactBoundarySeen}, hadError=${hadError}`); + + // Warn if compact_boundary was never observed — compaction may not have occurred + if (!hadError && !compactBoundarySeen) { + log('WARNING: compact_boundary was not observed. Compaction may not have completed.'); + } + + // Only emit final session marker if no result was emitted yet and no error occurred + if (!resultEmitted && !hadError) { + writeOutput({ + status: 'success', + result: compactBoundarySeen + ? 'Conversation compacted.' + : 'Compaction requested but compact_boundary was not observed.', + newSessionId: slashSessionId, + }); + } else if (!hadError) { + // Emit session-only marker so host updates session tracking + writeOutput({ status: 'success', result: null, newSessionId: slashSessionId }); + } + return; + } + // --- End slash command handling --- + // Query loop: run query → wait for IPC message → run new query → repeat let resumeAt: string | undefined; + let turnCount = 0; try { while (true) { - log(`Starting query (session: ${sessionId || 'new'}, resumeAt: ${resumeAt || 'latest'})...`); + turnCount++; + if (turnCount > MAX_TURNS) { + log(`Turn limit reached (${MAX_TURNS}), exiting`); + writeOutput({ status: 'success', result: '[세션 턴 제한 도달. 새 메시지로 다시 시작됩니다.]', newSessionId: sessionId }); + break; + } + log(`Starting query (session: ${sessionId || 'new'}, turn: ${turnCount}/${MAX_TURNS}, resumeAt: ${resumeAt || 'latest'})...`); const queryResult = await runQuery(prompt, sessionId, mcpServerPath, containerInput, sdkEnv, resumeAt); if (queryResult.newSessionId) { diff --git a/container/build.sh b/container/build.sh index 8321fdf..b08cb6f 100755 --- a/container/build.sh +++ b/container/build.sh @@ -8,13 +8,22 @@ cd "$SCRIPT_DIR" IMAGE_NAME="nanoclaw-agent" TAG="${1:-latest}" -CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-docker}" +CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-container}" echo "Building NanoClaw agent container image..." echo "Image: ${IMAGE_NAME}:${TAG}" ${CONTAINER_RUNTIME} build -t "${IMAGE_NAME}:${TAG}" . +# Build Codex agent image (if Dockerfile.codex exists) +if [ -f "$SCRIPT_DIR/Dockerfile.codex" ]; then + echo "" + echo "Building NanoClaw Codex agent container image..." + echo "Image: nanoclaw-codex-agent:${TAG}" + ${CONTAINER_RUNTIME} build -t "nanoclaw-codex-agent:${TAG}" -f Dockerfile.codex . + echo "Built nanoclaw-codex-agent:${TAG}" +fi + echo "" echo "Build complete!" echo "Image: ${IMAGE_NAME}:${TAG}" diff --git a/container/codex-runner/package.json b/container/codex-runner/package.json new file mode 100644 index 0000000..2a623e2 --- /dev/null +++ b/container/codex-runner/package.json @@ -0,0 +1,16 @@ +{ + "name": "nanoclaw-codex-runner", + "version": "1.0.0", + "type": "module", + "description": "Container-side Codex CLI runner for NanoClaw", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": {}, + "devDependencies": { + "@types/node": "^22.10.7", + "typescript": "^5.7.3" + } +} diff --git a/container/codex-runner/src/index.ts b/container/codex-runner/src/index.ts new file mode 100644 index 0000000..9cd68f9 --- /dev/null +++ b/container/codex-runner/src/index.ts @@ -0,0 +1,294 @@ +/** + * NanoClaw Codex Runner + * Runs inside a container, receives config via stdin, executes Codex CLI, outputs result to stdout + * + * Input protocol: + * Stdin: Full ContainerInput JSON (read until EOF) + * IPC: Follow-up messages written as JSON files to /workspace/ipc/input/ + * Sentinel: /workspace/ipc/input/_close — signals session end + * + * Stdout protocol: + * Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs. + */ + +import { spawn, ChildProcess } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +interface ContainerInput { + prompt: string; + sessionId?: string; + groupFolder: string; + chatJid: string; + isMain: boolean; + isScheduledTask?: boolean; + assistantName?: string; + agentType?: string; +} + +interface ContainerOutput { + status: 'success' | 'error'; + result: string | null; + newSessionId?: string; + error?: string; +} + +const IPC_INPUT_DIR = '/workspace/ipc/input'; +const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close'); +const IPC_POLL_MS = 500; +const MAX_TURNS = 100; + +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(`[codex-runner] ${message}`); +} + +async function readStdin(): Promise { + return new Promise((resolve, reject) => { + let data = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk: string) => { data += chunk; }); + process.stdin.on('end', () => resolve(data)); + process.stdin.on('error', reject); + }); +} + +function shouldClose(): boolean { + if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) { + try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ } + return true; + } + return false; +} + +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 []; + } +} + +function waitForIpcMessage(): Promise { + 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 Codex CLI exec command and return the result. + * If resume=true, resumes the most recent session instead of starting fresh. + */ +async function runCodexExec(prompt: string, resume: boolean): Promise<{ result: string; error?: string }> { + return new Promise((resolve) => { + const args: string[] = []; + + if (resume) { + // Resume the most recent session with a new prompt + // --dangerously-bypass-approvals-and-sandbox: fully disables sandbox + // (--full-auto forces workspace-write which blocks /workspace/extra/ mounts) + args.push( + 'exec', 'resume', '--last', + '--dangerously-bypass-approvals-and-sandbox', + '--skip-git-repo-check', + prompt, + ); + } else { + // Fresh session + // --dangerously-bypass-approvals-and-sandbox: fully disables sandbox + // (--full-auto forces workspace-write which blocks /workspace/extra/ mounts) + args.push( + 'exec', + '--dangerously-bypass-approvals-and-sandbox', + '-C', '/workspace/group', + '--skip-git-repo-check', + '--color', 'never', + prompt, + ); + } + + log(`Running: codex ${args.slice(0, 6).join(' ')}... (resume=${resume})`); + + const codex: ChildProcess = spawn('codex', args, { + stdio: ['pipe', 'pipe', 'pipe'], + cwd: '/workspace/group', + env: { ...process.env }, + }); + + let stdout = ''; + let stderr = ''; + + codex.stdout?.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + codex.stderr?.on('data', (data: Buffer) => { + const chunk = data.toString(); + stderr += chunk; + // Log stderr lines for debugging + for (const line of chunk.trim().split('\n')) { + if (line) log(line); + } + }); + + codex.on('close', (code: number | null) => { + log(`Codex exited with code ${code}`); + + if (code !== 0) { + resolve({ + result: stdout.trim() || '', + error: `Codex exited with code ${code}: ${stderr.slice(-500)}`, + }); + return; + } + + // Extract the meaningful output + const result = stdout.trim(); + resolve({ result }); + }); + + codex.on('error', (err: Error) => { + resolve({ + result: '', + error: `Failed to spawn codex: ${err.message}`, + }); + }); + + // Close stdin immediately since we pass prompt as argument + codex.stdin?.end(); + }); +} + +async function main(): Promise { + 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); + } + + fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); + // Clean up stale _close sentinel + try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ } + + // Build initial prompt + let prompt = containerInput.prompt; + if (containerInput.isScheduledTask) { + prompt = `[SCHEDULED TASK]\n\n${prompt}`; + } + const pending = drainIpcInput(); + if (pending.length > 0) { + prompt += '\n' + pending.join('\n'); + } + + // Query loop: run codex exec → wait for IPC message → repeat + // First exec is a fresh session; subsequent execs resume the last session. + let isFirstExec = true; + let turnCount = 0; + + try { + while (true) { + turnCount++; + if (turnCount > MAX_TURNS) { + log(`Turn limit reached (${MAX_TURNS}), exiting`); + writeOutput({ status: 'success', result: '[세션 턴 제한 도달. 새 메시지로 다시 시작됩니다.]' }); + break; + } + log(`Starting codex exec (first=${isFirstExec}, turn=${turnCount}/${MAX_TURNS})...`); + + const { result, error } = await runCodexExec(prompt, !isFirstExec); + isFirstExec = false; + + if (error) { + log(`Codex error: ${error}`); + writeOutput({ + status: 'error', + result: result || null, + error, + }); + } else { + writeOutput({ + status: 'success', + result: result || null, + }); + } + + // Check if close was requested + if (shouldClose()) { + log('Close sentinel detected, exiting'); + break; + } + + log('Codex exec done, waiting for next IPC message...'); + + const nextMessage = await waitForIpcMessage(); + if (nextMessage === null) { + log('Close sentinel received, exiting'); + break; + } + + log(`Got new message (${nextMessage.length} chars), starting new codex exec`); + prompt = nextMessage; + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + log(`Runner error: ${errorMessage}`); + writeOutput({ + status: 'error', + result: null, + error: errorMessage, + }); + process.exit(1); + } +} + +main(); diff --git a/container/codex-runner/tsconfig.json b/container/codex-runner/tsconfig.json new file mode 100644 index 0000000..de6431e --- /dev/null +++ b/container/codex-runner/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/package-lock.json b/package-lock.json index ef19a6c..14be4ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "better-sqlite3": "^11.8.1", "cron-parser": "^5.5.0", + "discord.js": "^14.18.0", "pino": "^9.6.0", "pino-pretty": "^13.0.0", "yaml": "^2.8.2", @@ -89,6 +90,136 @@ "node": ">=18" } }, + "node_modules/@discordjs/builders": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz", + "integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", + "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.1.1", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.3", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.16", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -915,6 +1046,39 @@ "win32" ] }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -961,12 +1125,20 @@ "version": "22.19.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitest/coverage-v8": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", @@ -1109,6 +1281,16 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1291,6 +1473,42 @@ "node": ">=8" } }, + "node_modules/discord-api-types": { + "version": "0.38.41", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.41.tgz", + "integrity": "sha512-yMECyR8j9c2fVTvCQ+Qc24pweYFIZk/XoxDOmt1UvPeSw5tK6gXBd/2hhP+FEAe9Y6ny8pRMaf618XDK4U53OQ==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.25.1", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz", + "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.13.0", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.0", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -1384,6 +1602,12 @@ "integrity": "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==", "license": "MIT" }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -1580,6 +1804,18 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", @@ -1589,6 +1825,12 @@ "node": ">=12" } }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1740,7 +1982,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2289,13 +2530,24 @@ "node": ">=14.0.0" } }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -2336,11 +2588,19 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/util-deprecate": { @@ -2355,7 +2615,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2431,7 +2690,6 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -2527,12 +2785,32 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yaml": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/package.json b/package.json index 5fae6f4..37bc97b 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dependencies": { "better-sqlite3": "^11.8.1", "cron-parser": "^5.5.0", + "discord.js": "^14.18.0", "pino": "^9.6.0", "pino-pretty": "^13.0.0", "yaml": "^2.8.2", diff --git a/src/channels/discord.test.ts b/src/channels/discord.test.ts new file mode 100644 index 0000000..b09d0d3 --- /dev/null +++ b/src/channels/discord.test.ts @@ -0,0 +1,781 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; + +// --- Mocks --- + +// Mock registry (registerChannel runs at import time) +vi.mock('./registry.js', () => ({ registerChannel: vi.fn() })); + +// Mock env reader (used by the factory, not needed in unit tests) +vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) })); + +// Mock config +vi.mock('../config.js', () => ({ + ASSISTANT_NAME: 'Andy', + TRIGGER_PATTERN: /^@Andy\b/i, +})); + +// Mock logger +vi.mock('../logger.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +// --- discord.js mock --- + +type Handler = (...args: any[]) => any; + +const clientRef = vi.hoisted(() => ({ current: null as any })); + +vi.mock('discord.js', () => { + const Events = { + MessageCreate: 'messageCreate', + ClientReady: 'ready', + Error: 'error', + }; + + const GatewayIntentBits = { + Guilds: 1, + GuildMessages: 2, + MessageContent: 4, + DirectMessages: 8, + }; + + class MockClient { + eventHandlers = new Map(); + user: any = { id: '999888777', tag: 'Andy#1234' }; + private _ready = false; + + constructor(_opts: any) { + clientRef.current = this; + } + + on(event: string, handler: Handler) { + const existing = this.eventHandlers.get(event) || []; + existing.push(handler); + this.eventHandlers.set(event, existing); + return this; + } + + once(event: string, handler: Handler) { + return this.on(event, handler); + } + + async login(_token: string) { + this._ready = true; + // Fire the ready event + const readyHandlers = this.eventHandlers.get('ready') || []; + for (const h of readyHandlers) { + h({ user: this.user }); + } + } + + isReady() { + return this._ready; + } + + channels = { + fetch: vi.fn().mockResolvedValue({ + send: vi.fn().mockResolvedValue(undefined), + sendTyping: vi.fn().mockResolvedValue(undefined), + }), + }; + + destroy() { + this._ready = false; + } + } + + // Mock TextChannel type + class TextChannel {} + + return { + Client: MockClient, + Events, + GatewayIntentBits, + TextChannel, + }; +}); + +import { DiscordChannel, DiscordChannelOpts } from './discord.js'; + +// --- Test helpers --- + +function createTestOpts( + overrides?: Partial, +): DiscordChannelOpts { + return { + onMessage: vi.fn(), + onChatMetadata: vi.fn(), + registeredGroups: vi.fn(() => ({ + 'dc:1234567890123456': { + name: 'Test Server #general', + folder: 'test-server', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + }, + })), + ...overrides, + }; +} + +function createMessage(overrides: { + channelId?: string; + content?: string; + authorId?: string; + authorUsername?: string; + authorDisplayName?: string; + memberDisplayName?: string; + isBot?: boolean; + guildName?: string; + channelName?: string; + messageId?: string; + createdAt?: Date; + attachments?: Map; + reference?: { messageId?: string }; + mentionsBotId?: boolean; +}) { + const channelId = overrides.channelId ?? '1234567890123456'; + const authorId = overrides.authorId ?? '55512345'; + const botId = '999888777'; // matches mock client user id + + const mentionsMap = new Map(); + if (overrides.mentionsBotId) { + mentionsMap.set(botId, { id: botId }); + } + + return { + channelId, + id: overrides.messageId ?? 'msg_001', + content: overrides.content ?? 'Hello everyone', + createdAt: overrides.createdAt ?? new Date('2024-01-01T00:00:00.000Z'), + author: { + id: authorId, + username: overrides.authorUsername ?? 'alice', + displayName: overrides.authorDisplayName ?? 'Alice', + bot: overrides.isBot ?? false, + }, + member: overrides.memberDisplayName + ? { displayName: overrides.memberDisplayName } + : null, + guild: overrides.guildName + ? { name: overrides.guildName } + : null, + channel: { + name: overrides.channelName ?? 'general', + messages: { + fetch: vi.fn().mockResolvedValue({ + author: { username: 'Bob', displayName: 'Bob' }, + member: { displayName: 'Bob' }, + }), + }, + }, + mentions: { + users: mentionsMap, + }, + attachments: overrides.attachments ?? new Map(), + reference: overrides.reference ?? null, + }; +} + +function currentClient() { + return clientRef.current; +} + +async function triggerMessage(message: any) { + const handlers = currentClient().eventHandlers.get('messageCreate') || []; + for (const h of handlers) await h(message); +} + +// --- Tests --- + +describe('DiscordChannel', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // --- Connection lifecycle --- + + describe('connection lifecycle', () => { + it('resolves connect() when client is ready', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + + await channel.connect(); + + expect(channel.isConnected()).toBe(true); + }); + + it('registers message handlers on connect', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + + await channel.connect(); + + expect(currentClient().eventHandlers.has('messageCreate')).toBe(true); + expect(currentClient().eventHandlers.has('error')).toBe(true); + expect(currentClient().eventHandlers.has('ready')).toBe(true); + }); + + it('disconnects cleanly', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + + await channel.connect(); + expect(channel.isConnected()).toBe(true); + + await channel.disconnect(); + expect(channel.isConnected()).toBe(false); + }); + + it('isConnected() returns false before connect', () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + + expect(channel.isConnected()).toBe(false); + }); + }); + + // --- Text message handling --- + + describe('text message handling', () => { + it('delivers message for registered channel', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const msg = createMessage({ + content: 'Hello everyone', + guildName: 'Test Server', + channelName: 'general', + }); + await triggerMessage(msg); + + expect(opts.onChatMetadata).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.any(String), + 'Test Server #general', + 'discord', + true, + ); + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ + id: 'msg_001', + chat_jid: 'dc:1234567890123456', + sender: '55512345', + sender_name: 'Alice', + content: 'Hello everyone', + is_from_me: false, + }), + ); + }); + + it('only emits metadata for unregistered channels', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const msg = createMessage({ + channelId: '9999999999999999', + content: 'Unknown channel', + guildName: 'Other Server', + }); + await triggerMessage(msg); + + expect(opts.onChatMetadata).toHaveBeenCalledWith( + 'dc:9999999999999999', + expect.any(String), + expect.any(String), + 'discord', + true, + ); + expect(opts.onMessage).not.toHaveBeenCalled(); + }); + + it('delivers bot messages with is_bot_message flag', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const msg = createMessage({ isBot: true, content: 'I am a bot' }); + await triggerMessage(msg); + + expect(opts.onMessage).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + content: 'I am a bot', + is_bot_message: true, + }), + ); + }); + + it('uses member displayName when available (server nickname)', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const msg = createMessage({ + content: 'Hi', + memberDisplayName: 'Alice Nickname', + authorDisplayName: 'Alice Global', + guildName: 'Server', + }); + await triggerMessage(msg); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ sender_name: 'Alice Nickname' }), + ); + }); + + it('falls back to author displayName when no member', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const msg = createMessage({ + content: 'Hi', + memberDisplayName: undefined, + authorDisplayName: 'Alice Global', + guildName: 'Server', + }); + await triggerMessage(msg); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ sender_name: 'Alice Global' }), + ); + }); + + it('uses sender name for DM chats (no guild)', async () => { + const opts = createTestOpts({ + registeredGroups: vi.fn(() => ({ + 'dc:1234567890123456': { + name: 'DM', + folder: 'dm', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + }, + })), + }); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const msg = createMessage({ + content: 'Hello', + guildName: undefined, + authorDisplayName: 'Alice', + }); + await triggerMessage(msg); + + expect(opts.onChatMetadata).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.any(String), + 'Alice', + 'discord', + false, + ); + }); + + it('uses guild name + channel name for server messages', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const msg = createMessage({ + content: 'Hello', + guildName: 'My Server', + channelName: 'bot-chat', + }); + await triggerMessage(msg); + + expect(opts.onChatMetadata).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.any(String), + 'My Server #bot-chat', + 'discord', + true, + ); + }); + }); + + // --- @mention translation --- + + describe('@mention translation', () => { + it('translates <@botId> mention to trigger format', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const msg = createMessage({ + content: '<@999888777> what time is it?', + mentionsBotId: true, + guildName: 'Server', + }); + await triggerMessage(msg); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ + content: '@Andy what time is it?', + }), + ); + }); + + it('does not translate if message already matches trigger', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const msg = createMessage({ + content: '@Andy hello <@999888777>', + mentionsBotId: true, + guildName: 'Server', + }); + await triggerMessage(msg); + + // Should NOT prepend @Andy — already starts with trigger + // But the <@botId> should still be stripped + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ + content: '@Andy hello', + }), + ); + }); + + it('does not translate when bot is not mentioned', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const msg = createMessage({ + content: 'hello everyone', + guildName: 'Server', + }); + await triggerMessage(msg); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ + content: 'hello everyone', + }), + ); + }); + + it('handles <@!botId> (nickname mention format)', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const msg = createMessage({ + content: '<@!999888777> check this', + mentionsBotId: true, + guildName: 'Server', + }); + await triggerMessage(msg); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ + content: '@Andy check this', + }), + ); + }); + }); + + // --- Attachments --- + + describe('attachments', () => { + it('stores image attachment with placeholder', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const attachments = new Map([ + ['att1', { name: 'photo.png', contentType: 'image/png' }], + ]); + const msg = createMessage({ + content: '', + attachments, + guildName: 'Server', + }); + await triggerMessage(msg); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ + content: '[Image: photo.png]', + }), + ); + }); + + it('stores video attachment with placeholder', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const attachments = new Map([ + ['att1', { name: 'clip.mp4', contentType: 'video/mp4' }], + ]); + const msg = createMessage({ + content: '', + attachments, + guildName: 'Server', + }); + await triggerMessage(msg); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ + content: '[Video: clip.mp4]', + }), + ); + }); + + it('stores file attachment with placeholder', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const attachments = new Map([ + ['att1', { name: 'report.pdf', contentType: 'application/pdf' }], + ]); + const msg = createMessage({ + content: '', + attachments, + guildName: 'Server', + }); + await triggerMessage(msg); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ + content: '[File: report.pdf]', + }), + ); + }); + + it('includes text content with attachments', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const attachments = new Map([ + ['att1', { name: 'photo.jpg', contentType: 'image/jpeg' }], + ]); + const msg = createMessage({ + content: 'Check this out', + attachments, + guildName: 'Server', + }); + await triggerMessage(msg); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ + content: 'Check this out\n[Image: photo.jpg]', + }), + ); + }); + + it('handles multiple attachments', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const attachments = new Map([ + ['att1', { name: 'a.png', contentType: 'image/png' }], + ['att2', { name: 'b.txt', contentType: 'text/plain' }], + ]); + const msg = createMessage({ + content: '', + attachments, + guildName: 'Server', + }); + await triggerMessage(msg); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ + content: '[Image: a.png]\n[File: b.txt]', + }), + ); + }); + }); + + // --- Reply context --- + + describe('reply context', () => { + it('includes reply author in content', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const msg = createMessage({ + content: 'I agree with that', + reference: { messageId: 'original_msg_id' }, + guildName: 'Server', + }); + await triggerMessage(msg); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ + content: '[Reply to Bob] I agree with that', + }), + ); + }); + }); + + // --- sendMessage --- + + describe('sendMessage', () => { + it('sends message via channel', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + await channel.sendMessage('dc:1234567890123456', 'Hello'); + + const fetchedChannel = await currentClient().channels.fetch('1234567890123456'); + expect(currentClient().channels.fetch).toHaveBeenCalledWith('1234567890123456'); + }); + + it('strips dc: prefix from JID', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + await channel.sendMessage('dc:9876543210', 'Test'); + + expect(currentClient().channels.fetch).toHaveBeenCalledWith('9876543210'); + }); + + it('handles send failure gracefully', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + currentClient().channels.fetch.mockRejectedValueOnce( + new Error('Channel not found'), + ); + + // Should not throw + await expect( + channel.sendMessage('dc:1234567890123456', 'Will fail'), + ).resolves.toBeUndefined(); + }); + + it('does nothing when client is not initialized', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + + // Don't connect — client is null + await channel.sendMessage('dc:1234567890123456', 'No client'); + + // No error, no API call + }); + + it('splits messages exceeding 2000 characters', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const mockChannel = { + send: vi.fn().mockResolvedValue(undefined), + sendTyping: vi.fn(), + }; + currentClient().channels.fetch.mockResolvedValue(mockChannel); + + const longText = 'x'.repeat(3000); + await channel.sendMessage('dc:1234567890123456', longText); + + expect(mockChannel.send).toHaveBeenCalledTimes(2); + expect(mockChannel.send).toHaveBeenNthCalledWith(1, 'x'.repeat(2000)); + expect(mockChannel.send).toHaveBeenNthCalledWith(2, 'x'.repeat(1000)); + }); + }); + + // --- ownsJid --- + + describe('ownsJid', () => { + it('owns dc: JIDs', () => { + const channel = new DiscordChannel('test-token', createTestOpts()); + expect(channel.ownsJid('dc:1234567890123456')).toBe(true); + }); + + it('does not own WhatsApp group JIDs', () => { + const channel = new DiscordChannel('test-token', createTestOpts()); + expect(channel.ownsJid('12345@g.us')).toBe(false); + }); + + it('does not own Telegram JIDs', () => { + const channel = new DiscordChannel('test-token', createTestOpts()); + expect(channel.ownsJid('tg:123456789')).toBe(false); + }); + + it('does not own unknown JID formats', () => { + const channel = new DiscordChannel('test-token', createTestOpts()); + expect(channel.ownsJid('random-string')).toBe(false); + }); + }); + + // --- setTyping --- + + describe('setTyping', () => { + it('sends typing indicator when isTyping is true', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const mockChannel = { + send: vi.fn(), + sendTyping: vi.fn().mockResolvedValue(undefined), + }; + currentClient().channels.fetch.mockResolvedValue(mockChannel); + + await channel.setTyping('dc:1234567890123456', true); + + expect(mockChannel.sendTyping).toHaveBeenCalled(); + }); + + it('does nothing when isTyping is false', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + await channel.setTyping('dc:1234567890123456', false); + + // channels.fetch should NOT be called + expect(currentClient().channels.fetch).not.toHaveBeenCalled(); + }); + + it('does nothing when client is not initialized', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + + // Don't connect + await channel.setTyping('dc:1234567890123456', true); + + // No error + }); + }); + + // --- Channel properties --- + + describe('channel properties', () => { + it('has name "discord"', () => { + const channel = new DiscordChannel('test-token', createTestOpts()); + expect(channel.name).toBe('discord'); + }); + }); +}); diff --git a/src/channels/discord.ts b/src/channels/discord.ts new file mode 100644 index 0000000..db2d69a --- /dev/null +++ b/src/channels/discord.ts @@ -0,0 +1,291 @@ +import { Client, Events, GatewayIntentBits, Message, TextChannel } from 'discord.js'; + +import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js'; +import { readEnvFile } from '../env.js'; +import { logger } from '../logger.js'; +import { registerChannel, ChannelOpts } from './registry.js'; +import { + AgentType, + Channel, + OnChatMetadata, + OnInboundMessage, + RegisteredGroup, +} from '../types.js'; + +export interface DiscordChannelOpts { + onMessage: OnInboundMessage; + onChatMetadata: OnChatMetadata; + registeredGroups: () => Record; +} + +export class DiscordChannel implements Channel { + name = 'discord'; + + private client: Client | null = null; + private opts: DiscordChannelOpts; + private botToken: string; + private typingIntervals = new Map(); + private agentTypeFilter?: AgentType; + + constructor(botToken: string, opts: DiscordChannelOpts, agentTypeFilter?: AgentType) { + this.botToken = botToken; + this.opts = opts; + this.agentTypeFilter = agentTypeFilter; + if (agentTypeFilter) { + this.name = `discord-${agentTypeFilter}`; + } + } + + async connect(): Promise { + this.client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages, + ], + }); + + this.client.on(Events.MessageCreate, async (message: Message) => { + // Ignore own messages only + if (message.author.id === this.client?.user?.id) return; + + const channelId = message.channelId; + const chatJid = `dc:${channelId}`; + let content = message.content; + const timestamp = message.createdAt.toISOString(); + const senderName = + message.member?.displayName || + message.author.displayName || + message.author.username; + const sender = message.author.id; + const msgId = message.id; + + // Determine chat name + let chatName: string; + if (message.guild) { + const textChannel = message.channel as TextChannel; + chatName = `${message.guild.name} #${textChannel.name}`; + } else { + chatName = senderName; + } + + // Translate Discord @bot mentions into TRIGGER_PATTERN format. + // Discord mentions look like <@botUserId> — these won't match + // TRIGGER_PATTERN (e.g., ^@Andy\b), so we prepend the trigger + // when the bot is @mentioned. + if (this.client?.user) { + const botId = this.client.user.id; + const isBotMentioned = + message.mentions.users.has(botId) || + content.includes(`<@${botId}>`) || + content.includes(`<@!${botId}>`); + + if (isBotMentioned) { + // Strip the <@botId> mention to avoid visual clutter + content = content + .replace(new RegExp(`<@!?${botId}>`, 'g'), '') + .trim(); + // Prepend trigger if not already present + if (!TRIGGER_PATTERN.test(content)) { + content = `@${ASSISTANT_NAME} ${content}`; + } + } + } + + // Handle attachments — store placeholders so the agent knows something was sent + if (message.attachments.size > 0) { + const attachmentDescriptions = [...message.attachments.values()].map((att) => { + const contentType = att.contentType || ''; + if (contentType.startsWith('image/')) { + return `[Image: ${att.name || 'image'}]`; + } else if (contentType.startsWith('video/')) { + return `[Video: ${att.name || 'video'}]`; + } else if (contentType.startsWith('audio/')) { + return `[Audio: ${att.name || 'audio'}]`; + } else { + return `[File: ${att.name || 'file'}]`; + } + }); + if (content) { + content = `${content}\n${attachmentDescriptions.join('\n')}`; + } else { + content = attachmentDescriptions.join('\n'); + } + } + + // Handle reply context — include who the user is replying to + if (message.reference?.messageId) { + try { + const repliedTo = await message.channel.messages.fetch( + message.reference.messageId, + ); + const replyAuthor = + repliedTo.member?.displayName || + repliedTo.author.displayName || + repliedTo.author.username; + content = `[Reply to ${replyAuthor}] ${content}`; + } catch { + // Referenced message may have been deleted + } + } + + // Store chat metadata for discovery + const isGroup = message.guild !== null; + this.opts.onChatMetadata(chatJid, timestamp, chatName, 'discord', isGroup); + + // Only deliver full message for registered groups matching our agent type + const group = this.opts.registeredGroups()[chatJid]; + if (!group) { + logger.debug( + { chatJid, chatName }, + 'Message from unregistered Discord channel', + ); + return; + } + if (this.agentTypeFilter && (group.agentType || 'claude-code') !== this.agentTypeFilter) { + return; // This JID belongs to a different agent type's bot + } + + // Deliver message — startMessageLoop() will pick it up + this.opts.onMessage(chatJid, { + id: msgId, + chat_jid: chatJid, + sender, + sender_name: senderName, + content, + timestamp, + is_from_me: false, + is_bot_message: message.author.bot ?? false, + }); + + logger.info( + { chatJid, chatName, sender: senderName }, + 'Discord message stored', + ); + }); + + // Handle errors gracefully + this.client.on(Events.Error, (err) => { + logger.error({ err: err.message }, 'Discord client error'); + }); + + return new Promise((resolve) => { + this.client!.once(Events.ClientReady, (readyClient) => { + logger.info( + { username: readyClient.user.tag, id: readyClient.user.id }, + 'Discord bot connected', + ); + console.log(`\n Discord bot: ${readyClient.user.tag}`); + console.log( + ` Use /chatid command or check channel IDs in Discord settings\n`, + ); + resolve(); + }); + + this.client!.login(this.botToken); + }); + } + + async sendMessage(jid: string, text: string): Promise { + if (!this.client) { + logger.warn('Discord client not initialized'); + return; + } + + try { + const channelId = jid.replace(/^dc:/, ''); + const channel = await this.client.channels.fetch(channelId); + + if (!channel || !('send' in channel)) { + logger.warn({ jid }, 'Discord channel not found or not text-based'); + return; + } + + const textChannel = channel as TextChannel; + + // Discord has a 2000 character limit per message — split if needed + const MAX_LENGTH = 2000; + if (text.length <= MAX_LENGTH) { + await textChannel.send(text); + } else { + for (let i = 0; i < text.length; i += MAX_LENGTH) { + await textChannel.send(text.slice(i, i + MAX_LENGTH)); + } + } + logger.info({ jid, length: text.length }, 'Discord message sent'); + } catch (err) { + logger.error({ jid, err }, 'Failed to send Discord message'); + } + } + + isConnected(): boolean { + return this.client !== null && this.client.isReady(); + } + + ownsJid(jid: string): boolean { + if (!jid.startsWith('dc:')) return false; + if (!this.agentTypeFilter) return true; + const group = this.opts.registeredGroups()[jid]; + if (!group) return false; + return (group.agentType || 'claude-code') === this.agentTypeFilter; + } + + async disconnect(): Promise { + if (this.client) { + this.client.destroy(); + this.client = null; + logger.info('Discord bot stopped'); + } + } + + async setTyping(jid: string, isTyping: boolean): Promise { + if (!this.client) return; + + // Clear any existing interval for this channel + const existing = this.typingIntervals.get(jid); + if (existing) { + clearInterval(existing); + this.typingIntervals.delete(jid); + } + + if (!isTyping) return; + + const sendOnce = async () => { + try { + const channelId = jid.replace(/^dc:/, ''); + const channel = await this.client!.channels.fetch(channelId); + if (channel && 'sendTyping' in channel) { + await (channel as TextChannel).sendTyping(); + } + } catch (err) { + logger.debug({ jid, err }, 'Failed to send Discord typing indicator'); + } + }; + + // Send immediately, then refresh every 8 seconds (Discord expires at ~10s) + await sendOnce(); + this.typingIntervals.set(jid, setInterval(sendOnce, 8000)); + } +} + +registerChannel('discord', (opts: ChannelOpts) => { + const envVars = readEnvFile(['DISCORD_BOT_TOKEN', 'DISCORD_CODEX_BOT_TOKEN']); + const token = + process.env.DISCORD_BOT_TOKEN || envVars.DISCORD_BOT_TOKEN || ''; + if (!token) { + logger.warn('Discord: DISCORD_BOT_TOKEN not set'); + return null; + } + // If a second Codex bot token exists, this instance only handles claude-code groups + const hasCodexBot = !!(process.env.DISCORD_CODEX_BOT_TOKEN || envVars.DISCORD_CODEX_BOT_TOKEN); + return new DiscordChannel(token, opts, hasCodexBot ? 'claude-code' : undefined); +}); + +registerChannel('discord-codex', (opts: ChannelOpts) => { + const envVars = readEnvFile(['DISCORD_CODEX_BOT_TOKEN']); + const token = + process.env.DISCORD_CODEX_BOT_TOKEN || envVars.DISCORD_CODEX_BOT_TOKEN || ''; + if (!token) return null; // Codex Discord bot is optional + return new DiscordChannel(token, opts, 'codex'); +}); diff --git a/src/channels/index.ts b/src/channels/index.ts index 44f4f55..3916e5e 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -2,6 +2,7 @@ // Each import triggers the channel module's registerChannel() call. // discord +import './discord.js'; // gmail diff --git a/src/config.ts b/src/config.ts index 43db54f..527b705 100644 --- a/src/config.ts +++ b/src/config.ts @@ -39,6 +39,8 @@ export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data'); export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest'; +export const CODEX_CONTAINER_IMAGE = + process.env.CODEX_CONTAINER_IMAGE || 'nanoclaw-codex-agent:latest'; export const CONTAINER_TIMEOUT = parseInt( process.env.CONTAINER_TIMEOUT || '1800000', 10, diff --git a/src/container-runner.ts b/src/container-runner.ts index be6f356..c28fb2a 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -4,9 +4,11 @@ */ import { ChildProcess, exec, spawn } from 'child_process'; import fs from 'fs'; +import os from 'os'; import path from 'path'; import { + CODEX_CONTAINER_IMAGE, CONTAINER_IMAGE, CONTAINER_MAX_OUTPUT_SIZE, CONTAINER_TIMEOUT, @@ -26,6 +28,7 @@ import { stopContainer, } from './container-runtime.js'; import { detectAuthMode } from './credential-proxy.js'; +import { readEnvFile } from './env.js'; import { validateAdditionalMounts } from './mount-security.js'; import { RegisteredGroup } from './types.js'; @@ -41,6 +44,7 @@ export interface ContainerInput { isMain: boolean; isScheduledTask?: boolean; assistantName?: string; + agentType?: 'claude-code' | 'codex'; } export interface ContainerOutput { @@ -76,17 +80,6 @@ function buildVolumeMounts( readonly: true, }); - // Shadow .env so the agent cannot read secrets from the mounted project root. - // Credentials are injected by the credential proxy, 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 also gets its group folder as the working directory mounts.push({ hostPath: groupDir, @@ -163,6 +156,40 @@ function buildVolumeMounts( readonly: false, }); + // For Codex groups: create a per-session writable .codex/ directory + if (group.agentType === 'codex') { + const hostCodexDir = path.join(process.env.HOME || os.homedir(), '.codex'); + const sessionCodexDir = path.join( + DATA_DIR, + 'sessions', + group.folder, + '.codex', + ); + fs.mkdirSync(sessionCodexDir, { recursive: true }); + + // Always refresh auth credentials from host + const authSrc = path.join(hostCodexDir, 'auth.json'); + const authDst = path.join(sessionCodexDir, 'auth.json'); + if (fs.existsSync(authSrc)) { + fs.copyFileSync(authSrc, authDst); + } + + // Only copy config files if they don't exist yet (preserves per-session customization) + for (const file of ['config.toml', 'config.json']) { + const src = path.join(hostCodexDir, file); + const dst = path.join(sessionCodexDir, file); + if (fs.existsSync(src) && !fs.existsSync(dst)) { + fs.copyFileSync(src, dst); + } + } + + mounts.push({ + hostPath: sessionCodexDir, + containerPath: '/home/node/.codex', + 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); @@ -175,20 +202,24 @@ function buildVolumeMounts( readonly: false, }); - // Copy agent-runner source into a per-group writable location so agents + // Copy 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 agentType = group.agentType || 'claude-code'; + const runnerDirName = agentType === 'codex' ? 'codex-runner' : 'agent-runner'; + const runnerSrcSuffix = + agentType === 'codex' ? 'codex-runner-src' : 'agent-runner-src'; const agentRunnerSrc = path.join( projectRoot, 'container', - 'agent-runner', + runnerDirName, 'src', ); const groupAgentRunnerDir = path.join( DATA_DIR, 'sessions', group.folder, - 'agent-runner-src', + runnerSrcSuffix, ); if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) { fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true }); @@ -215,39 +246,69 @@ function buildVolumeMounts( function buildContainerArgs( mounts: VolumeMount[], containerName: string, + isMain: boolean, + agentType: 'claude-code' | 'codex' = 'claude-code', ): 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}`); - // Route API traffic through the credential proxy (containers never see real secrets) - args.push( - '-e', - `ANTHROPIC_BASE_URL=http://${CONTAINER_HOST_GATEWAY}:${CREDENTIAL_PROXY_PORT}`, - ); - - // Mirror the host's auth method with a placeholder value. - // API key mode: SDK sends x-api-key, proxy replaces with real key. - // OAuth mode: SDK exchanges placeholder token for temp API key, - // proxy injects real OAuth token on that exchange request. - const authMode = detectAuthMode(); - if (authMode === 'api-key') { - args.push('-e', 'ANTHROPIC_API_KEY=placeholder'); + if (agentType === 'codex') { + // Codex uses OpenAI API — pass the key directly (no credential proxy) + const openaiKey = + process.env.CODEX_OPENAI_API_KEY || process.env.OPENAI_API_KEY; + if (openaiKey) { + args.push('-e', `OPENAI_API_KEY=${openaiKey}`); + } } else { - args.push('-e', 'CLAUDE_CODE_OAUTH_TOKEN=placeholder'); + // Route API traffic through the credential proxy (containers never see real secrets) + args.push( + '-e', + `ANTHROPIC_BASE_URL=http://${CONTAINER_HOST_GATEWAY}:${CREDENTIAL_PROXY_PORT}`, + ); + + // Mirror the host's auth method with a placeholder value. + const authMode = detectAuthMode(); + if (authMode === 'api-key') { + args.push('-e', 'ANTHROPIC_API_KEY=placeholder'); + } else { + args.push('-e', 'CLAUDE_CODE_OAUTH_TOKEN=placeholder'); + } } // Runtime-specific args for host gateway resolution args.push(...hostGatewayArgs()); + // Pass model and thinking configuration to container agent (read from .env file) + const modelEnv = readEnvFile(['CLAUDE_MODEL', 'CLAUDE_THINKING', 'CLAUDE_THINKING_BUDGET', 'CLAUDE_EFFORT']); + if (modelEnv.CLAUDE_MODEL) { + args.push('-e', `CLAUDE_MODEL=${modelEnv.CLAUDE_MODEL}`); + } + if (modelEnv.CLAUDE_THINKING) { + args.push('-e', `CLAUDE_THINKING=${modelEnv.CLAUDE_THINKING}`); + } + if (modelEnv.CLAUDE_THINKING_BUDGET) { + args.push('-e', `CLAUDE_THINKING_BUDGET=${modelEnv.CLAUDE_THINKING_BUDGET}`); + } + if (modelEnv.CLAUDE_EFFORT) { + args.push('-e', `CLAUDE_EFFORT=${modelEnv.CLAUDE_EFFORT}`); + } + // 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}`); + if (isMain) { + // Main containers start as root so the entrypoint can mount --bind + // to shadow .env. Privileges are dropped via setpriv in entrypoint.sh. + args.push('-e', `RUN_UID=${hostUid}`); + args.push('-e', `RUN_GID=${hostGid}`); + } else { + args.push('--user', `${hostUid}:${hostGid}`); + } args.push('-e', 'HOME=/home/node'); } @@ -259,7 +320,8 @@ function buildContainerArgs( } } - args.push(CONTAINER_IMAGE); + // Select container image based on agent type + args.push(agentType === 'codex' ? CODEX_CONTAINER_IMAGE : CONTAINER_IMAGE); return args; } @@ -278,7 +340,13 @@ export async function runContainerAgent( const mounts = buildVolumeMounts(group, input.isMain); const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-'); const containerName = `nanoclaw-${safeName}-${Date.now()}`; - const containerArgs = buildContainerArgs(mounts, containerName); + const agentType = group.agentType || 'claude-code'; + const containerArgs = buildContainerArgs( + mounts, + containerName, + input.isMain, + agentType, + ); logger.debug( { diff --git a/src/container-runtime.test.ts b/src/container-runtime.test.ts index 08ffd59..79b77a3 100644 --- a/src/container-runtime.test.ts +++ b/src/container-runtime.test.ts @@ -32,9 +32,12 @@ beforeEach(() => { // --- Pure functions --- describe('readonlyMountArgs', () => { - it('returns -v flag with :ro suffix', () => { + it('returns --mount flag with type=bind and readonly', () => { const args = readonlyMountArgs('/host/path', '/container/path'); - expect(args).toEqual(['-v', '/host/path:/container/path:ro']); + expect(args).toEqual([ + '--mount', + 'type=bind,source=/host/path,target=/container/path,readonly', + ]); }); }); @@ -55,18 +58,35 @@ describe('ensureContainerRuntimeRunning', () => { ensureContainerRuntimeRunning(); expect(mockExecSync).toHaveBeenCalledTimes(1); - expect(mockExecSync).toHaveBeenCalledWith(`${CONTAINER_RUNTIME_BIN} info`, { - stdio: 'pipe', - timeout: 10000, - }); - expect(logger.debug).toHaveBeenCalledWith( - 'Container runtime already running', + expect(mockExecSync).toHaveBeenCalledWith( + `${CONTAINER_RUNTIME_BIN} system status`, + { stdio: 'pipe' }, ); + expect(logger.debug).toHaveBeenCalledWith('Container runtime already running'); }); - it('throws when docker info fails', () => { + it('auto-starts when system status fails', () => { + // First call (system status) fails mockExecSync.mockImplementationOnce(() => { - throw new Error('Cannot connect to the Docker daemon'); + throw new Error('not running'); + }); + // Second call (system start) succeeds + mockExecSync.mockReturnValueOnce(''); + + ensureContainerRuntimeRunning(); + + expect(mockExecSync).toHaveBeenCalledTimes(2); + expect(mockExecSync).toHaveBeenNthCalledWith( + 2, + `${CONTAINER_RUNTIME_BIN} system start`, + { stdio: 'pipe', timeout: 30000 }, + ); + expect(logger.info).toHaveBeenCalledWith('Container runtime started'); + }); + + it('throws when both status and start fail', () => { + mockExecSync.mockImplementation(() => { + throw new Error('failed'); }); expect(() => ensureContainerRuntimeRunning()).toThrow( @@ -79,17 +99,21 @@ describe('ensureContainerRuntimeRunning', () => { // --- 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', - ); + it('stops orphaned nanoclaw containers from JSON output', () => { + // Apple Container ls returns JSON + const lsOutput = JSON.stringify([ + { status: 'running', configuration: { id: 'nanoclaw-group1-111' } }, + { status: 'stopped', configuration: { id: 'nanoclaw-group2-222' } }, + { status: 'running', configuration: { id: 'nanoclaw-group3-333' } }, + { status: 'running', configuration: { id: 'other-container' } }, + ]); + mockExecSync.mockReturnValueOnce(lsOutput); // stop calls succeed mockExecSync.mockReturnValue(''); cleanupOrphans(); - // ps + 2 stop calls + // ls + 2 stop calls (only running nanoclaw- containers) expect(mockExecSync).toHaveBeenCalledTimes(3); expect(mockExecSync).toHaveBeenNthCalledWith( 2, @@ -98,17 +122,17 @@ describe('cleanupOrphans', () => { ); expect(mockExecSync).toHaveBeenNthCalledWith( 3, - `${CONTAINER_RUNTIME_BIN} stop nanoclaw-group2-222`, + `${CONTAINER_RUNTIME_BIN} stop nanoclaw-group3-333`, { stdio: 'pipe' }, ); expect(logger.info).toHaveBeenCalledWith( - { count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group2-222'] }, + { count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group3-333'] }, 'Stopped orphaned containers', ); }); it('does nothing when no orphans exist', () => { - mockExecSync.mockReturnValueOnce(''); + mockExecSync.mockReturnValueOnce('[]'); cleanupOrphans(); @@ -116,9 +140,9 @@ describe('cleanupOrphans', () => { expect(logger.info).not.toHaveBeenCalled(); }); - it('warns and continues when ps fails', () => { + it('warns and continues when ls fails', () => { mockExecSync.mockImplementationOnce(() => { - throw new Error('docker not available'); + throw new Error('container not available'); }); cleanupOrphans(); // should not throw @@ -130,7 +154,11 @@ describe('cleanupOrphans', () => { }); it('continues stopping remaining containers when one stop fails', () => { - mockExecSync.mockReturnValueOnce('nanoclaw-a-1\nnanoclaw-b-2\n'); + const lsOutput = JSON.stringify([ + { status: 'running', configuration: { id: 'nanoclaw-a-1' } }, + { status: 'running', configuration: { id: 'nanoclaw-b-2' } }, + ]); + mockExecSync.mockReturnValueOnce(lsOutput); // First stop fails mockExecSync.mockImplementationOnce(() => { throw new Error('already stopped'); diff --git a/src/container-runtime.ts b/src/container-runtime.ts index c4acdba..de53cff 100644 --- a/src/container-runtime.ts +++ b/src/container-runtime.ts @@ -3,58 +3,35 @@ * All runtime-specific logic lives here so swapping runtimes means changing one file. */ import { execSync } from 'child_process'; -import fs from 'fs'; -import os from 'os'; import { logger } from './logger.js'; /** The container runtime binary name. */ -export const CONTAINER_RUNTIME_BIN = 'docker'; - -/** Hostname containers use to reach the host machine. */ -export const CONTAINER_HOST_GATEWAY = 'host.docker.internal'; +export const CONTAINER_RUNTIME_BIN = 'container'; /** - * Address the credential proxy binds to. - * Docker Desktop (macOS): 127.0.0.1 — the VM routes host.docker.internal to loopback. - * Docker (Linux): bind to the docker0 bridge IP so only containers can reach it, - * falling back to 0.0.0.0 if the interface isn't found. + * Hostname containers use to reach the host machine. + * Apple Container VMs access the host via the default gateway (192.168.64.1). */ -export const PROXY_BIND_HOST = - process.env.CREDENTIAL_PROXY_HOST || detectProxyBindHost(); +export const CONTAINER_HOST_GATEWAY = '192.168.64.1'; -function detectProxyBindHost(): string { - if (os.platform() === 'darwin') return '127.0.0.1'; +/** + * Address the credential proxy binds to on the host. + * Apple Container VMs reach the host via 192.168.64.1, so we must bind to all interfaces. + */ +export const PROXY_BIND_HOST = '0.0.0.0'; - // WSL uses Docker Desktop (same VM routing as macOS) — loopback is correct. - // Check /proc filesystem, not env vars — WSL_DISTRO_NAME isn't set under systemd. - if (fs.existsSync('/proc/sys/fs/binfmt_misc/WSLInterop')) return '127.0.0.1'; - - // Bare-metal Linux: bind to the docker0 bridge IP instead of 0.0.0.0 - const ifaces = os.networkInterfaces(); - const docker0 = ifaces['docker0']; - if (docker0) { - const ipv4 = docker0.find((a) => a.family === 'IPv4'); - if (ipv4) return ipv4.address; - } - return '0.0.0.0'; -} - -/** CLI args needed for the container to resolve the host gateway. */ +/** + * CLI args needed for the container to resolve the host gateway. + * Apple Container provides host networking natively on macOS — no extra args needed. + */ 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`]; +export function readonlyMountArgs(hostPath: string, containerPath: string): string[] { + return ['--mount', `type=bind,source=${hostPath},target=${containerPath},readonly`]; } /** Returns the shell command to stop a container by name. */ @@ -65,61 +42,62 @@ export function stopContainer(name: string): string { /** Ensure the container runtime is running, starting it if needed. */ export function ensureContainerRuntimeRunning(): void { try { - execSync(`${CONTAINER_RUNTIME_BIN} info`, { - stdio: 'pipe', - timeout: 10000, - }); + execSync(`${CONTAINER_RUNTIME_BIN} system status`, { stdio: 'pipe' }); 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'); + } catch { + logger.info('Starting container runtime...'); + try { + execSync(`${CONTAINER_RUNTIME_BIN} system start`, { stdio: 'pipe', timeout: 30000 }); + logger.info('Container runtime started'); + } catch (err) { + logger.error({ err }, 'Failed to start 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 Apple Container is installed ║', + ); + console.error( + '║ 2. Run: container system start ║', + ); + console.error( + '║ 3. Restart NanoClaw ║', + ); + console.error( + '╚════════════════════════════════════════════════════════════════╝\n', + ); + throw new Error('Container runtime is required but failed to start'); + } } } /** 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); + const output = execSync(`${CONTAINER_RUNTIME_BIN} ls --format json`, { + stdio: ['pipe', 'pipe', 'pipe'], + encoding: 'utf-8', + }); + const containers: { status: string; configuration: { id: string } }[] = JSON.parse(output || '[]'); + const orphans = containers + .filter((c) => c.status === 'running' && c.configuration.id.startsWith('nanoclaw-')) + .map((c) => c.configuration.id); for (const name of orphans) { try { execSync(stopContainer(name), { stdio: 'pipe' }); - } catch { - /* already stopped */ - } + } catch { /* already stopped */ } } if (orphans.length > 0) { - logger.info( - { count: orphans.length, names: orphans }, - 'Stopped orphaned containers', - ); + logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers'); } } catch (err) { logger.warn({ err }, 'Failed to clean up orphaned containers'); diff --git a/src/db.test.ts b/src/db.test.ts index a40d376..b40a3a7 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -187,25 +187,26 @@ describe('getMessagesSince', () => { '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'); + // Should exclude m1, m2 (before/at timestamp); m3 (bot) is now included + expect(msgs).toHaveLength(2); + expect(msgs[0].content).toBe('bot reply'); + expect(msgs[1].content).toBe('third'); }); - it('excludes bot messages via is_bot_message flag', () => { + it('includes bot messages with 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); + expect(botMsgs).toHaveLength(1); }); - it('returns all non-bot messages when sinceTimestamp is empty', () => { + it('returns all messages including bot when sinceTimestamp is empty', () => { const msgs = getMessagesSince('group@g.us', '', 'Andy'); - // 3 user messages (bot message excluded) - expect(msgs).toHaveLength(3); + // 3 user messages + 1 bot message + expect(msgs).toHaveLength(4); }); it('filters pre-migration bot messages via content prefix backstop', () => { @@ -275,8 +276,8 @@ describe('getNewMessages', () => { '2024-01-01T00:00:00.000Z', 'Andy', ); - // Excludes bot message, returns 3 user messages - expect(messages).toHaveLength(3); + // Includes bot message, returns all 4 messages + expect(messages).toHaveLength(4); expect(newTimestamp).toBe('2024-01-01T00:00:04.000Z'); }); @@ -286,9 +287,10 @@ describe('getNewMessages', () => { '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'); + // bot reply (00:00:03) + g1 msg2 (00:00:04) after ts + expect(messages).toHaveLength(2); + expect(messages[0].content).toBe('bot reply'); + expect(messages[1].content).toBe('g1 msg2'); }); it('returns empty for no registered groups', () => { diff --git a/src/db.ts b/src/db.ts index 0896f41..992bb66 100644 --- a/src/db.ts +++ b/src/db.ts @@ -119,6 +119,15 @@ function createSchema(database: Database.Database): void { /* column already exists */ } + // Add agent_type column if it doesn't exist (migration for Codex support) + try { + database.exec( + `ALTER TABLE registered_groups ADD COLUMN agent_type TEXT DEFAULT 'claude-code'`, + ); + } 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`); @@ -316,10 +325,10 @@ export function getNewMessages( // 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 + SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message FROM messages WHERE timestamp > ? AND chat_jid IN (${placeholders}) - AND is_bot_message = 0 AND content NOT LIKE ? + AND content NOT LIKE ? AND content != '' AND content IS NOT NULL ORDER BY timestamp DESC LIMIT ? @@ -349,10 +358,10 @@ export function getMessagesSince( // 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 + SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message FROM messages WHERE chat_jid = ? AND timestamp > ? - AND is_bot_message = 0 AND content NOT LIKE ? + AND content NOT LIKE ? AND content != '' AND content IS NOT NULL ORDER BY timestamp DESC LIMIT ? @@ -363,6 +372,18 @@ export function getMessagesSince( .all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit) as NewMessage[]; } +export function getLastHumanMessageTimestamp(chatJid: string): string | null { + const row = db + .prepare( + `SELECT timestamp FROM messages + WHERE chat_jid = ? AND is_bot_message = 0 AND is_from_me = 0 + AND content != '' AND content IS NOT NULL + ORDER BY timestamp DESC LIMIT 1`, + ) + .get(chatJid) as { timestamp: string } | undefined; + return row?.timestamp ?? null; +} + export function createTask( task: Omit, ): void { diff --git a/src/group-queue.ts b/src/group-queue.ts index f2984ce..e347b7b 100644 --- a/src/group-queue.ts +++ b/src/group-queue.ts @@ -59,11 +59,16 @@ export class GroupQueue { this.processMessagesFn = fn; } - enqueueMessageCheck(groupJid: string): void { + enqueueMessageCheck(groupJid: string, groupFolder?: string): void { if (this.shuttingDown) return; const state = this.getGroup(groupJid); + // Pre-set groupFolder so sendMessage can pipe IPC while container starts + if (groupFolder && !state.groupFolder) { + state.groupFolder = groupFolder; + } + if (state.active) { state.pendingMessages = true; logger.debug({ groupJid }, 'Container active, message queued'); diff --git a/src/index.ts b/src/index.ts index c6295c5..c33f643 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,7 @@ import { getAllRegisteredGroups, getAllSessions, getAllTasks, + getLastHumanMessageTimestamp, getMessagesSince, getNewMessages, getRegisteredGroup, @@ -52,6 +53,7 @@ import { loadSenderAllowlist, shouldDropMessage, } from './sender-allowlist.js'; +import { extractSessionCommand, handleSessionCommand, isSessionCommandAllowed } from './session-commands.js'; import { startSchedulerLoop } from './task-scheduler.js'; import { Channel, NewMessage, RegisteredGroup } from './types.js'; import { logger } from './logger.js'; @@ -164,6 +166,33 @@ async function processGroupMessages(chatJid: string): Promise { if (missedMessages.length === 0) return true; + // --- Session command interception (before trigger check) --- + const cmdResult = await handleSessionCommand({ + missedMessages, + isMainGroup, + groupName: group.name, + triggerPattern: TRIGGER_PATTERN, + timezone: TIMEZONE, + deps: { + sendMessage: (text) => channel.sendMessage(chatJid, text), + setTyping: (typing) => channel.setTyping?.(chatJid, typing) ?? Promise.resolve(), + runAgent: (prompt, onOutput) => runAgent(group, prompt, chatJid, onOutput), + closeStdin: () => queue.closeStdin(chatJid), + advanceCursor: (ts) => { lastAgentTimestamp[chatJid] = ts; saveState(); }, + formatMessages, + canSenderInteract: (msg) => { + const hasTrigger = TRIGGER_PATTERN.test(msg.content.trim()); + const reqTrigger = !isMainGroup && group.requiresTrigger !== false; + return isMainGroup || !reqTrigger || (hasTrigger && ( + msg.is_from_me || + isTriggerAllowed(chatJid, msg.sender, loadSenderAllowlist()) + )); + }, + }, + }); + if (cmdResult.handled) return cmdResult.success; + // --- End session command interception --- + // For non-main groups, check if trigger is required and present if (!isMainGroup && group.requiresTrigger !== false) { const allowlistCfg = loadSenderAllowlist(); @@ -172,7 +201,9 @@ async function processGroupMessages(chatJid: string): Promise { TRIGGER_PATTERN.test(m.content.trim()) && (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)), ); - if (!hasTrigger) return true; + if (!hasTrigger) { + return true; + } } const prompt = formatMessages(missedMessages, TIMEZONE); @@ -388,6 +419,51 @@ async function startMessageLoop(): Promise { } const isMainGroup = group.isMain === true; + + // --- Bot-collaboration timeout --- + // If all new messages are from bots, only process if a human + // sent a message within the last 12 hours. + const BOT_COLLAB_TIMEOUT_MS = 12 * 60 * 60 * 1000; + const allFromBots = groupMessages.every( + (m) => m.is_from_me || !!m.is_bot_message, + ); + if (allFromBots) { + const lastHuman = getLastHumanMessageTimestamp(chatJid); + if ( + !lastHuman || + Date.now() - new Date(lastHuman).getTime() > + BOT_COLLAB_TIMEOUT_MS + ) { + logger.info( + { chatJid, lastHuman }, + 'Bot-collaboration timeout: no human message within 12h, skipping', + ); + continue; + } + } + // --- End bot-collaboration timeout --- + + // --- Session command interception (message loop) --- + // Scan ALL messages in the batch for a session command. + const loopCmdMsg = groupMessages.find( + (m) => extractSessionCommand(m.content, TRIGGER_PATTERN) !== null, + ); + + if (loopCmdMsg) { + // Only close active container if the sender is authorized — otherwise an + // untrusted user could kill in-flight work by sending /compact (DoS). + // closeStdin no-ops internally when no container is active. + if (isSessionCommandAllowed(isMainGroup, loopCmdMsg.is_from_me === true)) { + queue.closeStdin(chatJid); + } + // Enqueue so processGroupMessages handles auth + cursor advancement. + // Don't pipe via IPC — slash commands need a fresh container with + // string prompt (not MessageStream) for SDK recognition. + queue.enqueueMessageCheck(chatJid); + continue; + } + // --- End session command interception --- + const needsTrigger = !isMainGroup && group.requiresTrigger !== false; // For non-main groups, only act on trigger messages. @@ -431,7 +507,7 @@ async function startMessageLoop(): Promise { ); } else { // No active container — enqueue for a new one - queue.enqueueMessageCheck(chatJid); + queue.enqueueMessageCheck(chatJid, group.folder); } } } @@ -455,7 +531,7 @@ function recoverPendingMessages(): void { { group: group.name, pendingCount: pending.length }, 'Recovery: found unprocessed messages', ); - queue.enqueueMessageCheck(chatJid); + queue.enqueueMessageCheck(chatJid, group.folder); } } } @@ -472,7 +548,7 @@ async function main(): Promise { loadState(); // Start credential proxy (containers route API calls through this) - const proxyServer = await startCredentialProxy( + await startCredentialProxy( CREDENTIAL_PROXY_PORT, PROXY_BIND_HOST, ); @@ -480,7 +556,6 @@ async function main(): Promise { // Graceful shutdown handlers const shutdown = async (signal: string) => { logger.info({ signal }, 'Shutdown signal received'); - proxyServer.close(); await queue.shutdown(10000); for (const ch of channels) await ch.disconnect(); process.exit(0); diff --git a/src/session-commands.test.ts b/src/session-commands.test.ts new file mode 100644 index 0000000..7cbc680 --- /dev/null +++ b/src/session-commands.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, vi } from 'vitest'; +import { extractSessionCommand, handleSessionCommand, isSessionCommandAllowed } from './session-commands.js'; +import type { NewMessage } from './types.js'; +import type { SessionCommandDeps } from './session-commands.js'; + +describe('extractSessionCommand', () => { + const trigger = /^@Andy\b/i; + + it('detects bare /compact', () => { + expect(extractSessionCommand('/compact', trigger)).toBe('/compact'); + }); + + it('detects /compact with trigger prefix', () => { + expect(extractSessionCommand('@Andy /compact', trigger)).toBe('/compact'); + }); + + it('rejects /compact with extra text', () => { + expect(extractSessionCommand('/compact now please', trigger)).toBeNull(); + }); + + it('rejects partial matches', () => { + expect(extractSessionCommand('/compaction', trigger)).toBeNull(); + }); + + it('rejects regular messages', () => { + expect(extractSessionCommand('please compact the conversation', trigger)).toBeNull(); + }); + + it('handles whitespace', () => { + expect(extractSessionCommand(' /compact ', trigger)).toBe('/compact'); + }); + + it('is case-sensitive for the command', () => { + expect(extractSessionCommand('/Compact', trigger)).toBeNull(); + }); +}); + +describe('isSessionCommandAllowed', () => { + it('allows main group regardless of sender', () => { + expect(isSessionCommandAllowed(true, false)).toBe(true); + }); + + it('allows trusted/admin sender (is_from_me) in non-main group', () => { + expect(isSessionCommandAllowed(false, true)).toBe(true); + }); + + it('denies untrusted sender in non-main group', () => { + expect(isSessionCommandAllowed(false, false)).toBe(false); + }); + + it('allows trusted sender in main group', () => { + expect(isSessionCommandAllowed(true, true)).toBe(true); + }); +}); + +function makeMsg(content: string, overrides: Partial = {}): NewMessage { + return { + id: 'msg-1', + chat_jid: 'group@test', + sender: 'user@test', + sender_name: 'User', + content, + timestamp: '100', + ...overrides, + }; +} + +function makeDeps(overrides: Partial = {}): SessionCommandDeps { + return { + sendMessage: vi.fn().mockResolvedValue(undefined), + setTyping: vi.fn().mockResolvedValue(undefined), + runAgent: vi.fn().mockResolvedValue('success'), + closeStdin: vi.fn(), + advanceCursor: vi.fn(), + formatMessages: vi.fn().mockReturnValue(''), + canSenderInteract: vi.fn().mockReturnValue(true), + ...overrides, + }; +} + +const trigger = /^@Andy\b/i; + +describe('handleSessionCommand', () => { + it('returns handled:false when no session command found', async () => { + const deps = makeDeps(); + const result = await handleSessionCommand({ + missedMessages: [makeMsg('hello')], + isMainGroup: true, + groupName: 'test', + triggerPattern: trigger, + timezone: 'UTC', + deps, + }); + expect(result.handled).toBe(false); + }); + + it('handles authorized /compact in main group', async () => { + const deps = makeDeps(); + const result = await handleSessionCommand({ + missedMessages: [makeMsg('/compact')], + isMainGroup: true, + groupName: 'test', + triggerPattern: trigger, + timezone: 'UTC', + deps, + }); + expect(result).toEqual({ handled: true, success: true }); + expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function)); + expect(deps.advanceCursor).toHaveBeenCalledWith('100'); + }); + + it('sends denial to interactable sender in non-main group', async () => { + const deps = makeDeps(); + const result = await handleSessionCommand({ + missedMessages: [makeMsg('/compact', { is_from_me: false })], + isMainGroup: false, + groupName: 'test', + triggerPattern: trigger, + timezone: 'UTC', + deps, + }); + expect(result).toEqual({ handled: true, success: true }); + expect(deps.sendMessage).toHaveBeenCalledWith('Session commands require admin access.'); + expect(deps.runAgent).not.toHaveBeenCalled(); + expect(deps.advanceCursor).toHaveBeenCalledWith('100'); + }); + + it('silently consumes denied command when sender cannot interact', async () => { + const deps = makeDeps({ canSenderInteract: vi.fn().mockReturnValue(false) }); + const result = await handleSessionCommand({ + missedMessages: [makeMsg('/compact', { is_from_me: false })], + isMainGroup: false, + groupName: 'test', + triggerPattern: trigger, + timezone: 'UTC', + deps, + }); + expect(result).toEqual({ handled: true, success: true }); + expect(deps.sendMessage).not.toHaveBeenCalled(); + expect(deps.advanceCursor).toHaveBeenCalledWith('100'); + }); + + it('processes pre-compact messages before /compact', async () => { + const deps = makeDeps(); + const msgs = [ + makeMsg('summarize this', { timestamp: '99' }), + makeMsg('/compact', { timestamp: '100' }), + ]; + const result = await handleSessionCommand({ + missedMessages: msgs, + isMainGroup: true, + groupName: 'test', + triggerPattern: trigger, + timezone: 'UTC', + deps, + }); + expect(result).toEqual({ handled: true, success: true }); + expect(deps.formatMessages).toHaveBeenCalledWith([msgs[0]], 'UTC'); + // Two runAgent calls: pre-compact + /compact + expect(deps.runAgent).toHaveBeenCalledTimes(2); + expect(deps.runAgent).toHaveBeenCalledWith('', expect.any(Function)); + expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function)); + }); + + it('allows is_from_me sender in non-main group', async () => { + const deps = makeDeps(); + const result = await handleSessionCommand({ + missedMessages: [makeMsg('/compact', { is_from_me: true })], + isMainGroup: false, + groupName: 'test', + triggerPattern: trigger, + timezone: 'UTC', + deps, + }); + expect(result).toEqual({ handled: true, success: true }); + expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function)); + }); + + it('reports failure when command-stage runAgent returns error without streamed status', async () => { + // runAgent resolves 'error' but callback never gets status: 'error' + const deps = makeDeps({ runAgent: vi.fn().mockImplementation(async (prompt, onOutput) => { + await onOutput({ status: 'success', result: null }); + return 'error'; + })}); + const result = await handleSessionCommand({ + missedMessages: [makeMsg('/compact')], + isMainGroup: true, + groupName: 'test', + triggerPattern: trigger, + timezone: 'UTC', + deps, + }); + expect(result).toEqual({ handled: true, success: true }); + expect(deps.sendMessage).toHaveBeenCalledWith(expect.stringContaining('failed')); + }); + + it('returns success:false on pre-compact failure with no output', async () => { + const deps = makeDeps({ runAgent: vi.fn().mockResolvedValue('error') }); + const msgs = [ + makeMsg('summarize this', { timestamp: '99' }), + makeMsg('/compact', { timestamp: '100' }), + ]; + const result = await handleSessionCommand({ + missedMessages: msgs, + isMainGroup: true, + groupName: 'test', + triggerPattern: trigger, + timezone: 'UTC', + deps, + }); + expect(result).toEqual({ handled: true, success: false }); + expect(deps.sendMessage).toHaveBeenCalledWith(expect.stringContaining('Failed to process')); + }); +}); diff --git a/src/session-commands.ts b/src/session-commands.ts new file mode 100644 index 0000000..69ea041 --- /dev/null +++ b/src/session-commands.ts @@ -0,0 +1,143 @@ +import type { NewMessage } from './types.js'; +import { logger } from './logger.js'; + +/** + * Extract a session slash command from a message, stripping the trigger prefix if present. + * Returns the slash command (e.g., '/compact') or null if not a session command. + */ +export function extractSessionCommand(content: string, triggerPattern: RegExp): string | null { + let text = content.trim(); + text = text.replace(triggerPattern, '').trim(); + if (text === '/compact') return '/compact'; + return null; +} + +/** + * Check if a session command sender is authorized. + * Allowed: main group (any sender), or trusted/admin sender (is_from_me) in any group. + */ +export function isSessionCommandAllowed(isMainGroup: boolean, isFromMe: boolean): boolean { + return isMainGroup || isFromMe; +} + +/** Minimal agent result interface — matches the subset of ContainerOutput used here. */ +export interface AgentResult { + status: 'success' | 'error'; + result?: string | object | null; +} + +/** Dependencies injected by the orchestrator. */ +export interface SessionCommandDeps { + sendMessage: (text: string) => Promise; + setTyping: (typing: boolean) => Promise; + runAgent: ( + prompt: string, + onOutput: (result: AgentResult) => Promise, + ) => Promise<'success' | 'error'>; + closeStdin: () => void; + advanceCursor: (timestamp: string) => void; + formatMessages: (msgs: NewMessage[], timezone: string) => string; + /** Whether the denied sender would normally be allowed to interact (for denial messages). */ + canSenderInteract: (msg: NewMessage) => boolean; +} + +function resultToText(result: string | object | null | undefined): string { + if (!result) return ''; + const raw = typeof result === 'string' ? result : JSON.stringify(result); + return raw.replace(/[\s\S]*?<\/internal>/g, '').trim(); +} + +/** + * Handle session command interception in processGroupMessages. + * Scans messages for a session command, handles auth + execution. + * Returns { handled: true, success } if a command was found; { handled: false } otherwise. + * success=false means the caller should retry (cursor was not advanced). + */ +export async function handleSessionCommand(opts: { + missedMessages: NewMessage[]; + isMainGroup: boolean; + groupName: string; + triggerPattern: RegExp; + timezone: string; + deps: SessionCommandDeps; +}): Promise<{ handled: false } | { handled: true; success: boolean }> { + const { missedMessages, isMainGroup, groupName, triggerPattern, timezone, deps } = opts; + + const cmdMsg = missedMessages.find( + (m) => extractSessionCommand(m.content, triggerPattern) !== null, + ); + const command = cmdMsg ? extractSessionCommand(cmdMsg.content, triggerPattern) : null; + + if (!command || !cmdMsg) return { handled: false }; + + if (!isSessionCommandAllowed(isMainGroup, cmdMsg.is_from_me === true)) { + // DENIED: send denial if the sender would normally be allowed to interact, + // then silently consume the command by advancing the cursor past it. + // Trade-off: other messages in the same batch are also consumed (cursor is + // a high-water mark). Acceptable for this narrow edge case. + if (deps.canSenderInteract(cmdMsg)) { + await deps.sendMessage('Session commands require admin access.'); + } + deps.advanceCursor(cmdMsg.timestamp); + return { handled: true, success: true }; + } + + // AUTHORIZED: process pre-compact messages first, then run the command + logger.info({ group: groupName, command }, 'Session command'); + + const cmdIndex = missedMessages.indexOf(cmdMsg); + const preCompactMsgs = missedMessages.slice(0, cmdIndex); + + // Send pre-compact messages to the agent so they're in the session context. + if (preCompactMsgs.length > 0) { + const prePrompt = deps.formatMessages(preCompactMsgs, timezone); + let hadPreError = false; + let preOutputSent = false; + + const preResult = await deps.runAgent(prePrompt, async (result) => { + if (result.status === 'error') hadPreError = true; + const text = resultToText(result.result); + if (text) { + await deps.sendMessage(text); + preOutputSent = true; + } + // Close stdin on session-update marker — emitted after query completes, + // so all results (including multi-result runs) are already written. + if (result.status === 'success' && result.result === null) { + deps.closeStdin(); + } + }); + + if (preResult === 'error' || hadPreError) { + logger.warn({ group: groupName }, 'Pre-compact processing failed, aborting session command'); + await deps.sendMessage(`Failed to process messages before ${command}. Try again.`); + if (preOutputSent) { + // Output was already sent — don't retry or it will duplicate. + // Advance cursor past pre-compact messages, leave command pending. + deps.advanceCursor(preCompactMsgs[preCompactMsgs.length - 1].timestamp); + return { handled: true, success: true }; + } + return { handled: true, success: false }; + } + } + + // Forward the literal slash command as the prompt (no XML formatting) + await deps.setTyping(true); + + let hadCmdError = false; + const cmdOutput = await deps.runAgent(command, async (result) => { + if (result.status === 'error') hadCmdError = true; + const text = resultToText(result.result); + if (text) await deps.sendMessage(text); + }); + + // Advance cursor to the command — messages AFTER it remain pending for next poll. + deps.advanceCursor(cmdMsg.timestamp); + await deps.setTyping(false); + + if (cmdOutput === 'error' || hadCmdError) { + await deps.sendMessage(`${command} failed. The session is unchanged.`); + } + + return { handled: true, success: true }; +} diff --git a/src/types.ts b/src/types.ts index acbb08a..2801f34 100644 --- a/src/types.ts +++ b/src/types.ts @@ -32,6 +32,8 @@ export interface ContainerConfig { timeout?: number; // Default: 300000 (5 minutes) } +export type AgentType = 'claude-code' | 'codex'; + export interface RegisteredGroup { name: string; folder: string; @@ -40,6 +42,7 @@ export interface RegisteredGroup { containerConfig?: ContainerConfig; requiresTrigger?: boolean; // Default: true for groups, false for solo chats isMain?: boolean; // True for the main control group (no trigger, elevated privileges) + agentType?: AgentType; } export interface NewMessage {