refactor: remove legacy container and non-discord remnants
This commit is contained in:
@@ -18,6 +18,11 @@ import {
|
||||
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
|
||||
import { logger } from './logger.js';
|
||||
import { readEnvFile } from './env.js';
|
||||
import { isPairedRoomJid } from './db.js';
|
||||
import {
|
||||
readPairedRoomPrompt,
|
||||
readPlatformPrompt,
|
||||
} from './platform-prompts.js';
|
||||
import { RegisteredGroup } from './types.js';
|
||||
|
||||
// Sentinel markers for robust output parsing (must match agent-runner)
|
||||
@@ -29,6 +34,7 @@ export interface AgentInput {
|
||||
sessionId?: string;
|
||||
groupFolder: string;
|
||||
chatJid: string;
|
||||
runId?: string;
|
||||
isMain: boolean;
|
||||
isScheduledTask?: boolean;
|
||||
assistantName?: string;
|
||||
@@ -49,6 +55,7 @@ export interface AgentOutput {
|
||||
function prepareGroupEnvironment(
|
||||
group: RegisteredGroup,
|
||||
isMain: boolean,
|
||||
chatJid: string,
|
||||
): { env: Record<string, string>; groupDir: string; runnerDir: string } {
|
||||
const projectRoot = process.cwd();
|
||||
const groupDir = resolveGroupFolderPath(group.folder);
|
||||
@@ -82,14 +89,14 @@ function prepareGroupEnvironment(
|
||||
}
|
||||
|
||||
// Sync skills into each group's .claude/ session dir
|
||||
// Sources: 1) user's global ~/.claude/skills 2) project workDir/.claude/skills 3) container/skills/
|
||||
// Sources: 1) user's global ~/.claude/skills 2) project workDir/.claude/skills 3) runners/skills/
|
||||
const workDirClaude = group.workDir
|
||||
? path.join(group.workDir, '.claude')
|
||||
: null;
|
||||
const skillSources = [
|
||||
path.join(os.homedir(), '.claude', 'skills'),
|
||||
...(workDirClaude ? [path.join(workDirClaude, 'skills')] : []),
|
||||
path.join(projectRoot, 'container', 'skills'),
|
||||
path.join(projectRoot, 'runners', 'skills'),
|
||||
];
|
||||
const skillsDst = path.join(groupSessionsDir, 'skills');
|
||||
for (const src of skillSources) {
|
||||
@@ -114,21 +121,36 @@ function prepareGroupEnvironment(
|
||||
|
||||
// Global memory directory (for non-main groups)
|
||||
const globalDir = path.join(GROUPS_DIR, 'global');
|
||||
const globalClaudeMdPath = path.join(globalDir, 'CLAUDE.md');
|
||||
const isPairedRoom = isPairedRoomJid(chatJid);
|
||||
|
||||
// Additional mount directories (validated)
|
||||
const extraDirs: string[] = [];
|
||||
if (group.agentConfig?.additionalMounts) {
|
||||
for (const mount of group.agentConfig.additionalMounts) {
|
||||
if (fs.existsSync(mount.hostPath)) {
|
||||
extraDirs.push(mount.hostPath);
|
||||
}
|
||||
}
|
||||
const claudePlatformPrompt = readPlatformPrompt('claude-code', projectRoot);
|
||||
const claudePairedRoomPrompt = isPairedRoom
|
||||
? readPairedRoomPrompt('claude-code', projectRoot)
|
||||
: undefined;
|
||||
const globalClaudeMemory =
|
||||
!isMain && fs.existsSync(globalClaudeMdPath)
|
||||
? fs.readFileSync(globalClaudeMdPath, 'utf-8').trim()
|
||||
: undefined;
|
||||
const sessionClaudeMd = [
|
||||
claudePlatformPrompt,
|
||||
claudePairedRoomPrompt,
|
||||
globalClaudeMemory,
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join('\n\n---\n\n')
|
||||
.trim();
|
||||
const sessionClaudeMdPath = path.join(groupSessionsDir, 'CLAUDE.md');
|
||||
if (sessionClaudeMd) {
|
||||
fs.writeFileSync(sessionClaudeMdPath, sessionClaudeMd + '\n');
|
||||
} else if (fs.existsSync(sessionClaudeMdPath)) {
|
||||
fs.unlinkSync(sessionClaudeMdPath);
|
||||
}
|
||||
|
||||
// Determine runner directory
|
||||
const agentType = group.agentType || 'claude-code';
|
||||
const runnerDirName = agentType === 'codex' ? 'codex-runner' : 'agent-runner';
|
||||
const runnerDir = path.join(projectRoot, 'container', runnerDirName);
|
||||
const runnerDir = path.join(projectRoot, 'runners', runnerDirName);
|
||||
|
||||
// Build environment variables for the runner process
|
||||
const envVars = readEnvFile([
|
||||
@@ -170,7 +192,6 @@ function prepareGroupEnvironment(
|
||||
NANOCLAW_GROUP_DIR: groupDir,
|
||||
NANOCLAW_IPC_DIR: groupIpcDir,
|
||||
NANOCLAW_GLOBAL_DIR: globalDir,
|
||||
NANOCLAW_EXTRA_DIR: extraDirs.length > 0 ? extraDirs[0] : '',
|
||||
// Working directory override (agent uses this as cwd instead of group dir)
|
||||
...(group.workDir ? { NANOCLAW_WORK_DIR: group.workDir } : {}),
|
||||
// MCP server context
|
||||
@@ -214,18 +235,32 @@ function prepareGroupEnvironment(
|
||||
const authSrc = path.join(hostCodexDir, 'auth.json');
|
||||
const authDst = path.join(sessionCodexDir, 'auth.json');
|
||||
if (fs.existsSync(authSrc)) fs.copyFileSync(authSrc, authDst);
|
||||
for (const file of ['config.toml', 'config.json', 'instructions.md']) {
|
||||
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.copyFileSync(src, dst);
|
||||
}
|
||||
}
|
||||
const sessionAgentsPath = path.join(sessionCodexDir, 'AGENTS.md');
|
||||
const codexPlatformPrompt = readPlatformPrompt('codex', projectRoot);
|
||||
const codexPairedRoomPrompt = isPairedRoom
|
||||
? readPairedRoomPrompt('codex', projectRoot)
|
||||
: undefined;
|
||||
const sessionAgents = [codexPlatformPrompt, codexPairedRoomPrompt]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join('\n\n---\n\n')
|
||||
.trim();
|
||||
if (sessionAgents) {
|
||||
fs.writeFileSync(sessionAgentsPath, sessionAgents + '\n');
|
||||
} else if (fs.existsSync(sessionAgentsPath)) {
|
||||
fs.unlinkSync(sessionAgentsPath);
|
||||
}
|
||||
// Sync skills into Codex session dir
|
||||
// SSOT: ~/.claude/skills/ (shared with Claude Code) + container/skills/
|
||||
// SSOT: ~/.claude/skills/ (shared with Claude Code) + runners/skills/
|
||||
const codexSkillSources = [
|
||||
path.join(os.homedir(), '.claude', 'skills'),
|
||||
path.join(projectRoot, 'container', 'skills'),
|
||||
path.join(projectRoot, 'runners', 'skills'),
|
||||
];
|
||||
const codexSkillsDst = path.join(sessionCodexDir, 'skills');
|
||||
for (const src of codexSkillSources) {
|
||||
@@ -245,7 +280,7 @@ function prepareGroupEnvironment(
|
||||
// Inject nanoclaw MCP server into Codex config.toml
|
||||
const mcpServerPath = path.join(
|
||||
projectRoot,
|
||||
'container',
|
||||
'runners',
|
||||
'agent-runner',
|
||||
'dist',
|
||||
'ipc-mcp-stdio.js',
|
||||
@@ -323,17 +358,22 @@ export async function runAgentProcess(
|
||||
const { env, groupDir, runnerDir } = prepareGroupEnvironment(
|
||||
group,
|
||||
input.isMain,
|
||||
input.chatJid,
|
||||
);
|
||||
if (input.runId) {
|
||||
env.NANOCLAW_RUN_ID = input.runId;
|
||||
}
|
||||
|
||||
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
const processName = `nanoclaw-${safeName}-${Date.now()}`;
|
||||
const processSuffix = input.runId || `${Date.now()}`;
|
||||
const processName = `nanoclaw-${safeName}-${processSuffix}`;
|
||||
|
||||
// Check if runner is built
|
||||
const distEntry = path.join(runnerDir, 'dist', 'index.js');
|
||||
if (!fs.existsSync(distEntry)) {
|
||||
logger.error(
|
||||
{ runnerDir },
|
||||
'Runner not built. Run: cd container/agent-runner && npm install && npm run build',
|
||||
{ runnerDir, chatJid: input.chatJid, runId: input.runId },
|
||||
'Runner not built. Run: cd runners/agent-runner && npm install && npm run build',
|
||||
);
|
||||
return {
|
||||
status: 'error',
|
||||
@@ -345,6 +385,9 @@ export async function runAgentProcess(
|
||||
logger.info(
|
||||
{
|
||||
group: group.name,
|
||||
chatJid: input.chatJid,
|
||||
groupFolder: input.groupFolder,
|
||||
runId: input.runId,
|
||||
processName,
|
||||
agentType: group.agentType || 'claude-code',
|
||||
isMain: input.isMain,
|
||||
@@ -386,7 +429,12 @@ export async function runAgentProcess(
|
||||
stdout += chunk.slice(0, remaining);
|
||||
stdoutTruncated = true;
|
||||
logger.warn(
|
||||
{ group: group.name, size: stdout.length },
|
||||
{
|
||||
group: group.name,
|
||||
chatJid: input.chatJid,
|
||||
runId: input.runId,
|
||||
size: stdout.length,
|
||||
},
|
||||
'Agent stdout truncated due to size limit',
|
||||
);
|
||||
} else {
|
||||
@@ -416,7 +464,12 @@ export async function runAgentProcess(
|
||||
outputChain = outputChain.then(() => onOutput(parsed));
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ group: group.name, error: err },
|
||||
{
|
||||
group: group.name,
|
||||
chatJid: input.chatJid,
|
||||
runId: input.runId,
|
||||
error: err,
|
||||
},
|
||||
'Failed to parse streamed output chunk',
|
||||
);
|
||||
}
|
||||
@@ -424,22 +477,6 @@ export async function runAgentProcess(
|
||||
}
|
||||
});
|
||||
|
||||
proc.stderr.on('data', (data) => {
|
||||
const chunk = data.toString();
|
||||
const lines = chunk.trim().split('\n');
|
||||
for (const line of lines) {
|
||||
if (line) logger.debug({ agent: group.folder }, line);
|
||||
}
|
||||
if (stderrTruncated) return;
|
||||
const remaining = AGENT_MAX_OUTPUT_SIZE - stderr.length;
|
||||
if (chunk.length > remaining) {
|
||||
stderr += chunk.slice(0, remaining);
|
||||
stderrTruncated = true;
|
||||
} else {
|
||||
stderr += chunk;
|
||||
}
|
||||
});
|
||||
|
||||
let timedOut = false;
|
||||
let hadStreamingOutput = false;
|
||||
const configTimeout = group.agentConfig?.timeout || AGENT_TIMEOUT;
|
||||
@@ -448,7 +485,12 @@ export async function runAgentProcess(
|
||||
const killOnTimeout = () => {
|
||||
timedOut = true;
|
||||
logger.error(
|
||||
{ group: group.name, processName },
|
||||
{
|
||||
group: group.name,
|
||||
chatJid: input.chatJid,
|
||||
runId: input.runId,
|
||||
processName,
|
||||
},
|
||||
'Agent timeout, sending SIGTERM',
|
||||
);
|
||||
proc.kill('SIGTERM');
|
||||
@@ -465,6 +507,35 @@ export async function runAgentProcess(
|
||||
timeout = setTimeout(killOnTimeout, timeoutMs);
|
||||
};
|
||||
|
||||
proc.stderr.on('data', (data) => {
|
||||
const chunk = data.toString();
|
||||
const lines = chunk.trim().split('\n');
|
||||
for (const line of lines) {
|
||||
if (!line) continue;
|
||||
if (line.includes('Turn in progress')) {
|
||||
logger.info(
|
||||
{ group: group.name, chatJid: input.chatJid, runId: input.runId },
|
||||
line.replace(/^\[.*?\]\s*/, ''),
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
{ agent: group.folder, chatJid: input.chatJid, runId: input.runId },
|
||||
line,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Stderr activity means agent is alive — reset timeout
|
||||
resetTimeout();
|
||||
if (stderrTruncated) return;
|
||||
const remaining = AGENT_MAX_OUTPUT_SIZE - stderr.length;
|
||||
if (chunk.length > remaining) {
|
||||
stderr += chunk.slice(0, remaining);
|
||||
stderrTruncated = true;
|
||||
} else {
|
||||
stderr += chunk;
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
clearTimeout(timeout);
|
||||
const duration = Date.now() - startTime;
|
||||
@@ -472,11 +543,13 @@ export async function runAgentProcess(
|
||||
if (timedOut) {
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
fs.writeFileSync(
|
||||
path.join(logsDir, `agent-${ts}.log`),
|
||||
path.join(logsDir, `agent-${input.runId || 'adhoc'}-${ts}.log`),
|
||||
[
|
||||
`=== Agent Run Log (TIMEOUT) ===`,
|
||||
`Timestamp: ${new Date().toISOString()}`,
|
||||
`Group: ${group.name}`,
|
||||
`ChatJid: ${input.chatJid}`,
|
||||
`RunId: ${input.runId || 'n/a'}`,
|
||||
`Process: ${processName}`,
|
||||
`Duration: ${duration}ms`,
|
||||
`Exit Code: ${code}`,
|
||||
@@ -486,7 +559,14 @@ export async function runAgentProcess(
|
||||
|
||||
if (hadStreamingOutput) {
|
||||
logger.info(
|
||||
{ group: group.name, processName, duration, code },
|
||||
{
|
||||
group: group.name,
|
||||
chatJid: input.chatJid,
|
||||
runId: input.runId,
|
||||
processName,
|
||||
duration,
|
||||
code,
|
||||
},
|
||||
'Agent timed out after output (idle cleanup)',
|
||||
);
|
||||
outputChain.then(() => {
|
||||
@@ -504,7 +584,10 @@ export async function runAgentProcess(
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const logFile = path.join(logsDir, `agent-${timestamp}.log`);
|
||||
const logFile = path.join(
|
||||
logsDir,
|
||||
`agent-${input.runId || 'adhoc'}-${timestamp}.log`,
|
||||
);
|
||||
const isVerbose =
|
||||
process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace';
|
||||
|
||||
@@ -512,6 +595,9 @@ export async function runAgentProcess(
|
||||
`=== Agent Run Log ===`,
|
||||
`Timestamp: ${new Date().toISOString()}`,
|
||||
`Group: ${group.name}`,
|
||||
`ChatJid: ${input.chatJid}`,
|
||||
`GroupFolder: ${input.groupFolder}`,
|
||||
`RunId: ${input.runId || 'n/a'}`,
|
||||
`IsMain: ${input.isMain}`,
|
||||
`AgentType: ${group.agentType || 'claude-code'}`,
|
||||
`Duration: ${duration}ms`,
|
||||
@@ -542,7 +628,14 @@ export async function runAgentProcess(
|
||||
|
||||
if (code !== 0) {
|
||||
logger.error(
|
||||
{ group: group.name, code, duration, logFile },
|
||||
{
|
||||
group: group.name,
|
||||
chatJid: input.chatJid,
|
||||
runId: input.runId,
|
||||
code,
|
||||
duration,
|
||||
logFile,
|
||||
},
|
||||
'Agent exited with error',
|
||||
);
|
||||
resolve({
|
||||
@@ -556,7 +649,13 @@ export async function runAgentProcess(
|
||||
if (onOutput) {
|
||||
outputChain.then(() => {
|
||||
logger.info(
|
||||
{ group: group.name, duration, newSessionId },
|
||||
{
|
||||
group: group.name,
|
||||
chatJid: input.chatJid,
|
||||
runId: input.runId,
|
||||
duration,
|
||||
newSessionId,
|
||||
},
|
||||
'Agent completed (streaming mode)',
|
||||
);
|
||||
resolve({ status: 'success', result: null, newSessionId });
|
||||
@@ -579,13 +678,24 @@ export async function runAgentProcess(
|
||||
}
|
||||
const output: AgentOutput = JSON.parse(jsonLine);
|
||||
logger.info(
|
||||
{ group: group.name, duration, status: output.status },
|
||||
{
|
||||
group: group.name,
|
||||
chatJid: input.chatJid,
|
||||
runId: input.runId,
|
||||
duration,
|
||||
status: output.status,
|
||||
},
|
||||
'Agent completed',
|
||||
);
|
||||
resolve(output);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ group: group.name, error: err },
|
||||
{
|
||||
group: group.name,
|
||||
chatJid: input.chatJid,
|
||||
runId: input.runId,
|
||||
error: err,
|
||||
},
|
||||
'Failed to parse agent output',
|
||||
);
|
||||
resolve({
|
||||
@@ -599,7 +709,13 @@ export async function runAgentProcess(
|
||||
proc.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
logger.error(
|
||||
{ group: group.name, processName, error: err },
|
||||
{
|
||||
group: group.name,
|
||||
chatJid: input.chatJid,
|
||||
runId: input.runId,
|
||||
processName,
|
||||
error: err,
|
||||
},
|
||||
'Agent spawn error',
|
||||
);
|
||||
resolve({
|
||||
@@ -644,7 +760,7 @@ export function writeGroupsSnapshot(
|
||||
groupFolder: string,
|
||||
isMain: boolean,
|
||||
groups: AvailableGroup[],
|
||||
registeredJids: Set<string>,
|
||||
_registeredJids?: Set<string>,
|
||||
): void {
|
||||
const groupIpcDir = resolveGroupIpcPath(groupFolder);
|
||||
fs.mkdirSync(groupIpcDir, { recursive: true });
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
// Channel self-registration barrel file.
|
||||
// Each import triggers the channel module's registerChannel() call.
|
||||
|
||||
// discord
|
||||
import './discord.js';
|
||||
|
||||
// gmail
|
||||
|
||||
// slack
|
||||
|
||||
// telegram
|
||||
|
||||
// whatsapp
|
||||
|
||||
@@ -1,15 +1,34 @@
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import type { AgentType } from './types.js';
|
||||
import { readEnvFile } from './env.js';
|
||||
|
||||
const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER']);
|
||||
const envConfig = readEnvFile([
|
||||
'ASSISTANT_NAME',
|
||||
'ASSISTANT_HAS_OWN_NUMBER',
|
||||
'SERVICE_ID',
|
||||
'SERVICE_AGENT_TYPE',
|
||||
'SESSION_COMMAND_ALLOWED_SENDERS',
|
||||
'USAGE_DASHBOARD',
|
||||
]);
|
||||
|
||||
export const ASSISTANT_NAME =
|
||||
process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
|
||||
export const ASSISTANT_HAS_OWN_NUMBER =
|
||||
(process.env.ASSISTANT_HAS_OWN_NUMBER ||
|
||||
envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true';
|
||||
const ASSISTANT_SLUG = ASSISTANT_NAME.trim().toLowerCase();
|
||||
const rawServiceAgentType =
|
||||
process.env.SERVICE_AGENT_TYPE || envConfig.SERVICE_AGENT_TYPE;
|
||||
export const SERVICE_ID =
|
||||
process.env.SERVICE_ID || envConfig.SERVICE_ID || ASSISTANT_SLUG;
|
||||
export const SERVICE_AGENT_TYPE: AgentType =
|
||||
rawServiceAgentType === 'codex' || rawServiceAgentType === 'claude-code'
|
||||
? rawServiceAgentType
|
||||
: ASSISTANT_SLUG === 'codex'
|
||||
? 'codex'
|
||||
: 'claude-code';
|
||||
export const POLL_INTERVAL = 2000;
|
||||
export const SCHEDULER_POLL_INTERVAL = 60000;
|
||||
|
||||
@@ -62,8 +81,23 @@ export const TRIGGER_PATTERN = new RegExp(
|
||||
export const STATUS_CHANNEL_ID = process.env.STATUS_CHANNEL_ID || '';
|
||||
export const STATUS_UPDATE_INTERVAL = 10000; // 10s
|
||||
export const USAGE_UPDATE_INTERVAL = 300000; // 5 minutes
|
||||
export const USAGE_DASHBOARD_ENABLED =
|
||||
(process.env.USAGE_DASHBOARD || envConfig.USAGE_DASHBOARD) === 'true';
|
||||
|
||||
// Timezone for scheduled tasks (cron expressions, etc.)
|
||||
// Uses system timezone by default
|
||||
export const TIMEZONE =
|
||||
process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
const SESSION_COMMAND_ALLOWED_SENDERS = new Set(
|
||||
(process.env.SESSION_COMMAND_ALLOWED_SENDERS ||
|
||||
envConfig.SESSION_COMMAND_ALLOWED_SENDERS ||
|
||||
'')
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
export function isSessionCommandSenderAllowed(sender: string): boolean {
|
||||
return SESSION_COMMAND_ALLOWED_SENDERS.has(sender);
|
||||
}
|
||||
|
||||
@@ -455,25 +455,25 @@ describe('message query LIMIT', () => {
|
||||
|
||||
describe('registered group isMain', () => {
|
||||
it('persists isMain=true through set/get round-trip', () => {
|
||||
setRegisteredGroup('main@s.whatsapp.net', {
|
||||
setRegisteredGroup('dc:main', {
|
||||
name: 'Main Chat',
|
||||
folder: 'whatsapp_main',
|
||||
folder: 'discord_main',
|
||||
trigger: '@Andy',
|
||||
added_at: '2024-01-01T00:00:00.000Z',
|
||||
isMain: true,
|
||||
});
|
||||
|
||||
const groups = getAllRegisteredGroups();
|
||||
const group = groups['main@s.whatsapp.net'];
|
||||
const group = groups['dc:main'];
|
||||
expect(group).toBeDefined();
|
||||
expect(group.isMain).toBe(true);
|
||||
expect(group.folder).toBe('whatsapp_main');
|
||||
expect(group.folder).toBe('discord_main');
|
||||
});
|
||||
|
||||
it('omits isMain for non-main groups', () => {
|
||||
setRegisteredGroup('group@g.us', {
|
||||
name: 'Family Chat',
|
||||
folder: 'whatsapp_family-chat',
|
||||
folder: 'discord_family-chat',
|
||||
trigger: '@Andy',
|
||||
added_at: '2024-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
364
src/db.ts
364
src/db.ts
@@ -2,11 +2,18 @@ import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { ASSISTANT_NAME, DATA_DIR, STORE_DIR } from './config.js';
|
||||
import {
|
||||
ASSISTANT_NAME,
|
||||
DATA_DIR,
|
||||
SERVICE_AGENT_TYPE,
|
||||
SERVICE_ID,
|
||||
STORE_DIR,
|
||||
} from './config.js';
|
||||
import { isValidGroupFolder } from './group-folder.js';
|
||||
import { logger } from './logger.js';
|
||||
import {
|
||||
NewMessage,
|
||||
AgentType,
|
||||
RegisteredGroup,
|
||||
ScheduledTask,
|
||||
TaskRunLog,
|
||||
@@ -70,17 +77,24 @@ function createSchema(database: Database.Database): void {
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
group_folder TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL
|
||||
group_folder TEXT NOT NULL,
|
||||
agent_type TEXT NOT NULL DEFAULT 'claude-code',
|
||||
session_id TEXT NOT NULL,
|
||||
PRIMARY KEY (group_folder, agent_type)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS registered_groups (
|
||||
jid TEXT PRIMARY KEY,
|
||||
jid TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL UNIQUE,
|
||||
folder TEXT NOT NULL,
|
||||
trigger_pattern TEXT NOT NULL,
|
||||
added_at TEXT NOT NULL,
|
||||
container_config TEXT,
|
||||
requires_trigger INTEGER DEFAULT 1
|
||||
agent_config TEXT,
|
||||
requires_trigger INTEGER DEFAULT 1,
|
||||
is_main INTEGER DEFAULT 0,
|
||||
agent_type TEXT NOT NULL DEFAULT 'claude-code',
|
||||
work_dir TEXT,
|
||||
PRIMARY KEY (jid, agent_type),
|
||||
UNIQUE (folder, agent_type)
|
||||
);
|
||||
`);
|
||||
|
||||
@@ -106,33 +120,137 @@ function createSchema(database: Database.Database): void {
|
||||
/* column already exists */
|
||||
}
|
||||
|
||||
// Add is_main column if it doesn't exist (migration for existing DBs)
|
||||
try {
|
||||
database.exec(
|
||||
`ALTER TABLE registered_groups ADD COLUMN is_main INTEGER DEFAULT 0`,
|
||||
// Migrate registered_groups to composite keys so Claude/Codex can share a jid/folder.
|
||||
const registeredGroupsSql = (
|
||||
database
|
||||
.prepare(
|
||||
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'registered_groups'`,
|
||||
)
|
||||
.get() as { sql?: string } | undefined
|
||||
)?.sql;
|
||||
if (
|
||||
registeredGroupsSql &&
|
||||
!registeredGroupsSql.includes('PRIMARY KEY (jid, agent_type)')
|
||||
) {
|
||||
const registeredGroupCols = database
|
||||
.prepare('PRAGMA table_info(registered_groups)')
|
||||
.all() as Array<{ name: string }>;
|
||||
const hasIsMain = registeredGroupCols.some((col) => col.name === 'is_main');
|
||||
const hasAgentType = registeredGroupCols.some(
|
||||
(col) => col.name === 'agent_type',
|
||||
);
|
||||
const hasWorkDir = registeredGroupCols.some((col) => col.name === 'work_dir');
|
||||
const hasAgentConfig = registeredGroupCols.some(
|
||||
(col) => col.name === 'agent_config',
|
||||
);
|
||||
const hasContainerConfig = registeredGroupCols.some(
|
||||
(col) => col.name === 'container_config',
|
||||
);
|
||||
|
||||
database.exec(`
|
||||
CREATE TABLE registered_groups_new (
|
||||
jid TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL,
|
||||
trigger_pattern TEXT NOT NULL,
|
||||
added_at TEXT NOT NULL,
|
||||
agent_config TEXT,
|
||||
requires_trigger INTEGER DEFAULT 1,
|
||||
is_main INTEGER DEFAULT 0,
|
||||
agent_type TEXT NOT NULL DEFAULT 'claude-code',
|
||||
work_dir TEXT,
|
||||
PRIMARY KEY (jid, agent_type),
|
||||
UNIQUE (folder, agent_type)
|
||||
);
|
||||
`);
|
||||
|
||||
database.exec(`
|
||||
INSERT INTO registered_groups_new (
|
||||
jid,
|
||||
name,
|
||||
folder,
|
||||
trigger_pattern,
|
||||
added_at,
|
||||
agent_config,
|
||||
requires_trigger,
|
||||
is_main,
|
||||
agent_type,
|
||||
work_dir
|
||||
)
|
||||
SELECT
|
||||
jid,
|
||||
name,
|
||||
folder,
|
||||
trigger_pattern,
|
||||
added_at,
|
||||
${
|
||||
hasAgentConfig
|
||||
? 'agent_config'
|
||||
: hasContainerConfig
|
||||
? 'container_config'
|
||||
: 'NULL'
|
||||
},
|
||||
requires_trigger,
|
||||
${hasIsMain ? 'COALESCE(is_main, 0)' : "CASE WHEN folder = 'main' THEN 1 ELSE 0 END"},
|
||||
${hasAgentType ? "COALESCE(agent_type, 'claude-code')" : "'claude-code'"},
|
||||
${hasWorkDir ? 'work_dir' : 'NULL'}
|
||||
FROM registered_groups;
|
||||
`);
|
||||
|
||||
database.exec(`
|
||||
DROP TABLE registered_groups;
|
||||
ALTER TABLE registered_groups_new RENAME TO registered_groups;
|
||||
`);
|
||||
} else {
|
||||
// Backfill: existing rows with folder = 'main' are the main group
|
||||
database.exec(
|
||||
`UPDATE registered_groups SET is_main = 1 WHERE folder = 'main'`,
|
||||
`UPDATE registered_groups SET is_main = 1 WHERE folder = 'main' AND COALESCE(is_main, 0) = 0`,
|
||||
);
|
||||
} catch {
|
||||
/* column already exists */
|
||||
}
|
||||
|
||||
// Add agent_type column if it doesn't exist (migration for Codex support)
|
||||
try {
|
||||
const registeredGroupCols = database
|
||||
.prepare('PRAGMA table_info(registered_groups)')
|
||||
.all() as Array<{ name: string }>;
|
||||
const hasAgentConfig = registeredGroupCols.some(
|
||||
(col) => col.name === 'agent_config',
|
||||
);
|
||||
const hasContainerConfig = registeredGroupCols.some(
|
||||
(col) => col.name === 'container_config',
|
||||
);
|
||||
if (!hasAgentConfig) {
|
||||
database.exec(`ALTER TABLE registered_groups ADD COLUMN agent_config TEXT`);
|
||||
}
|
||||
if (hasContainerConfig) {
|
||||
database.exec(
|
||||
`ALTER TABLE registered_groups ADD COLUMN agent_type TEXT DEFAULT 'claude-code'`,
|
||||
`UPDATE registered_groups
|
||||
SET agent_config = COALESCE(agent_config, container_config)
|
||||
WHERE container_config IS NOT NULL`,
|
||||
);
|
||||
} catch {
|
||||
/* column already exists */
|
||||
}
|
||||
|
||||
// Add work_dir column if it doesn't exist (migration for per-group working directory)
|
||||
try {
|
||||
database.exec(`ALTER TABLE registered_groups ADD COLUMN work_dir TEXT`);
|
||||
} catch {
|
||||
/* column already exists */
|
||||
// Migrate sessions table to composite PK (group_folder, agent_type)
|
||||
const sessionCols = database
|
||||
.prepare('PRAGMA table_info(sessions)')
|
||||
.all() as Array<{ name: string }>;
|
||||
if (!sessionCols.some((col) => col.name === 'agent_type')) {
|
||||
database.exec(`
|
||||
CREATE TABLE sessions_new (
|
||||
group_folder TEXT NOT NULL,
|
||||
agent_type TEXT NOT NULL DEFAULT 'claude-code',
|
||||
session_id TEXT NOT NULL,
|
||||
PRIMARY KEY (group_folder, agent_type)
|
||||
);
|
||||
`);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO sessions_new (group_folder, agent_type, session_id)
|
||||
SELECT group_folder, ?, session_id FROM sessions`,
|
||||
)
|
||||
.run(SERVICE_AGENT_TYPE);
|
||||
database.exec(`
|
||||
DROP TABLE sessions;
|
||||
ALTER TABLE sessions_new RENAME TO sessions;
|
||||
`);
|
||||
}
|
||||
|
||||
// Add channel and is_group columns if they don't exist (migration for existing DBs)
|
||||
@@ -162,6 +280,8 @@ export function initDatabase(): void {
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
|
||||
db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('busy_timeout = 5000');
|
||||
createSchema(db);
|
||||
|
||||
// Migrate from JSON files if they exist
|
||||
@@ -214,20 +334,6 @@ export function storeChatMetadata(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update chat name without changing timestamp for existing chats.
|
||||
* New chats get the current time as their initial timestamp.
|
||||
* Used during group metadata sync.
|
||||
*/
|
||||
export function updateChatName(chatJid: string, name: string): void {
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)
|
||||
ON CONFLICT(jid) DO UPDATE SET name = excluded.name
|
||||
`,
|
||||
).run(chatJid, name, new Date().toISOString());
|
||||
}
|
||||
|
||||
export interface ChatInfo {
|
||||
jid: string;
|
||||
name: string;
|
||||
@@ -251,27 +357,6 @@ export function getAllChats(): ChatInfo[] {
|
||||
.all() as ChatInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get timestamp of last group metadata sync.
|
||||
*/
|
||||
export function getLastGroupSync(): string | null {
|
||||
// Store sync time in a special chat entry
|
||||
const row = db
|
||||
.prepare(`SELECT last_message_time FROM chats WHERE jid = '__group_sync__'`)
|
||||
.get() as { last_message_time: string } | undefined;
|
||||
return row?.last_message_time || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that group metadata was synced.
|
||||
*/
|
||||
export function setLastGroupSync(): void {
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO chats (jid, name, last_message_time) VALUES ('__group_sync__', '__group_sync__', ?)`,
|
||||
).run(now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a message with full content.
|
||||
* Only call this for registered groups where message history is needed.
|
||||
@@ -291,33 +376,6 @@ export function storeMessage(msg: NewMessage): void {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a message directly.
|
||||
*/
|
||||
export function storeMessageDirect(msg: {
|
||||
id: string;
|
||||
chat_jid: string;
|
||||
sender: string;
|
||||
sender_name: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
is_from_me: boolean;
|
||||
is_bot_message?: boolean;
|
||||
}): void {
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
msg.id,
|
||||
msg.chat_jid,
|
||||
msg.sender,
|
||||
msg.sender_name,
|
||||
msg.content,
|
||||
msg.timestamp,
|
||||
msg.is_from_me ? 1 : 0,
|
||||
msg.is_bot_message ? 1 : 0,
|
||||
);
|
||||
}
|
||||
|
||||
export function getNewMessages(
|
||||
jids: string[],
|
||||
lastTimestamp: string,
|
||||
@@ -327,9 +385,9 @@ export function getNewMessages(
|
||||
if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp };
|
||||
|
||||
const placeholders = jids.map(() => '?').join(',');
|
||||
// Filter bot messages using both the is_bot_message flag AND the content
|
||||
// prefix as a backstop for messages written before the migration ran.
|
||||
// Subquery takes the N most recent, outer query re-sorts chronologically.
|
||||
// Filter legacy prefixed outbound messages as a backstop for rows written
|
||||
// before explicit bot flags existed. Self-message filtering is channel-specific
|
||||
// and happens in message-runtime so cross-bot collaboration still works.
|
||||
const sql = `
|
||||
SELECT * FROM (
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message
|
||||
@@ -360,9 +418,9 @@ export function getMessagesSince(
|
||||
botPrefix: string,
|
||||
limit: number = 200,
|
||||
): NewMessage[] {
|
||||
// Filter bot messages using both the is_bot_message flag AND the content
|
||||
// prefix as a backstop for messages written before the migration ran.
|
||||
// Subquery takes the N most recent, outer query re-sorts chronologically.
|
||||
// Filter legacy prefixed outbound messages as a backstop for rows written
|
||||
// before explicit bot flags existed. Self-message filtering is channel-specific
|
||||
// and happens in message-runtime so cross-bot collaboration still works.
|
||||
const sql = `
|
||||
SELECT * FROM (
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message
|
||||
@@ -527,37 +585,65 @@ export function logTaskRun(log: TaskRunLog): void {
|
||||
// --- Router state accessors ---
|
||||
|
||||
export function getRouterState(key: string): string | undefined {
|
||||
const prefixedKey = `${SERVICE_ID}:${key}`;
|
||||
const row = db
|
||||
.prepare('SELECT value FROM router_state WHERE key = ?')
|
||||
.get(prefixedKey) as { value: string } | undefined;
|
||||
if (row) return row.value;
|
||||
|
||||
// Lazy migration: read unprefixed key and migrate to prefixed
|
||||
const old = db
|
||||
.prepare('SELECT value FROM router_state WHERE key = ?')
|
||||
.get(key) as { value: string } | undefined;
|
||||
return row?.value;
|
||||
if (old) {
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)',
|
||||
).run(prefixedKey, old.value);
|
||||
db.prepare('DELETE FROM router_state WHERE key = ?').run(key);
|
||||
return old.value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function setRouterState(key: string, value: string): void {
|
||||
const prefixedKey = `${SERVICE_ID}:${key}`;
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)',
|
||||
).run(key, value);
|
||||
).run(prefixedKey, value);
|
||||
}
|
||||
|
||||
// --- Session accessors ---
|
||||
|
||||
export function getSession(groupFolder: string): string | undefined {
|
||||
const row = db
|
||||
.prepare('SELECT session_id FROM sessions WHERE group_folder = ?')
|
||||
.get(groupFolder) as { session_id: string } | undefined;
|
||||
.prepare(
|
||||
'SELECT session_id FROM sessions WHERE group_folder = ? AND agent_type = ?',
|
||||
)
|
||||
.get(groupFolder, SERVICE_AGENT_TYPE) as { session_id: string } | undefined;
|
||||
return row?.session_id;
|
||||
}
|
||||
|
||||
export function setSession(groupFolder: string, sessionId: string): void {
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO sessions (group_folder, session_id) VALUES (?, ?)',
|
||||
).run(groupFolder, sessionId);
|
||||
'INSERT OR REPLACE INTO sessions (group_folder, agent_type, session_id) VALUES (?, ?, ?)',
|
||||
).run(groupFolder, SERVICE_AGENT_TYPE, sessionId);
|
||||
}
|
||||
|
||||
export function deleteSession(groupFolder: string): void {
|
||||
db.prepare(
|
||||
'DELETE FROM sessions WHERE group_folder = ? AND agent_type = ?',
|
||||
).run(groupFolder, SERVICE_AGENT_TYPE);
|
||||
}
|
||||
|
||||
export function getAllSessions(): Record<string, string> {
|
||||
const rows = db
|
||||
.prepare('SELECT group_folder, session_id FROM sessions')
|
||||
.all() as Array<{ group_folder: string; session_id: string }>;
|
||||
.prepare(
|
||||
'SELECT group_folder, session_id FROM sessions WHERE agent_type = ?',
|
||||
)
|
||||
.all(SERVICE_AGENT_TYPE) as Array<{
|
||||
group_folder: string;
|
||||
session_id: string;
|
||||
}>;
|
||||
const result: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
result[row.group_folder] = row.session_id;
|
||||
@@ -574,15 +660,15 @@ export function getRegisteredGroup(
|
||||
.prepare('SELECT * FROM registered_groups WHERE jid = ?')
|
||||
.get(jid) as
|
||||
| {
|
||||
jid: string;
|
||||
name: string;
|
||||
folder: string;
|
||||
trigger_pattern: string;
|
||||
added_at: string;
|
||||
container_config: string | null;
|
||||
requires_trigger: number | null;
|
||||
is_main: number | null;
|
||||
agent_type: string | null;
|
||||
jid: string;
|
||||
name: string;
|
||||
folder: string;
|
||||
trigger_pattern: string;
|
||||
added_at: string;
|
||||
agent_config: string | null;
|
||||
requires_trigger: number | null;
|
||||
is_main: number | null;
|
||||
agent_type: string | null;
|
||||
work_dir: string | null;
|
||||
}
|
||||
| undefined;
|
||||
@@ -600,8 +686,8 @@ export function getRegisteredGroup(
|
||||
folder: row.folder,
|
||||
trigger: row.trigger_pattern,
|
||||
added_at: row.added_at,
|
||||
agentConfig: row.container_config
|
||||
? JSON.parse(row.container_config)
|
||||
agentConfig: row.agent_config
|
||||
? JSON.parse(row.agent_config)
|
||||
: undefined,
|
||||
requiresTrigger:
|
||||
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
|
||||
@@ -616,7 +702,7 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
|
||||
throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`);
|
||||
}
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main, agent_type, work_dir)
|
||||
`INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger, is_main, agent_type, work_dir)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
jid,
|
||||
@@ -632,17 +718,25 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
|
||||
);
|
||||
}
|
||||
|
||||
export function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
|
||||
const rows = db.prepare('SELECT * FROM registered_groups').all() as Array<{
|
||||
export function getAllRegisteredGroups(
|
||||
agentTypeFilter?: string,
|
||||
): Record<string, RegisteredGroup> {
|
||||
const rows = (
|
||||
agentTypeFilter
|
||||
? db
|
||||
.prepare('SELECT * FROM registered_groups WHERE agent_type = ?')
|
||||
.all(agentTypeFilter)
|
||||
: db.prepare('SELECT * FROM registered_groups').all()
|
||||
) as Array<{
|
||||
jid: string;
|
||||
name: string;
|
||||
folder: string;
|
||||
trigger_pattern: string;
|
||||
added_at: string;
|
||||
container_config: string | null;
|
||||
requires_trigger: number | null;
|
||||
is_main: number | null;
|
||||
agent_type: string | null;
|
||||
name: string;
|
||||
folder: string;
|
||||
trigger_pattern: string;
|
||||
added_at: string;
|
||||
agent_config: string | null;
|
||||
requires_trigger: number | null;
|
||||
is_main: number | null;
|
||||
agent_type: string | null;
|
||||
work_dir: string | null;
|
||||
}>;
|
||||
const result: Record<string, RegisteredGroup> = {};
|
||||
@@ -659,8 +753,8 @@ export function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
|
||||
folder: row.folder,
|
||||
trigger: row.trigger_pattern,
|
||||
added_at: row.added_at,
|
||||
agentConfig: row.container_config
|
||||
? JSON.parse(row.container_config)
|
||||
agentConfig: row.agent_config
|
||||
? JSON.parse(row.agent_config)
|
||||
: undefined,
|
||||
requiresTrigger:
|
||||
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
|
||||
@@ -672,6 +766,28 @@ export function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getRegisteredAgentTypesForJid(jid: string): AgentType[] {
|
||||
if (!db) return [];
|
||||
|
||||
const rows = db
|
||||
.prepare('SELECT agent_type FROM registered_groups WHERE jid = ?')
|
||||
.all(jid) as Array<{ agent_type: string | null }>;
|
||||
|
||||
const types = new Set<AgentType>();
|
||||
for (const row of rows) {
|
||||
const agentType = row.agent_type as AgentType | null;
|
||||
if (agentType === 'claude-code' || agentType === 'codex') {
|
||||
types.add(agentType);
|
||||
}
|
||||
}
|
||||
return [...types];
|
||||
}
|
||||
|
||||
export function isPairedRoomJid(jid: string): boolean {
|
||||
const types = getRegisteredAgentTypesForJid(jid);
|
||||
return types.includes('claude-code') && types.includes('codex');
|
||||
}
|
||||
|
||||
// --- JSON migration ---
|
||||
|
||||
function migrateJsonState(): void {
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from 'path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DATA_DIR, GROUPS_DIR } from './config.js';
|
||||
import {
|
||||
isValidGroupFolder,
|
||||
resolveGroupFolderPath,
|
||||
@@ -24,16 +25,12 @@ describe('group folder validation', () => {
|
||||
|
||||
it('resolves safe paths under groups directory', () => {
|
||||
const resolved = resolveGroupFolderPath('family-chat');
|
||||
expect(resolved.endsWith(`${path.sep}groups${path.sep}family-chat`)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(resolved).toBe(path.resolve(GROUPS_DIR, 'family-chat'));
|
||||
});
|
||||
|
||||
it('resolves safe paths under data ipc directory', () => {
|
||||
const resolved = resolveGroupIpcPath('family-chat');
|
||||
expect(
|
||||
resolved.endsWith(`${path.sep}data${path.sep}ipc${path.sep}family-chat`),
|
||||
).toBe(true);
|
||||
expect(resolved).toBe(path.resolve(DATA_DIR, 'ipc', 'family-chat'));
|
||||
});
|
||||
|
||||
it('throws for unsafe folder names', () => {
|
||||
|
||||
10
src/index.ts
10
src/index.ts
@@ -7,6 +7,7 @@ import {
|
||||
ASSISTANT_NAME,
|
||||
IDLE_TIMEOUT,
|
||||
POLL_INTERVAL,
|
||||
isSessionCommandSenderAllowed,
|
||||
STATUS_CHANNEL_ID,
|
||||
STATUS_UPDATE_INTERVAL,
|
||||
TIMEZONE,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
initDatabase,
|
||||
setRegisteredGroup,
|
||||
setRouterState,
|
||||
deleteSession,
|
||||
setSession,
|
||||
storeChatMetadata,
|
||||
storeMessage,
|
||||
@@ -94,6 +96,11 @@ function saveState(): void {
|
||||
setRouterState('last_agent_timestamp', JSON.stringify(lastAgentTimestamp));
|
||||
}
|
||||
|
||||
function clearSession(groupFolder: string): void {
|
||||
delete sessions[groupFolder];
|
||||
deleteSession(groupFolder);
|
||||
}
|
||||
|
||||
function registerGroup(jid: string, group: RegisteredGroup): void {
|
||||
let groupDir: string;
|
||||
try {
|
||||
@@ -182,11 +189,13 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
|
||||
runAgent: (prompt, onOutput) =>
|
||||
runAgent(group, prompt, chatJid, onOutput),
|
||||
closeStdin: () => queue.closeStdin(chatJid),
|
||||
clearSession: () => clearSession(group.folder),
|
||||
advanceCursor: (ts) => {
|
||||
lastAgentTimestamp[chatJid] = ts;
|
||||
saveState();
|
||||
},
|
||||
formatMessages,
|
||||
isAdminSender: (msg) => isSessionCommandSenderAllowed(msg.sender),
|
||||
canSenderInteract: (msg) => {
|
||||
const hasTrigger = TRIGGER_PATTERN.test(msg.content.trim());
|
||||
const reqTrigger = !isMainGroup && group.requiresTrigger !== false;
|
||||
@@ -921,6 +930,7 @@ async function startMessageLoop(): Promise<void> {
|
||||
isSessionCommandAllowed(
|
||||
isMainGroup,
|
||||
loopCmdMsg.is_from_me === true,
|
||||
isSessionCommandSenderAllowed(loopCmdMsg.sender),
|
||||
)
|
||||
) {
|
||||
queue.closeStdin(chatJid);
|
||||
|
||||
60
src/platform-prompts.ts
Normal file
60
src/platform-prompts.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import type { AgentType } from './types.js';
|
||||
|
||||
const PLATFORM_PROMPT_FILES: Record<AgentType, string> = {
|
||||
'claude-code': 'claude-platform.md',
|
||||
codex: 'codex-platform.md',
|
||||
};
|
||||
|
||||
const PAIRED_ROOM_PROMPT_FILES: Record<AgentType, string> = {
|
||||
'claude-code': 'claude-paired-room.md',
|
||||
codex: 'codex-paired-room.md',
|
||||
};
|
||||
|
||||
export function getPlatformPromptsDir(projectRoot = process.cwd()): string {
|
||||
return path.join(projectRoot, 'prompts');
|
||||
}
|
||||
|
||||
export function getPlatformPromptPath(
|
||||
agentType: AgentType,
|
||||
projectRoot = process.cwd(),
|
||||
): string {
|
||||
return path.join(
|
||||
getPlatformPromptsDir(projectRoot),
|
||||
PLATFORM_PROMPT_FILES[agentType],
|
||||
);
|
||||
}
|
||||
|
||||
export function readPlatformPrompt(
|
||||
agentType: AgentType,
|
||||
projectRoot = process.cwd(),
|
||||
): string | undefined {
|
||||
const promptPath = getPlatformPromptPath(agentType, projectRoot);
|
||||
if (!fs.existsSync(promptPath)) return undefined;
|
||||
|
||||
const prompt = fs.readFileSync(promptPath, 'utf-8').trim();
|
||||
return prompt || undefined;
|
||||
}
|
||||
|
||||
export function getPairedRoomPromptPath(
|
||||
agentType: AgentType,
|
||||
projectRoot = process.cwd(),
|
||||
): string {
|
||||
return path.join(
|
||||
getPlatformPromptsDir(projectRoot),
|
||||
PAIRED_ROOM_PROMPT_FILES[agentType],
|
||||
);
|
||||
}
|
||||
|
||||
export function readPairedRoomPrompt(
|
||||
agentType: AgentType,
|
||||
projectRoot = process.cwd(),
|
||||
): string | undefined {
|
||||
const promptPath = getPairedRoomPromptPath(agentType, projectRoot);
|
||||
if (!fs.existsSync(promptPath)) return undefined;
|
||||
|
||||
const prompt = fs.readFileSync(promptPath, 'utf-8').trim();
|
||||
return prompt || undefined;
|
||||
}
|
||||
@@ -82,8 +82,10 @@ function makeDeps(
|
||||
setTyping: vi.fn().mockResolvedValue(undefined),
|
||||
runAgent: vi.fn().mockResolvedValue('success'),
|
||||
closeStdin: vi.fn(),
|
||||
clearSession: vi.fn(),
|
||||
advanceCursor: vi.fn(),
|
||||
formatMessages: vi.fn().mockReturnValue('<formatted>'),
|
||||
isAdminSender: vi.fn().mockReturnValue(false),
|
||||
canSenderInteract: vi.fn().mockReturnValue(true),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import type { NewMessage } from './types.js';
|
||||
import { logger } from './logger.js';
|
||||
import { formatOutbound } from './router.js';
|
||||
|
||||
const SESSION_COMMAND_CONTROL_PATTERNS = [
|
||||
/^Current session cleared\. The next message will start a new conversation\.$/,
|
||||
/^Session commands require admin access\.$/,
|
||||
/^Failed to process messages before \/compact\. Try again\.$/,
|
||||
/^\/compact failed\. The session is unchanged\.$/,
|
||||
/^Conversation compacted\.$/,
|
||||
];
|
||||
|
||||
/**
|
||||
* Extract a session slash command from a message, stripping the trigger prefix if present.
|
||||
@@ -12,18 +21,27 @@ export function extractSessionCommand(
|
||||
let text = content.trim();
|
||||
text = text.replace(triggerPattern, '').trim();
|
||||
if (text === '/compact') return '/compact';
|
||||
if (text === '/clear') return '/clear';
|
||||
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.
|
||||
* Allowed: main group (any sender), or trusted/admin sender in any group.
|
||||
*/
|
||||
export function isSessionCommandAllowed(
|
||||
isMainGroup: boolean,
|
||||
isFromMe: boolean,
|
||||
isAdminSender: boolean = false,
|
||||
): boolean {
|
||||
return isMainGroup || isFromMe;
|
||||
return isMainGroup || isFromMe || isAdminSender;
|
||||
}
|
||||
|
||||
export function isSessionCommandControlMessage(content: string): boolean {
|
||||
const trimmed = content.trim();
|
||||
return SESSION_COMMAND_CONTROL_PATTERNS.some((pattern) =>
|
||||
pattern.test(trimmed),
|
||||
);
|
||||
}
|
||||
|
||||
/** Minimal agent result interface — matches the subset of AgentOutput used here. */
|
||||
@@ -41,8 +59,10 @@ export interface SessionCommandDeps {
|
||||
onOutput: (result: AgentResult) => Promise<void>,
|
||||
) => Promise<'success' | 'error'>;
|
||||
closeStdin: () => void;
|
||||
clearSession: () => void;
|
||||
advanceCursor: (timestamp: string) => void;
|
||||
formatMessages: (msgs: NewMessage[], timezone: string) => string;
|
||||
isAdminSender: (msg: NewMessage) => boolean;
|
||||
/** Whether the denied sender would normally be allowed to interact (for denial messages). */
|
||||
canSenderInteract: (msg: NewMessage) => boolean;
|
||||
}
|
||||
@@ -50,7 +70,7 @@ export interface SessionCommandDeps {
|
||||
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();
|
||||
return formatOutbound(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,6 +83,7 @@ export async function handleSessionCommand(opts: {
|
||||
missedMessages: NewMessage[];
|
||||
isMainGroup: boolean;
|
||||
groupName: string;
|
||||
runId?: string;
|
||||
triggerPattern: RegExp;
|
||||
timezone: string;
|
||||
deps: SessionCommandDeps;
|
||||
@@ -71,6 +92,7 @@ export async function handleSessionCommand(opts: {
|
||||
missedMessages,
|
||||
isMainGroup,
|
||||
groupName,
|
||||
runId,
|
||||
triggerPattern,
|
||||
timezone,
|
||||
deps,
|
||||
@@ -85,7 +107,13 @@ export async function handleSessionCommand(opts: {
|
||||
|
||||
if (!command || !cmdMsg) return { handled: false };
|
||||
|
||||
if (!isSessionCommandAllowed(isMainGroup, cmdMsg.is_from_me === true)) {
|
||||
if (
|
||||
!isSessionCommandAllowed(
|
||||
isMainGroup,
|
||||
cmdMsg.is_from_me === true,
|
||||
deps.isAdminSender(cmdMsg),
|
||||
)
|
||||
) {
|
||||
// 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
|
||||
@@ -98,7 +126,17 @@ export async function handleSessionCommand(opts: {
|
||||
}
|
||||
|
||||
// AUTHORIZED: process pre-compact messages first, then run the command
|
||||
logger.info({ group: groupName, command }, 'Session command');
|
||||
logger.info({ group: groupName, runId, command }, 'Session command');
|
||||
|
||||
if (command === '/clear') {
|
||||
deps.closeStdin();
|
||||
deps.clearSession();
|
||||
deps.advanceCursor(cmdMsg.timestamp);
|
||||
await deps.sendMessage(
|
||||
'Current session cleared. The next message will start a new conversation.',
|
||||
);
|
||||
return { handled: true, success: true };
|
||||
}
|
||||
|
||||
const cmdIndex = missedMessages.indexOf(cmdMsg);
|
||||
const preCompactMsgs = missedMessages.slice(0, cmdIndex);
|
||||
@@ -125,7 +163,7 @@ export async function handleSessionCommand(opts: {
|
||||
|
||||
if (preResult === 'error' || hadPreError) {
|
||||
logger.warn(
|
||||
{ group: groupName },
|
||||
{ group: groupName, runId },
|
||||
'Pre-compact processing failed, aborting session command',
|
||||
);
|
||||
await deps.sendMessage(
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
export interface AdditionalMount {
|
||||
hostPath: string; // Absolute path on host (supports ~ for home)
|
||||
containerPath?: string; // Optional — defaults to basename of hostPath. Mounted at /workspace/extra/{value}
|
||||
readonly?: boolean; // Default: true for safety
|
||||
}
|
||||
|
||||
export interface AgentConfig {
|
||||
additionalMounts?: AdditionalMount[];
|
||||
timeout?: number; // Default: 300000 (5 minutes)
|
||||
// Per-group model/effort overrides (take precedence over global env vars)
|
||||
codexModel?: string;
|
||||
@@ -78,6 +71,8 @@ export interface Channel {
|
||||
sendMessage(jid: string, text: string): Promise<void>;
|
||||
isConnected(): boolean;
|
||||
ownsJid(jid: string): boolean;
|
||||
// Optional: whether a stored inbound message was authored by this channel's own bot/user.
|
||||
isOwnMessage?(msg: NewMessage): boolean;
|
||||
disconnect(): Promise<void>;
|
||||
// Optional: typing indicator. Channels that support it implement it.
|
||||
setTyping?(jid: string, isTyping: boolean): Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user