Refactor index (#156)

* feat: add Telegram channel with agent swarm support

Add Telegram as a messaging channel that can run alongside WhatsApp
or standalone (TELEGRAM_ONLY mode). Includes bot pool support for
agent swarms where each subagent appears as a different bot identity
in the group.

- Add grammy dependency for Telegram Bot API
- Route messages through tg: JID prefix convention
- Add storeMessageDirect for non-Baileys channels
- Add sender field to IPC send_message for swarm identity
- Support TELEGRAM_BOT_TOKEN, TELEGRAM_ONLY, TELEGRAM_BOT_POOL config

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add index.ts refactor plan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: extract channel abstraction, IPC, and router from index.ts

Break the 1088-line monolith into focused modules:
- src/channels/whatsapp.ts: WhatsAppChannel class implementing Channel interface
- src/ipc.ts: IPC watcher and task processing with dependency injection
- src/router.ts: message formatting, outbound routing, channel lookup
- src/types.ts: Channel interface, OnInboundMessage, OnChatMetadata types

Also adds regression test suite (98 tests), updates all documentation
and skill files to reflect the new architecture.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: add test workflow for PRs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: remove accidentally committed pool-bot assets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ci): remove grammy from base dependencies

Grammy is installed by the /add-telegram skill, not a base dependency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-02-11 00:36:37 +02:00
committed by GitHub
parent 196abf67cf
commit 2b56fecfdc
28 changed files with 4273 additions and 1066 deletions

View File

@@ -1,104 +1,57 @@
import { exec, execSync } from 'child_process';
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import makeWASocket, {
DisconnectReason,
WASocket,
makeCacheableSignalKeyStore,
useMultiFileAuthState,
} from '@whiskeysockets/baileys';
import { CronExpressionParser } from 'cron-parser';
import {
ASSISTANT_NAME,
DATA_DIR,
IDLE_TIMEOUT,
IPC_POLL_INTERVAL,
MAIN_GROUP_FOLDER,
POLL_INTERVAL,
STORE_DIR,
TIMEZONE,
TRIGGER_PATTERN,
} from './config.js';
import { WhatsAppChannel } from './channels/whatsapp.js';
import {
AvailableGroup,
ContainerOutput,
runContainerAgent,
writeGroupsSnapshot,
writeTasksSnapshot,
} from './container-runner.js';
import {
createTask,
deleteTask,
getAllChats,
getAllRegisteredGroups,
getAllSessions,
getAllTasks,
getLastGroupSync,
getMessagesSince,
getNewMessages,
getRouterState,
getTaskById,
initDatabase,
setLastGroupSync,
setRegisteredGroup,
setRouterState,
setSession,
storeChatMetadata,
storeMessage,
updateChatName,
updateTask,
} from './db.js';
import { GroupQueue } from './group-queue.js';
import { startIpcWatcher } from './ipc.js';
import { formatMessages, formatOutbound } from './router.js';
import { startSchedulerLoop } from './task-scheduler.js';
import { NewMessage, RegisteredGroup } from './types.js';
import { logger } from './logger.js';
const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
// Re-export for backwards compatibility during refactor
export { escapeXml, formatMessages } from './router.js';
let sock: WASocket;
let lastTimestamp = '';
let sessions: Record<string, string> = {};
let registeredGroups: Record<string, RegisteredGroup> = {};
let lastAgentTimestamp: Record<string, string> = {};
// LID to phone number mapping (WhatsApp now sends LID JIDs for self-chats)
let lidToPhoneMap: Record<string, string> = {};
// Guards to prevent duplicate loops on WhatsApp reconnect
let messageLoopRunning = false;
let ipcWatcherRunning = false;
let groupSyncTimerStarted = false;
// WhatsApp connection state and outgoing message queue
let waConnected = false;
const outgoingQueue: Array<{ jid: string; text: string }> = [];
let whatsapp: WhatsAppChannel;
const queue = new GroupQueue();
/**
* Translate a JID from LID format to phone format if we have a mapping.
* Returns the original JID if no mapping exists.
*/
function translateJid(jid: string): string {
if (!jid.endsWith('@lid')) return jid;
const lidUser = jid.split('@')[0].split(':')[0];
const phoneJid = lidToPhoneMap[lidUser];
if (phoneJid) {
logger.debug({ lidJid: jid, phoneJid }, 'Translated LID to phone JID');
return phoneJid;
}
return jid;
}
async function setTyping(jid: string, isTyping: boolean): Promise<void> {
try {
await sock.sendPresenceUpdate(isTyping ? 'composing' : 'paused', jid);
} catch (err) {
logger.debug({ jid, err }, 'Failed to update typing status');
}
}
function loadState(): void {
// Load from SQLite (migration from JSON happens in initDatabase)
lastTimestamp = getRouterState('last_timestamp') || '';
const agentTs = getRouterState('last_agent_timestamp');
try {
@@ -137,49 +90,11 @@ function registerGroup(jid: string, group: RegisteredGroup): void {
);
}
/**
* Sync group metadata from WhatsApp.
* Fetches all participating groups and stores their names in the database.
* Called on startup, daily, and on-demand via IPC.
*/
async function syncGroupMetadata(force = false): Promise<void> {
// Check if we need to sync (skip if synced recently, unless forced)
if (!force) {
const lastSync = getLastGroupSync();
if (lastSync) {
const lastSyncTime = new Date(lastSync).getTime();
const now = Date.now();
if (now - lastSyncTime < GROUP_SYNC_INTERVAL_MS) {
logger.debug({ lastSync }, 'Skipping group sync - synced recently');
return;
}
}
}
try {
logger.info('Syncing group metadata from WhatsApp...');
const groups = await sock.groupFetchAllParticipating();
let count = 0;
for (const [jid, metadata] of Object.entries(groups)) {
if (metadata.subject) {
updateChatName(jid, metadata.subject);
count++;
}
}
setLastGroupSync();
logger.info({ count }, 'Group metadata synced');
} catch (err) {
logger.error({ err }, 'Failed to sync group metadata');
}
}
/**
* Get available groups list for the agent.
* Returns groups ordered by most recent activity.
*/
function getAvailableGroups(): AvailableGroup[] {
export function getAvailableGroups(): import('./container-runner.js').AvailableGroup[] {
const chats = getAllChats();
const registeredJids = new Set(Object.keys(registeredGroups));
@@ -193,28 +108,14 @@ function getAvailableGroups(): AvailableGroup[] {
}));
}
function escapeXml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function formatMessages(messages: NewMessage[]): string {
const lines = messages.map((m) =>
`<message sender="${escapeXml(m.sender_name)}" time="${m.timestamp}">${escapeXml(m.content)}</message>`,
);
return `<messages>\n${lines.join('\n')}\n</messages>`;
/** @internal - exported for testing */
export function _setRegisteredGroups(groups: Record<string, RegisteredGroup>): void {
registeredGroups = groups;
}
/**
* Process all pending messages for a group.
* Called by the GroupQueue when it's this group's turn.
*
* Uses streaming output: agent results are sent to WhatsApp as they arrive.
* The container stays alive for IDLE_TIMEOUT after each result, allowing
* rapid-fire messages to be piped in without spawning a new container.
*/
async function processGroupMessages(chatJid: string): Promise<boolean> {
const group = registeredGroups[chatJid];
@@ -222,7 +123,6 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
// Get all messages since last agent interaction
const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
const missedMessages = getMessagesSince(
chatJid,
@@ -265,7 +165,7 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
}, IDLE_TIMEOUT);
};
await setTyping(chatJid, true);
await whatsapp.setTyping(chatJid, true);
let hadError = false;
const output = await runAgent(group, prompt, chatJid, async (result) => {
@@ -276,7 +176,7 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
const text = raw.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
logger.info({ group: group.name }, `Agent output: ${raw.slice(0, 200)}`);
if (text) {
await sendMessage(chatJid, `${ASSISTANT_NAME}: ${text}`);
await whatsapp.sendMessage(chatJid, `${ASSISTANT_NAME}: ${text}`);
}
// Only reset idle timer on actual results, not session-update markers (result: null)
resetIdleTimer();
@@ -287,7 +187,7 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
}
});
await setTyping(chatJid, false);
await whatsapp.setTyping(chatJid, false);
if (idleTimer) clearTimeout(idleTimer);
if (output === 'error' || hadError) {
@@ -380,511 +280,6 @@ async function runAgent(
}
}
async function sendMessage(jid: string, text: string): Promise<void> {
if (!waConnected) {
outgoingQueue.push({ jid, text });
logger.info({ jid, length: text.length, queueSize: outgoingQueue.length }, 'WA disconnected, message queued');
return;
}
try {
await sock.sendMessage(jid, { text });
logger.info({ jid, length: text.length }, 'Message sent');
} catch (err) {
// If send fails, queue it for retry on reconnect
outgoingQueue.push({ jid, text });
logger.warn({ jid, err, queueSize: outgoingQueue.length }, 'Failed to send, message queued');
}
}
let flushing = false;
async function flushOutgoingQueue(): Promise<void> {
if (flushing || outgoingQueue.length === 0) return;
flushing = true;
try {
logger.info({ count: outgoingQueue.length }, 'Flushing outgoing message queue');
// Process one at a time — sendMessage re-queues on failure internally.
// Shift instead of splice so unattempted messages stay in the queue
// if an unexpected error occurs.
while (outgoingQueue.length > 0) {
const item = outgoingQueue.shift()!;
await sendMessage(item.jid, item.text);
}
} finally {
flushing = false;
}
}
function startIpcWatcher(): void {
if (ipcWatcherRunning) {
logger.debug('IPC watcher already running, skipping duplicate start');
return;
}
ipcWatcherRunning = true;
const ipcBaseDir = path.join(DATA_DIR, 'ipc');
fs.mkdirSync(ipcBaseDir, { recursive: true });
const processIpcFiles = async () => {
// Scan all group IPC directories (identity determined by directory)
let groupFolders: string[];
try {
groupFolders = fs.readdirSync(ipcBaseDir).filter((f) => {
const stat = fs.statSync(path.join(ipcBaseDir, f));
return stat.isDirectory() && f !== 'errors';
});
} catch (err) {
logger.error({ err }, 'Error reading IPC base directory');
setTimeout(processIpcFiles, IPC_POLL_INTERVAL);
return;
}
for (const sourceGroup of groupFolders) {
const isMain = sourceGroup === MAIN_GROUP_FOLDER;
const messagesDir = path.join(ipcBaseDir, sourceGroup, 'messages');
const tasksDir = path.join(ipcBaseDir, sourceGroup, 'tasks');
// Process messages from this group's IPC directory
try {
if (fs.existsSync(messagesDir)) {
const messageFiles = fs
.readdirSync(messagesDir)
.filter((f) => f.endsWith('.json'));
for (const file of messageFiles) {
const filePath = path.join(messagesDir, file);
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
if (data.type === 'message' && data.chatJid && data.text) {
// Authorization: verify this group can send to this chatJid
const targetGroup = registeredGroups[data.chatJid];
if (
isMain ||
(targetGroup && targetGroup.folder === sourceGroup)
) {
await sendMessage(
data.chatJid,
`${ASSISTANT_NAME}: ${data.text}`,
);
logger.info(
{ chatJid: data.chatJid, sourceGroup },
'IPC message sent',
);
} else {
logger.warn(
{ chatJid: data.chatJid, sourceGroup },
'Unauthorized IPC message attempt blocked',
);
}
}
fs.unlinkSync(filePath);
} catch (err) {
logger.error(
{ file, sourceGroup, err },
'Error processing IPC message',
);
const errorDir = path.join(ipcBaseDir, 'errors');
fs.mkdirSync(errorDir, { recursive: true });
fs.renameSync(
filePath,
path.join(errorDir, `${sourceGroup}-${file}`),
);
}
}
}
} catch (err) {
logger.error(
{ err, sourceGroup },
'Error reading IPC messages directory',
);
}
// Process tasks from this group's IPC directory
try {
if (fs.existsSync(tasksDir)) {
const taskFiles = fs
.readdirSync(tasksDir)
.filter((f) => f.endsWith('.json'));
for (const file of taskFiles) {
const filePath = path.join(tasksDir, file);
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
// Pass source group identity to processTaskIpc for authorization
await processTaskIpc(data, sourceGroup, isMain);
fs.unlinkSync(filePath);
} catch (err) {
logger.error(
{ file, sourceGroup, err },
'Error processing IPC task',
);
const errorDir = path.join(ipcBaseDir, 'errors');
fs.mkdirSync(errorDir, { recursive: true });
fs.renameSync(
filePath,
path.join(errorDir, `${sourceGroup}-${file}`),
);
}
}
}
} catch (err) {
logger.error({ err, sourceGroup }, 'Error reading IPC tasks directory');
}
}
setTimeout(processIpcFiles, IPC_POLL_INTERVAL);
};
processIpcFiles();
logger.info('IPC watcher started (per-group namespaces)');
}
async function processTaskIpc(
data: {
type: string;
taskId?: string;
prompt?: string;
schedule_type?: string;
schedule_value?: string;
context_mode?: string;
groupFolder?: string;
chatJid?: string;
targetJid?: string;
// For register_group
jid?: string;
name?: string;
folder?: string;
trigger?: string;
containerConfig?: RegisteredGroup['containerConfig'];
},
sourceGroup: string, // Verified identity from IPC directory
isMain: boolean, // Verified from directory path
): Promise<void> {
switch (data.type) {
case 'schedule_task':
if (
data.prompt &&
data.schedule_type &&
data.schedule_value &&
data.targetJid
) {
// Resolve the target group from JID
const targetJid = data.targetJid as string;
const targetGroupEntry = registeredGroups[targetJid];
if (!targetGroupEntry) {
logger.warn(
{ targetJid },
'Cannot schedule task: target group not registered',
);
break;
}
const targetFolder = targetGroupEntry.folder;
// Authorization: non-main groups can only schedule for themselves
if (!isMain && targetFolder !== sourceGroup) {
logger.warn(
{ sourceGroup, targetFolder },
'Unauthorized schedule_task attempt blocked',
);
break;
}
const scheduleType = data.schedule_type as 'cron' | 'interval' | 'once';
let nextRun: string | null = null;
if (scheduleType === 'cron') {
try {
const interval = CronExpressionParser.parse(data.schedule_value, {
tz: TIMEZONE,
});
nextRun = interval.next().toISOString();
} catch {
logger.warn(
{ scheduleValue: data.schedule_value },
'Invalid cron expression',
);
break;
}
} else if (scheduleType === 'interval') {
const ms = parseInt(data.schedule_value, 10);
if (isNaN(ms) || ms <= 0) {
logger.warn(
{ scheduleValue: data.schedule_value },
'Invalid interval',
);
break;
}
nextRun = new Date(Date.now() + ms).toISOString();
} else if (scheduleType === 'once') {
const scheduled = new Date(data.schedule_value);
if (isNaN(scheduled.getTime())) {
logger.warn(
{ scheduleValue: data.schedule_value },
'Invalid timestamp',
);
break;
}
nextRun = scheduled.toISOString();
}
const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const contextMode =
data.context_mode === 'group' || data.context_mode === 'isolated'
? data.context_mode
: 'isolated';
createTask({
id: taskId,
group_folder: targetFolder,
chat_jid: targetJid,
prompt: data.prompt,
schedule_type: scheduleType,
schedule_value: data.schedule_value,
context_mode: contextMode,
next_run: nextRun,
status: 'active',
created_at: new Date().toISOString(),
});
logger.info(
{ taskId, sourceGroup, targetFolder, contextMode },
'Task created via IPC',
);
}
break;
case 'pause_task':
if (data.taskId) {
const task = getTaskById(data.taskId);
if (task && (isMain || task.group_folder === sourceGroup)) {
updateTask(data.taskId, { status: 'paused' });
logger.info(
{ taskId: data.taskId, sourceGroup },
'Task paused via IPC',
);
} else {
logger.warn(
{ taskId: data.taskId, sourceGroup },
'Unauthorized task pause attempt',
);
}
}
break;
case 'resume_task':
if (data.taskId) {
const task = getTaskById(data.taskId);
if (task && (isMain || task.group_folder === sourceGroup)) {
updateTask(data.taskId, { status: 'active' });
logger.info(
{ taskId: data.taskId, sourceGroup },
'Task resumed via IPC',
);
} else {
logger.warn(
{ taskId: data.taskId, sourceGroup },
'Unauthorized task resume attempt',
);
}
}
break;
case 'cancel_task':
if (data.taskId) {
const task = getTaskById(data.taskId);
if (task && (isMain || task.group_folder === sourceGroup)) {
deleteTask(data.taskId);
logger.info(
{ taskId: data.taskId, sourceGroup },
'Task cancelled via IPC',
);
} else {
logger.warn(
{ taskId: data.taskId, sourceGroup },
'Unauthorized task cancel attempt',
);
}
}
break;
case 'refresh_groups':
// Only main group can request a refresh
if (isMain) {
logger.info(
{ sourceGroup },
'Group metadata refresh requested via IPC',
);
await syncGroupMetadata(true);
// Write updated snapshot immediately
const availableGroups = getAvailableGroups();
writeGroupsSnapshot(
sourceGroup,
true,
availableGroups,
new Set(Object.keys(registeredGroups)),
);
} else {
logger.warn(
{ sourceGroup },
'Unauthorized refresh_groups attempt blocked',
);
}
break;
case 'register_group':
// Only main group can register new groups
if (!isMain) {
logger.warn(
{ sourceGroup },
'Unauthorized register_group attempt blocked',
);
break;
}
if (data.jid && data.name && data.folder && data.trigger) {
registerGroup(data.jid, {
name: data.name,
folder: data.folder,
trigger: data.trigger,
added_at: new Date().toISOString(),
containerConfig: data.containerConfig,
});
} else {
logger.warn(
{ data },
'Invalid register_group request - missing required fields',
);
}
break;
default:
logger.warn({ type: data.type }, 'Unknown IPC task type');
}
}
async function connectWhatsApp(): Promise<void> {
const authDir = path.join(STORE_DIR, 'auth');
fs.mkdirSync(authDir, { recursive: true });
const { state, saveCreds } = await useMultiFileAuthState(authDir);
sock = makeWASocket({
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, logger),
},
printQRInTerminal: false,
logger,
browser: ['NanoClaw', 'Chrome', '1.0.0'],
});
sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
const msg =
'WhatsApp authentication required. Run /setup in Claude Code.';
logger.error(msg);
exec(
`osascript -e 'display notification "${msg}" with title "NanoClaw" sound name "Basso"'`,
);
setTimeout(() => process.exit(1), 1000);
}
if (connection === 'close') {
waConnected = false;
const reason = (lastDisconnect?.error as any)?.output?.statusCode;
const shouldReconnect = reason !== DisconnectReason.loggedOut;
logger.info({ reason, shouldReconnect, queuedMessages: outgoingQueue.length }, 'Connection closed');
if (shouldReconnect) {
logger.info('Reconnecting...');
connectWhatsApp().catch((err) => {
logger.error({ err }, 'Failed to reconnect, retrying in 5s');
setTimeout(() => {
connectWhatsApp().catch((err2) => {
logger.error({ err: err2 }, 'Reconnection retry failed');
});
}, 5000);
});
} else {
logger.info('Logged out. Run /setup to re-authenticate.');
process.exit(0);
}
} else if (connection === 'open') {
waConnected = true;
logger.info('Connected to WhatsApp');
// Build LID to phone mapping from auth state for self-chat translation
if (sock.user) {
const phoneUser = sock.user.id.split(':')[0];
const lidUser = sock.user.lid?.split(':')[0];
if (lidUser && phoneUser) {
lidToPhoneMap[lidUser] = `${phoneUser}@s.whatsapp.net`;
logger.debug({ lidUser, phoneUser }, 'LID to phone mapping set');
}
}
// Flush any messages queued while disconnected
flushOutgoingQueue().catch((err) =>
logger.error({ err }, 'Failed to flush outgoing queue'),
);
// Sync group metadata on startup (respects 24h cache)
syncGroupMetadata().catch((err) =>
logger.error({ err }, 'Initial group sync failed'),
);
// Set up daily sync timer (only once)
if (!groupSyncTimerStarted) {
groupSyncTimerStarted = true;
setInterval(() => {
syncGroupMetadata().catch((err) =>
logger.error({ err }, 'Periodic group sync failed'),
);
}, GROUP_SYNC_INTERVAL_MS);
}
startSchedulerLoop({
registeredGroups: () => registeredGroups,
getSessions: () => sessions,
queue,
onProcess: (groupJid, proc, containerName, groupFolder) => queue.registerProcess(groupJid, proc, containerName, groupFolder),
sendMessage,
assistantName: ASSISTANT_NAME,
});
startIpcWatcher();
queue.setProcessMessagesFn(processGroupMessages);
recoverPendingMessages();
startMessageLoop();
}
});
sock.ev.on('creds.update', saveCreds);
sock.ev.on('messages.upsert', ({ messages }) => {
for (const msg of messages) {
if (!msg.message) continue;
const rawJid = msg.key.remoteJid;
if (!rawJid || rawJid === 'status@broadcast') continue;
// Translate LID JID to phone JID if applicable
const chatJid = translateJid(rawJid);
const timestamp = new Date(
Number(msg.messageTimestamp) * 1000,
).toISOString();
// Always store chat metadata for group discovery
storeChatMetadata(chatJid, timestamp);
// Only store full message content for registered groups
if (registeredGroups[chatJid]) {
storeMessage(
msg,
chatJid,
msg.key.fromMe || false,
msg.pushName || undefined,
);
}
}
});
}
async function startMessageLoop(): Promise<void> {
if (messageLoopRunning) {
logger.debug('Message loop already running, skipping duplicate start');
@@ -1060,15 +455,54 @@ async function main(): Promise<void> {
const shutdown = async (signal: string) => {
logger.info({ signal }, 'Shutdown signal received');
await queue.shutdown(10000);
await whatsapp.disconnect();
process.exit(0);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
await connectWhatsApp();
// Create WhatsApp channel
whatsapp = new WhatsAppChannel({
onMessage: (chatJid, msg) => storeMessage(msg),
onChatMetadata: (chatJid, timestamp) => storeChatMetadata(chatJid, timestamp),
registeredGroups: () => registeredGroups,
});
// Connect — resolves when first connected
await whatsapp.connect();
// Start subsystems (independently of connection handler)
startSchedulerLoop({
registeredGroups: () => registeredGroups,
getSessions: () => sessions,
queue,
onProcess: (groupJid, proc, containerName, groupFolder) => queue.registerProcess(groupJid, proc, containerName, groupFolder),
sendMessage: async (jid, rawText) => {
const text = formatOutbound(whatsapp, rawText);
if (text) await whatsapp.sendMessage(jid, text);
},
});
startIpcWatcher({
sendMessage: (jid, text) => whatsapp.sendMessage(jid, text),
registeredGroups: () => registeredGroups,
registerGroup,
syncGroupMetadata: (force) => whatsapp.syncGroupMetadata(force),
getAvailableGroups,
writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj),
});
queue.setProcessMessagesFn(processGroupMessages);
recoverPendingMessages();
startMessageLoop();
}
main().catch((err) => {
logger.error({ err }, 'Failed to start NanoClaw');
process.exit(1);
});
// Guard: only run when executed directly, not when imported by tests
const isDirectRun =
process.argv[1] &&
new URL(import.meta.url).pathname === new URL(`file://${process.argv[1]}`).pathname;
if (isDirectRun) {
main().catch((err) => {
logger.error({ err }, 'Failed to start NanoClaw');
process.exit(1);
});
}