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
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ FROM node:22-slim
|
|||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
chromium \
|
chromium \
|
||||||
fonts-liberation \
|
fonts-liberation \
|
||||||
fonts-noto-cjk \
|
|
||||||
fonts-noto-color-emoji \
|
fonts-noto-color-emoji \
|
||||||
libgbm1 \
|
libgbm1 \
|
||||||
libnss3 \
|
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
|
RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/messages /workspace/ipc/tasks /workspace/ipc/input
|
||||||
|
|
||||||
# Create entrypoint script
|
# Create entrypoint script
|
||||||
# Container input (prompt, group info) is passed via stdin JSON.
|
# Secrets are passed via stdin JSON — temp file is deleted immediately after Node reads it
|
||||||
# Credentials are injected by the host's credential proxy — never passed here.
|
|
||||||
# Follow-up messages arrive via IPC files in /workspace/ipc/input/
|
# 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
|
# Set ownership to node user (non-root) for writable directories
|
||||||
RUN chown -R node:node /workspace && chmod 777 /home/node
|
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
|
# Set working directory to group workspace
|
||||||
WORKDIR /workspace/group
|
WORKDIR /workspace/group
|
||||||
|
|
||||||
|
|||||||
43
container/Dockerfile.codex
Normal file
43
container/Dockerfile.codex
Normal file
@@ -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"]
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
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';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
interface ContainerInput {
|
interface ContainerInput {
|
||||||
@@ -27,6 +27,7 @@ interface ContainerInput {
|
|||||||
isMain: boolean;
|
isMain: boolean;
|
||||||
isScheduledTask?: boolean;
|
isScheduledTask?: boolean;
|
||||||
assistantName?: string;
|
assistantName?: string;
|
||||||
|
secrets?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContainerOutput {
|
interface ContainerOutput {
|
||||||
@@ -55,6 +56,7 @@ interface SDKUserMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const IPC_INPUT_DIR = '/workspace/ipc/input';
|
const IPC_INPUT_DIR = '/workspace/ipc/input';
|
||||||
|
const MAX_TURNS = 100;
|
||||||
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
|
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
|
||||||
const IPC_POLL_MS = 500;
|
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<string, unknown>),
|
||||||
|
command: unsetPrefix + command,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeFilename(summary: string): string {
|
function sanitizeFilename(summary: string): string {
|
||||||
return summary
|
return summary
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -389,10 +415,29 @@ async function runQuery(
|
|||||||
log(`Additional directories: ${extraDirs.join(', ')}`);
|
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({
|
for await (const message of query({
|
||||||
prompt: stream,
|
prompt: stream,
|
||||||
options: {
|
options: {
|
||||||
cwd: '/workspace/group',
|
cwd: '/workspace/group',
|
||||||
|
model,
|
||||||
|
thinking,
|
||||||
|
effort,
|
||||||
additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined,
|
additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined,
|
||||||
resume: sessionId,
|
resume: sessionId,
|
||||||
resumeSessionAt: resumeAt,
|
resumeSessionAt: resumeAt,
|
||||||
@@ -426,6 +471,7 @@ async function runQuery(
|
|||||||
},
|
},
|
||||||
hooks: {
|
hooks: {
|
||||||
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
|
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
|
||||||
|
PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})) {
|
})) {
|
||||||
@@ -470,6 +516,7 @@ async function main(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const stdinData = await readStdin();
|
const stdinData = await readStdin();
|
||||||
containerInput = JSON.parse(stdinData);
|
containerInput = JSON.parse(stdinData);
|
||||||
|
// Delete the temp file the entrypoint wrote — it contains secrets
|
||||||
try { fs.unlinkSync('/tmp/input.json'); } catch { /* may not exist */ }
|
try { fs.unlinkSync('/tmp/input.json'); } catch { /* may not exist */ }
|
||||||
log(`Received input for group: ${containerInput.groupFolder}`);
|
log(`Received input for group: ${containerInput.groupFolder}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -481,9 +528,12 @@ async function main(): Promise<void> {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Credentials are injected by the host's credential proxy via ANTHROPIC_BASE_URL.
|
// Build SDK env: merge secrets into process.env for the SDK only.
|
||||||
// No real secrets exist in the container environment.
|
// Secrets never touch process.env itself, so Bash subprocesses can't see them.
|
||||||
const sdkEnv: Record<string, string | undefined> = { ...process.env };
|
const sdkEnv: Record<string, string | undefined> = { ...process.env };
|
||||||
|
for (const [key, value] of Object.entries(containerInput.secrets || {})) {
|
||||||
|
sdkEnv[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js');
|
const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js');
|
||||||
@@ -505,11 +555,118 @@ async function main(): Promise<void> {
|
|||||||
prompt += '\n' + pending.join('\n');
|
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
|
// Query loop: run query → wait for IPC message → run new query → repeat
|
||||||
let resumeAt: string | undefined;
|
let resumeAt: string | undefined;
|
||||||
|
let turnCount = 0;
|
||||||
try {
|
try {
|
||||||
while (true) {
|
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);
|
const queryResult = await runQuery(prompt, sessionId, mcpServerPath, containerInput, sdkEnv, resumeAt);
|
||||||
if (queryResult.newSessionId) {
|
if (queryResult.newSessionId) {
|
||||||
|
|||||||
@@ -8,13 +8,22 @@ cd "$SCRIPT_DIR"
|
|||||||
|
|
||||||
IMAGE_NAME="nanoclaw-agent"
|
IMAGE_NAME="nanoclaw-agent"
|
||||||
TAG="${1:-latest}"
|
TAG="${1:-latest}"
|
||||||
CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-docker}"
|
CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-container}"
|
||||||
|
|
||||||
echo "Building NanoClaw agent container image..."
|
echo "Building NanoClaw agent container image..."
|
||||||
echo "Image: ${IMAGE_NAME}:${TAG}"
|
echo "Image: ${IMAGE_NAME}:${TAG}"
|
||||||
|
|
||||||
${CONTAINER_RUNTIME} build -t "${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 ""
|
||||||
echo "Build complete!"
|
echo "Build complete!"
|
||||||
echo "Image: ${IMAGE_NAME}:${TAG}"
|
echo "Image: ${IMAGE_NAME}:${TAG}"
|
||||||
|
|||||||
16
container/codex-runner/package.json
Normal file
16
container/codex-runner/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
294
container/codex-runner/src/index.ts
Normal file
294
container/codex-runner/src/index.ts
Normal file
@@ -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<string> {
|
||||||
|
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<string | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const poll = () => {
|
||||||
|
if (shouldClose()) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const messages = drainIpcInput();
|
||||||
|
if (messages.length > 0) {
|
||||||
|
resolve(messages.join('\n'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(poll, IPC_POLL_MS);
|
||||||
|
};
|
||||||
|
poll();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a 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<void> {
|
||||||
|
let containerInput: ContainerInput;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stdinData = await readStdin();
|
||||||
|
containerInput = JSON.parse(stdinData);
|
||||||
|
try { fs.unlinkSync('/tmp/input.json'); } catch { /* may not exist */ }
|
||||||
|
log(`Received input for group: ${containerInput.groupFolder}`);
|
||||||
|
} catch (err) {
|
||||||
|
writeOutput({
|
||||||
|
status: 'error',
|
||||||
|
result: null,
|
||||||
|
error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
});
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
15
container/codex-runner/tsconfig.json
Normal file
15
container/codex-runner/tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
292
package-lock.json
generated
292
package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
"cron-parser": "^5.5.0",
|
"cron-parser": "^5.5.0",
|
||||||
|
"discord.js": "^14.18.0",
|
||||||
"pino": "^9.6.0",
|
"pino": "^9.6.0",
|
||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "^13.0.0",
|
||||||
"yaml": "^2.8.2",
|
"yaml": "^2.8.2",
|
||||||
@@ -89,6 +90,136 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.3",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||||
@@ -915,6 +1046,39 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -961,12 +1125,20 @@
|
|||||||
"version": "22.19.11",
|
"version": "22.19.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
|
||||||
"integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==",
|
"integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"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": {
|
"node_modules/@vitest/coverage-v8": {
|
||||||
"version": "4.0.18",
|
"version": "4.0.18",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz",
|
||||||
@@ -1109,6 +1281,16 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"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": {
|
"node_modules/assertion-error": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
@@ -1291,6 +1473,42 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/end-of-stream": {
|
||||||
"version": "1.4.5",
|
"version": "1.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
"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==",
|
"integrity": "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fast-safe-stringify": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||||
@@ -1580,6 +1804,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/luxon": {
|
||||||
"version": "3.7.2",
|
"version": "3.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||||
@@ -1589,6 +1825,12 @@
|
|||||||
"node": ">=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": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@@ -1740,7 +1982,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -2289,13 +2530,24 @@
|
|||||||
"node": ">=14.0.0"
|
"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": {
|
"node_modules/tsx": {
|
||||||
"version": "4.21.0",
|
"version": "4.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "~0.27.0",
|
"esbuild": "~0.27.0",
|
||||||
"get-tsconfig": "^4.7.5"
|
"get-tsconfig": "^4.7.5"
|
||||||
@@ -2336,11 +2588,19 @@
|
|||||||
"node": ">=14.17"
|
"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": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
@@ -2355,7 +2615,6 @@
|
|||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -2431,7 +2690,6 @@
|
|||||||
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "4.0.18",
|
"@vitest/expect": "4.0.18",
|
||||||
"@vitest/mocker": "4.0.18",
|
"@vitest/mocker": "4.0.18",
|
||||||
@@ -2527,12 +2785,32 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/yaml": {
|
||||||
"version": "2.8.2",
|
"version": "2.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
"cron-parser": "^5.5.0",
|
"cron-parser": "^5.5.0",
|
||||||
|
"discord.js": "^14.18.0",
|
||||||
"pino": "^9.6.0",
|
"pino": "^9.6.0",
|
||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "^13.0.0",
|
||||||
"yaml": "^2.8.2",
|
"yaml": "^2.8.2",
|
||||||
|
|||||||
781
src/channels/discord.test.ts
Normal file
781
src/channels/discord.test.ts
Normal file
@@ -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<string, Handler[]>();
|
||||||
|
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>,
|
||||||
|
): 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<string, any>;
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
291
src/channels/discord.ts
Normal file
291
src/channels/discord.ts
Normal file
@@ -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<string, RegisteredGroup>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DiscordChannel implements Channel {
|
||||||
|
name = 'discord';
|
||||||
|
|
||||||
|
private client: Client | null = null;
|
||||||
|
private opts: DiscordChannelOpts;
|
||||||
|
private botToken: string;
|
||||||
|
private typingIntervals = new Map<string, NodeJS.Timeout>();
|
||||||
|
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<void> {
|
||||||
|
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<void>((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<void> {
|
||||||
|
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<void> {
|
||||||
|
if (this.client) {
|
||||||
|
this.client.destroy();
|
||||||
|
this.client = null;
|
||||||
|
logger.info('Discord bot stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setTyping(jid: string, isTyping: boolean): Promise<void> {
|
||||||
|
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');
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// Each import triggers the channel module's registerChannel() call.
|
// Each import triggers the channel module's registerChannel() call.
|
||||||
|
|
||||||
// discord
|
// discord
|
||||||
|
import './discord.js';
|
||||||
|
|
||||||
// gmail
|
// gmail
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
|
|||||||
|
|
||||||
export const CONTAINER_IMAGE =
|
export const CONTAINER_IMAGE =
|
||||||
process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest';
|
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(
|
export const CONTAINER_TIMEOUT = parseInt(
|
||||||
process.env.CONTAINER_TIMEOUT || '1800000',
|
process.env.CONTAINER_TIMEOUT || '1800000',
|
||||||
10,
|
10,
|
||||||
|
|||||||
@@ -4,9 +4,11 @@
|
|||||||
*/
|
*/
|
||||||
import { ChildProcess, exec, spawn } from 'child_process';
|
import { ChildProcess, exec, spawn } from 'child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
CODEX_CONTAINER_IMAGE,
|
||||||
CONTAINER_IMAGE,
|
CONTAINER_IMAGE,
|
||||||
CONTAINER_MAX_OUTPUT_SIZE,
|
CONTAINER_MAX_OUTPUT_SIZE,
|
||||||
CONTAINER_TIMEOUT,
|
CONTAINER_TIMEOUT,
|
||||||
@@ -26,6 +28,7 @@ import {
|
|||||||
stopContainer,
|
stopContainer,
|
||||||
} from './container-runtime.js';
|
} from './container-runtime.js';
|
||||||
import { detectAuthMode } from './credential-proxy.js';
|
import { detectAuthMode } from './credential-proxy.js';
|
||||||
|
import { readEnvFile } from './env.js';
|
||||||
import { validateAdditionalMounts } from './mount-security.js';
|
import { validateAdditionalMounts } from './mount-security.js';
|
||||||
import { RegisteredGroup } from './types.js';
|
import { RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
@@ -41,6 +44,7 @@ export interface ContainerInput {
|
|||||||
isMain: boolean;
|
isMain: boolean;
|
||||||
isScheduledTask?: boolean;
|
isScheduledTask?: boolean;
|
||||||
assistantName?: string;
|
assistantName?: string;
|
||||||
|
agentType?: 'claude-code' | 'codex';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContainerOutput {
|
export interface ContainerOutput {
|
||||||
@@ -76,17 +80,6 @@ function buildVolumeMounts(
|
|||||||
readonly: true,
|
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
|
// Main also gets its group folder as the working directory
|
||||||
mounts.push({
|
mounts.push({
|
||||||
hostPath: groupDir,
|
hostPath: groupDir,
|
||||||
@@ -163,6 +156,40 @@ function buildVolumeMounts(
|
|||||||
readonly: false,
|
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
|
// Per-group IPC namespace: each group gets its own IPC directory
|
||||||
// This prevents cross-group privilege escalation via IPC
|
// This prevents cross-group privilege escalation via IPC
|
||||||
const groupIpcDir = resolveGroupIpcPath(group.folder);
|
const groupIpcDir = resolveGroupIpcPath(group.folder);
|
||||||
@@ -175,20 +202,24 @@ function buildVolumeMounts(
|
|||||||
readonly: false,
|
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
|
// can customize it (add tools, change behavior) without affecting other
|
||||||
// groups. Recompiled on container startup via entrypoint.sh.
|
// 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(
|
const agentRunnerSrc = path.join(
|
||||||
projectRoot,
|
projectRoot,
|
||||||
'container',
|
'container',
|
||||||
'agent-runner',
|
runnerDirName,
|
||||||
'src',
|
'src',
|
||||||
);
|
);
|
||||||
const groupAgentRunnerDir = path.join(
|
const groupAgentRunnerDir = path.join(
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
'sessions',
|
'sessions',
|
||||||
group.folder,
|
group.folder,
|
||||||
'agent-runner-src',
|
runnerSrcSuffix,
|
||||||
);
|
);
|
||||||
if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) {
|
if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) {
|
||||||
fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true });
|
fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true });
|
||||||
@@ -215,12 +246,22 @@ function buildVolumeMounts(
|
|||||||
function buildContainerArgs(
|
function buildContainerArgs(
|
||||||
mounts: VolumeMount[],
|
mounts: VolumeMount[],
|
||||||
containerName: string,
|
containerName: string,
|
||||||
|
isMain: boolean,
|
||||||
|
agentType: 'claude-code' | 'codex' = 'claude-code',
|
||||||
): string[] {
|
): string[] {
|
||||||
const args: string[] = ['run', '-i', '--rm', '--name', containerName];
|
const args: string[] = ['run', '-i', '--rm', '--name', containerName];
|
||||||
|
|
||||||
// Pass host timezone so container's local time matches the user's
|
// Pass host timezone so container's local time matches the user's
|
||||||
args.push('-e', `TZ=${TIMEZONE}`);
|
args.push('-e', `TZ=${TIMEZONE}`);
|
||||||
|
|
||||||
|
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 {
|
||||||
// Route API traffic through the credential proxy (containers never see real secrets)
|
// Route API traffic through the credential proxy (containers never see real secrets)
|
||||||
args.push(
|
args.push(
|
||||||
'-e',
|
'-e',
|
||||||
@@ -228,26 +269,46 @@ function buildContainerArgs(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Mirror the host's auth method with a placeholder value.
|
// 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();
|
const authMode = detectAuthMode();
|
||||||
if (authMode === 'api-key') {
|
if (authMode === 'api-key') {
|
||||||
args.push('-e', 'ANTHROPIC_API_KEY=placeholder');
|
args.push('-e', 'ANTHROPIC_API_KEY=placeholder');
|
||||||
} else {
|
} else {
|
||||||
args.push('-e', 'CLAUDE_CODE_OAUTH_TOKEN=placeholder');
|
args.push('-e', 'CLAUDE_CODE_OAUTH_TOKEN=placeholder');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Runtime-specific args for host gateway resolution
|
// Runtime-specific args for host gateway resolution
|
||||||
args.push(...hostGatewayArgs());
|
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.
|
// 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),
|
// Skip when running as root (uid 0), as the container's node user (uid 1000),
|
||||||
// or when getuid is unavailable (native Windows without WSL).
|
// or when getuid is unavailable (native Windows without WSL).
|
||||||
const hostUid = process.getuid?.();
|
const hostUid = process.getuid?.();
|
||||||
const hostGid = process.getgid?.();
|
const hostGid = process.getgid?.();
|
||||||
if (hostUid != null && hostUid !== 0 && hostUid !== 1000) {
|
if (hostUid != null && hostUid !== 0 && hostUid !== 1000) {
|
||||||
|
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('--user', `${hostUid}:${hostGid}`);
|
||||||
|
}
|
||||||
args.push('-e', 'HOME=/home/node');
|
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;
|
return args;
|
||||||
}
|
}
|
||||||
@@ -278,7 +340,13 @@ export async function runContainerAgent(
|
|||||||
const mounts = buildVolumeMounts(group, input.isMain);
|
const mounts = buildVolumeMounts(group, input.isMain);
|
||||||
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
|
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||||
const containerName = `nanoclaw-${safeName}-${Date.now()}`;
|
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(
|
logger.debug(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -32,9 +32,12 @@ beforeEach(() => {
|
|||||||
// --- Pure functions ---
|
// --- Pure functions ---
|
||||||
|
|
||||||
describe('readonlyMountArgs', () => {
|
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');
|
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();
|
ensureContainerRuntimeRunning();
|
||||||
|
|
||||||
expect(mockExecSync).toHaveBeenCalledTimes(1);
|
expect(mockExecSync).toHaveBeenCalledTimes(1);
|
||||||
expect(mockExecSync).toHaveBeenCalledWith(`${CONTAINER_RUNTIME_BIN} info`, {
|
expect(mockExecSync).toHaveBeenCalledWith(
|
||||||
stdio: 'pipe',
|
`${CONTAINER_RUNTIME_BIN} system status`,
|
||||||
timeout: 10000,
|
{ stdio: 'pipe' },
|
||||||
});
|
|
||||||
expect(logger.debug).toHaveBeenCalledWith(
|
|
||||||
'Container runtime already running',
|
|
||||||
);
|
);
|
||||||
|
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(() => {
|
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(
|
expect(() => ensureContainerRuntimeRunning()).toThrow(
|
||||||
@@ -79,17 +99,21 @@ describe('ensureContainerRuntimeRunning', () => {
|
|||||||
// --- cleanupOrphans ---
|
// --- cleanupOrphans ---
|
||||||
|
|
||||||
describe('cleanupOrphans', () => {
|
describe('cleanupOrphans', () => {
|
||||||
it('stops orphaned nanoclaw containers', () => {
|
it('stops orphaned nanoclaw containers from JSON output', () => {
|
||||||
// docker ps returns container names, one per line
|
// Apple Container ls returns JSON
|
||||||
mockExecSync.mockReturnValueOnce(
|
const lsOutput = JSON.stringify([
|
||||||
'nanoclaw-group1-111\nnanoclaw-group2-222\n',
|
{ 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
|
// stop calls succeed
|
||||||
mockExecSync.mockReturnValue('');
|
mockExecSync.mockReturnValue('');
|
||||||
|
|
||||||
cleanupOrphans();
|
cleanupOrphans();
|
||||||
|
|
||||||
// ps + 2 stop calls
|
// ls + 2 stop calls (only running nanoclaw- containers)
|
||||||
expect(mockExecSync).toHaveBeenCalledTimes(3);
|
expect(mockExecSync).toHaveBeenCalledTimes(3);
|
||||||
expect(mockExecSync).toHaveBeenNthCalledWith(
|
expect(mockExecSync).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
@@ -98,17 +122,17 @@ describe('cleanupOrphans', () => {
|
|||||||
);
|
);
|
||||||
expect(mockExecSync).toHaveBeenNthCalledWith(
|
expect(mockExecSync).toHaveBeenNthCalledWith(
|
||||||
3,
|
3,
|
||||||
`${CONTAINER_RUNTIME_BIN} stop nanoclaw-group2-222`,
|
`${CONTAINER_RUNTIME_BIN} stop nanoclaw-group3-333`,
|
||||||
{ stdio: 'pipe' },
|
{ stdio: 'pipe' },
|
||||||
);
|
);
|
||||||
expect(logger.info).toHaveBeenCalledWith(
|
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',
|
'Stopped orphaned containers',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does nothing when no orphans exist', () => {
|
it('does nothing when no orphans exist', () => {
|
||||||
mockExecSync.mockReturnValueOnce('');
|
mockExecSync.mockReturnValueOnce('[]');
|
||||||
|
|
||||||
cleanupOrphans();
|
cleanupOrphans();
|
||||||
|
|
||||||
@@ -116,9 +140,9 @@ describe('cleanupOrphans', () => {
|
|||||||
expect(logger.info).not.toHaveBeenCalled();
|
expect(logger.info).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('warns and continues when ps fails', () => {
|
it('warns and continues when ls fails', () => {
|
||||||
mockExecSync.mockImplementationOnce(() => {
|
mockExecSync.mockImplementationOnce(() => {
|
||||||
throw new Error('docker not available');
|
throw new Error('container not available');
|
||||||
});
|
});
|
||||||
|
|
||||||
cleanupOrphans(); // should not throw
|
cleanupOrphans(); // should not throw
|
||||||
@@ -130,7 +154,11 @@ describe('cleanupOrphans', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('continues stopping remaining containers when one stop fails', () => {
|
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
|
// First stop fails
|
||||||
mockExecSync.mockImplementationOnce(() => {
|
mockExecSync.mockImplementationOnce(() => {
|
||||||
throw new Error('already stopped');
|
throw new Error('already stopped');
|
||||||
|
|||||||
@@ -3,58 +3,35 @@
|
|||||||
* All runtime-specific logic lives here so swapping runtimes means changing one file.
|
* All runtime-specific logic lives here so swapping runtimes means changing one file.
|
||||||
*/
|
*/
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import fs from 'fs';
|
|
||||||
import os from 'os';
|
|
||||||
|
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
/** The container runtime binary name. */
|
/** The container runtime binary name. */
|
||||||
export const CONTAINER_RUNTIME_BIN = 'docker';
|
export const CONTAINER_RUNTIME_BIN = 'container';
|
||||||
|
|
||||||
/** Hostname containers use to reach the host machine. */
|
|
||||||
export const CONTAINER_HOST_GATEWAY = 'host.docker.internal';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Address the credential proxy binds to.
|
* Hostname containers use to reach the host machine.
|
||||||
* Docker Desktop (macOS): 127.0.0.1 — the VM routes host.docker.internal to loopback.
|
* Apple Container VMs access the host via the default gateway (192.168.64.1).
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export const PROXY_BIND_HOST =
|
export const CONTAINER_HOST_GATEWAY = '192.168.64.1';
|
||||||
process.env.CREDENTIAL_PROXY_HOST || detectProxyBindHost();
|
|
||||||
|
|
||||||
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.
|
* CLI args needed for the container to resolve the host gateway.
|
||||||
if (fs.existsSync('/proc/sys/fs/binfmt_misc/WSLInterop')) return '127.0.0.1';
|
* Apple Container provides host networking natively on macOS — no extra args needed.
|
||||||
|
*/
|
||||||
// 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. */
|
|
||||||
export function hostGatewayArgs(): string[] {
|
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 [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns CLI args for a readonly bind mount. */
|
/** Returns CLI args for a readonly bind mount. */
|
||||||
export function readonlyMountArgs(
|
export function readonlyMountArgs(hostPath: string, containerPath: string): string[] {
|
||||||
hostPath: string,
|
return ['--mount', `type=bind,source=${hostPath},target=${containerPath},readonly`];
|
||||||
containerPath: string,
|
|
||||||
): string[] {
|
|
||||||
return ['-v', `${hostPath}:${containerPath}:ro`];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the shell command to stop a container by name. */
|
/** Returns the shell command to stop a container by name. */
|
||||||
@@ -65,13 +42,15 @@ export function stopContainer(name: string): string {
|
|||||||
/** Ensure the container runtime is running, starting it if needed. */
|
/** Ensure the container runtime is running, starting it if needed. */
|
||||||
export function ensureContainerRuntimeRunning(): void {
|
export function ensureContainerRuntimeRunning(): void {
|
||||||
try {
|
try {
|
||||||
execSync(`${CONTAINER_RUNTIME_BIN} info`, {
|
execSync(`${CONTAINER_RUNTIME_BIN} system status`, { stdio: 'pipe' });
|
||||||
stdio: 'pipe',
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
logger.debug('Container runtime already running');
|
logger.debug('Container runtime already running');
|
||||||
|
} catch {
|
||||||
|
logger.info('Starting container runtime...');
|
||||||
|
try {
|
||||||
|
execSync(`${CONTAINER_RUNTIME_BIN} system start`, { stdio: 'pipe', timeout: 30000 });
|
||||||
|
logger.info('Container runtime started');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err }, 'Failed to reach container runtime');
|
logger.error({ err }, 'Failed to start container runtime');
|
||||||
console.error(
|
console.error(
|
||||||
'\n╔════════════════════════════════════════════════════════════════╗',
|
'\n╔════════════════════════════════════════════════════════════════╗',
|
||||||
);
|
);
|
||||||
@@ -85,10 +64,10 @@ export function ensureContainerRuntimeRunning(): void {
|
|||||||
'║ Agents cannot run without a container runtime. To fix: ║',
|
'║ Agents cannot run without a container runtime. To fix: ║',
|
||||||
);
|
);
|
||||||
console.error(
|
console.error(
|
||||||
'║ 1. Ensure Docker is installed and running ║',
|
'║ 1. Ensure Apple Container is installed ║',
|
||||||
);
|
);
|
||||||
console.error(
|
console.error(
|
||||||
'║ 2. Run: docker info ║',
|
'║ 2. Run: container system start ║',
|
||||||
);
|
);
|
||||||
console.error(
|
console.error(
|
||||||
'║ 3. Restart NanoClaw ║',
|
'║ 3. Restart NanoClaw ║',
|
||||||
@@ -98,28 +77,27 @@ export function ensureContainerRuntimeRunning(): void {
|
|||||||
);
|
);
|
||||||
throw new Error('Container runtime is required but failed to start');
|
throw new Error('Container runtime is required but failed to start');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Kill orphaned NanoClaw containers from previous runs. */
|
/** Kill orphaned NanoClaw containers from previous runs. */
|
||||||
export function cleanupOrphans(): void {
|
export function cleanupOrphans(): void {
|
||||||
try {
|
try {
|
||||||
const output = execSync(
|
const output = execSync(`${CONTAINER_RUNTIME_BIN} ls --format json`, {
|
||||||
`${CONTAINER_RUNTIME_BIN} ps --filter name=nanoclaw- --format '{{.Names}}'`,
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
{ stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' },
|
encoding: 'utf-8',
|
||||||
);
|
});
|
||||||
const orphans = output.trim().split('\n').filter(Boolean);
|
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) {
|
for (const name of orphans) {
|
||||||
try {
|
try {
|
||||||
execSync(stopContainer(name), { stdio: 'pipe' });
|
execSync(stopContainer(name), { stdio: 'pipe' });
|
||||||
} catch {
|
} catch { /* already stopped */ }
|
||||||
/* already stopped */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (orphans.length > 0) {
|
if (orphans.length > 0) {
|
||||||
logger.info(
|
logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers');
|
||||||
{ count: orphans.length, names: orphans },
|
|
||||||
'Stopped orphaned containers',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn({ err }, 'Failed to clean up orphaned containers');
|
logger.warn({ err }, 'Failed to clean up orphaned containers');
|
||||||
|
|||||||
@@ -187,25 +187,26 @@ describe('getMessagesSince', () => {
|
|||||||
'2024-01-01T00:00:02.000Z',
|
'2024-01-01T00:00:02.000Z',
|
||||||
'Andy',
|
'Andy',
|
||||||
);
|
);
|
||||||
// Should exclude m1, m2 (before/at timestamp), m3 (bot message)
|
// Should exclude m1, m2 (before/at timestamp); m3 (bot) is now included
|
||||||
expect(msgs).toHaveLength(1);
|
expect(msgs).toHaveLength(2);
|
||||||
expect(msgs[0].content).toBe('third');
|
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(
|
const msgs = getMessagesSince(
|
||||||
'group@g.us',
|
'group@g.us',
|
||||||
'2024-01-01T00:00:00.000Z',
|
'2024-01-01T00:00:00.000Z',
|
||||||
'Andy',
|
'Andy',
|
||||||
);
|
);
|
||||||
const botMsgs = msgs.filter((m) => m.content === 'bot reply');
|
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');
|
const msgs = getMessagesSince('group@g.us', '', 'Andy');
|
||||||
// 3 user messages (bot message excluded)
|
// 3 user messages + 1 bot message
|
||||||
expect(msgs).toHaveLength(3);
|
expect(msgs).toHaveLength(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters pre-migration bot messages via content prefix backstop', () => {
|
it('filters pre-migration bot messages via content prefix backstop', () => {
|
||||||
@@ -275,8 +276,8 @@ describe('getNewMessages', () => {
|
|||||||
'2024-01-01T00:00:00.000Z',
|
'2024-01-01T00:00:00.000Z',
|
||||||
'Andy',
|
'Andy',
|
||||||
);
|
);
|
||||||
// Excludes bot message, returns 3 user messages
|
// Includes bot message, returns all 4 messages
|
||||||
expect(messages).toHaveLength(3);
|
expect(messages).toHaveLength(4);
|
||||||
expect(newTimestamp).toBe('2024-01-01T00:00:04.000Z');
|
expect(newTimestamp).toBe('2024-01-01T00:00:04.000Z');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -286,9 +287,10 @@ describe('getNewMessages', () => {
|
|||||||
'2024-01-01T00:00:02.000Z',
|
'2024-01-01T00:00:02.000Z',
|
||||||
'Andy',
|
'Andy',
|
||||||
);
|
);
|
||||||
// Only g1 msg2 (after ts, not bot)
|
// bot reply (00:00:03) + g1 msg2 (00:00:04) after ts
|
||||||
expect(messages).toHaveLength(1);
|
expect(messages).toHaveLength(2);
|
||||||
expect(messages[0].content).toBe('g1 msg2');
|
expect(messages[0].content).toBe('bot reply');
|
||||||
|
expect(messages[1].content).toBe('g1 msg2');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty for no registered groups', () => {
|
it('returns empty for no registered groups', () => {
|
||||||
|
|||||||
29
src/db.ts
29
src/db.ts
@@ -119,6 +119,15 @@ function createSchema(database: Database.Database): void {
|
|||||||
/* column already exists */
|
/* 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)
|
// Add channel and is_group columns if they don't exist (migration for existing DBs)
|
||||||
try {
|
try {
|
||||||
database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`);
|
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.
|
// Subquery takes the N most recent, outer query re-sorts chronologically.
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT * FROM (
|
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
|
FROM messages
|
||||||
WHERE timestamp > ? AND chat_jid IN (${placeholders})
|
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
|
AND content != '' AND content IS NOT NULL
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
@@ -349,10 +358,10 @@ export function getMessagesSince(
|
|||||||
// Subquery takes the N most recent, outer query re-sorts chronologically.
|
// Subquery takes the N most recent, outer query re-sorts chronologically.
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT * FROM (
|
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
|
FROM messages
|
||||||
WHERE chat_jid = ? AND timestamp > ?
|
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
|
AND content != '' AND content IS NOT NULL
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
@@ -363,6 +372,18 @@ export function getMessagesSince(
|
|||||||
.all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit) as NewMessage[];
|
.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(
|
export function createTask(
|
||||||
task: Omit<ScheduledTask, 'last_run' | 'last_result'>,
|
task: Omit<ScheduledTask, 'last_run' | 'last_result'>,
|
||||||
): void {
|
): void {
|
||||||
|
|||||||
@@ -59,11 +59,16 @@ export class GroupQueue {
|
|||||||
this.processMessagesFn = fn;
|
this.processMessagesFn = fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
enqueueMessageCheck(groupJid: string): void {
|
enqueueMessageCheck(groupJid: string, groupFolder?: string): void {
|
||||||
if (this.shuttingDown) return;
|
if (this.shuttingDown) return;
|
||||||
|
|
||||||
const state = this.getGroup(groupJid);
|
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) {
|
if (state.active) {
|
||||||
state.pendingMessages = true;
|
state.pendingMessages = true;
|
||||||
logger.debug({ groupJid }, 'Container active, message queued');
|
logger.debug({ groupJid }, 'Container active, message queued');
|
||||||
|
|||||||
85
src/index.ts
85
src/index.ts
@@ -31,6 +31,7 @@ import {
|
|||||||
getAllRegisteredGroups,
|
getAllRegisteredGroups,
|
||||||
getAllSessions,
|
getAllSessions,
|
||||||
getAllTasks,
|
getAllTasks,
|
||||||
|
getLastHumanMessageTimestamp,
|
||||||
getMessagesSince,
|
getMessagesSince,
|
||||||
getNewMessages,
|
getNewMessages,
|
||||||
getRegisteredGroup,
|
getRegisteredGroup,
|
||||||
@@ -52,6 +53,7 @@ import {
|
|||||||
loadSenderAllowlist,
|
loadSenderAllowlist,
|
||||||
shouldDropMessage,
|
shouldDropMessage,
|
||||||
} from './sender-allowlist.js';
|
} from './sender-allowlist.js';
|
||||||
|
import { extractSessionCommand, handleSessionCommand, isSessionCommandAllowed } from './session-commands.js';
|
||||||
import { startSchedulerLoop } from './task-scheduler.js';
|
import { startSchedulerLoop } from './task-scheduler.js';
|
||||||
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
@@ -164,6 +166,33 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
|
|||||||
|
|
||||||
if (missedMessages.length === 0) return true;
|
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
|
// For non-main groups, check if trigger is required and present
|
||||||
if (!isMainGroup && group.requiresTrigger !== false) {
|
if (!isMainGroup && group.requiresTrigger !== false) {
|
||||||
const allowlistCfg = loadSenderAllowlist();
|
const allowlistCfg = loadSenderAllowlist();
|
||||||
@@ -172,7 +201,9 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
|
|||||||
TRIGGER_PATTERN.test(m.content.trim()) &&
|
TRIGGER_PATTERN.test(m.content.trim()) &&
|
||||||
(m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
|
(m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
|
||||||
);
|
);
|
||||||
if (!hasTrigger) return true;
|
if (!hasTrigger) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const prompt = formatMessages(missedMessages, TIMEZONE);
|
const prompt = formatMessages(missedMessages, TIMEZONE);
|
||||||
@@ -388,6 +419,51 @@ async function startMessageLoop(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isMainGroup = group.isMain === true;
|
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;
|
const needsTrigger = !isMainGroup && group.requiresTrigger !== false;
|
||||||
|
|
||||||
// For non-main groups, only act on trigger messages.
|
// For non-main groups, only act on trigger messages.
|
||||||
@@ -431,7 +507,7 @@ async function startMessageLoop(): Promise<void> {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// No active container — enqueue for a new one
|
// 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 },
|
{ group: group.name, pendingCount: pending.length },
|
||||||
'Recovery: found unprocessed messages',
|
'Recovery: found unprocessed messages',
|
||||||
);
|
);
|
||||||
queue.enqueueMessageCheck(chatJid);
|
queue.enqueueMessageCheck(chatJid, group.folder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -472,7 +548,7 @@ async function main(): Promise<void> {
|
|||||||
loadState();
|
loadState();
|
||||||
|
|
||||||
// Start credential proxy (containers route API calls through this)
|
// Start credential proxy (containers route API calls through this)
|
||||||
const proxyServer = await startCredentialProxy(
|
await startCredentialProxy(
|
||||||
CREDENTIAL_PROXY_PORT,
|
CREDENTIAL_PROXY_PORT,
|
||||||
PROXY_BIND_HOST,
|
PROXY_BIND_HOST,
|
||||||
);
|
);
|
||||||
@@ -480,7 +556,6 @@ async function main(): Promise<void> {
|
|||||||
// Graceful shutdown handlers
|
// Graceful shutdown handlers
|
||||||
const shutdown = async (signal: string) => {
|
const shutdown = async (signal: string) => {
|
||||||
logger.info({ signal }, 'Shutdown signal received');
|
logger.info({ signal }, 'Shutdown signal received');
|
||||||
proxyServer.close();
|
|
||||||
await queue.shutdown(10000);
|
await queue.shutdown(10000);
|
||||||
for (const ch of channels) await ch.disconnect();
|
for (const ch of channels) await ch.disconnect();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
214
src/session-commands.test.ts
Normal file
214
src/session-commands.test.ts
Normal file
@@ -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> = {}): NewMessage {
|
||||||
|
return {
|
||||||
|
id: 'msg-1',
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
sender: 'user@test',
|
||||||
|
sender_name: 'User',
|
||||||
|
content,
|
||||||
|
timestamp: '100',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDeps(overrides: Partial<SessionCommandDeps> = {}): 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('<formatted>'),
|
||||||
|
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('<formatted>', 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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
143
src/session-commands.ts
Normal file
143
src/session-commands.ts
Normal file
@@ -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<void>;
|
||||||
|
setTyping: (typing: boolean) => Promise<void>;
|
||||||
|
runAgent: (
|
||||||
|
prompt: string,
|
||||||
|
onOutput: (result: AgentResult) => Promise<void>,
|
||||||
|
) => 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(/<internal>[\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 };
|
||||||
|
}
|
||||||
@@ -32,6 +32,8 @@ export interface ContainerConfig {
|
|||||||
timeout?: number; // Default: 300000 (5 minutes)
|
timeout?: number; // Default: 300000 (5 minutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AgentType = 'claude-code' | 'codex';
|
||||||
|
|
||||||
export interface RegisteredGroup {
|
export interface RegisteredGroup {
|
||||||
name: string;
|
name: string;
|
||||||
folder: string;
|
folder: string;
|
||||||
@@ -40,6 +42,7 @@ export interface RegisteredGroup {
|
|||||||
containerConfig?: ContainerConfig;
|
containerConfig?: ContainerConfig;
|
||||||
requiresTrigger?: boolean; // Default: true for groups, false for solo chats
|
requiresTrigger?: boolean; // Default: true for groups, false for solo chats
|
||||||
isMain?: boolean; // True for the main control group (no trigger, elevated privileges)
|
isMain?: boolean; // True for the main control group (no trigger, elevated privileges)
|
||||||
|
agentType?: AgentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NewMessage {
|
export interface NewMessage {
|
||||||
|
|||||||
Reference in New Issue
Block a user