diff --git a/.claude/skills/debug/SKILL.md b/.claude/skills/debug/SKILL.md index 68e18b2..baab425 100644 --- a/.claude/skills/debug/SKILL.md +++ b/.claude/skills/debug/SKILL.md @@ -113,14 +113,16 @@ container run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c 'ls -la /work Expected structure: ``` /workspace/ -├── env-dir/env # Environment file (CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY) -├── group/ # Current group folder (cwd) -├── project/ # Project root (main channel only) -├── global/ # Global CLAUDE.md (non-main only) -├── ipc/ # Inter-process communication -│ ├── messages/ # Outgoing WhatsApp messages -│ └── tasks/ # Scheduled task commands -└── extra/ # Additional custom mounts +├── env-dir/env # Environment file (CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY) +├── group/ # Current group folder (cwd) +├── project/ # Project root (main channel only) +├── global/ # Global CLAUDE.md (non-main only) +├── ipc/ # Inter-process communication +│ ├── messages/ # Outgoing WhatsApp messages +│ ├── tasks/ # Scheduled task commands +│ ├── current_tasks.json # Read-only: scheduled tasks visible to this group +│ └── available_groups.json # Read-only: WhatsApp groups for activation (main only) +└── extra/ # Additional custom mounts ``` ### 4. Permission Issues @@ -304,8 +306,20 @@ ls -la data/ipc/tasks/ # Read a specific IPC file cat data/ipc/messages/*.json + +# Check available groups (main channel only) +cat data/ipc/main/available_groups.json + +# Check current tasks snapshot +cat data/ipc/{groupFolder}/current_tasks.json ``` +**IPC file types:** +- `messages/*.json` - Agent writes: outgoing WhatsApp messages +- `tasks/*.json` - Agent writes: task operations (schedule, pause, resume, cancel, refresh_groups) +- `current_tasks.json` - Host writes: read-only snapshot of scheduled tasks +- `available_groups.json` - Host writes: read-only list of WhatsApp groups (main only) + ## Quick Diagnostic Script Run this to check common issues: diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md index a1db749..14da0cd 100644 --- a/groups/main/CLAUDE.md +++ b/groups/main/CLAUDE.md @@ -57,15 +57,40 @@ Key paths inside the container: ### Finding Available Groups -Groups appear in the database when messages are received. Query the SQLite database: +Available groups are provided in `/workspace/ipc/available_groups.json`: + +```json +{ + "groups": [ + { + "jid": "120363336345536173@g.us", + "name": "Family Chat", + "lastActivity": "2026-01-31T12:00:00.000Z", + "isRegistered": false + } + ], + "lastSync": "2026-01-31T12:00:00.000Z" +} +``` + +Groups are ordered by most recent activity. The list is synced from WhatsApp daily. + +If a group the user mentions isn't in the list, request a fresh sync: + +```bash +echo '{"type": "refresh_groups"}' > /workspace/ipc/tasks/refresh_$(date +%s).json +``` + +Then wait a moment and re-read `available_groups.json`. + +**Fallback**: Query the SQLite database directly: ```bash sqlite3 /workspace/project/store/messages.db " - SELECT DISTINCT chat_jid, MAX(timestamp) as last_message - FROM messages - WHERE chat_jid LIKE '%@g.us' - GROUP BY chat_jid - ORDER BY last_message DESC + SELECT jid, name, last_message_time + FROM chats + WHERE jid LIKE '%@g.us' AND jid != '__group_sync__' + ORDER BY last_message_time DESC LIMIT 10; " ``` diff --git a/src/container-runner.ts b/src/container-runner.ts index e79fd32..58d14c8 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -400,3 +400,34 @@ export function writeTasksSnapshot( const tasksFile = path.join(groupIpcDir, 'current_tasks.json'); fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2)); } + +export interface AvailableGroup { + jid: string; + name: string; + lastActivity: string; + isRegistered: boolean; +} + +/** + * Write available groups snapshot for the container to read. + * Only main group can see all available groups (for activation). + * Non-main groups only see their own registration status. + */ +export function writeGroupsSnapshot( + groupFolder: string, + isMain: boolean, + groups: AvailableGroup[], + registeredJids: Set +): void { + const groupIpcDir = path.join(DATA_DIR, 'ipc', groupFolder); + fs.mkdirSync(groupIpcDir, { recursive: true }); + + // Main sees all groups; others see nothing (they can't activate groups) + const visibleGroups = isMain ? groups : []; + + const groupsFile = path.join(groupIpcDir, 'available_groups.json'); + fs.writeFileSync(groupsFile, JSON.stringify({ + groups: visibleGroups, + lastSync: new Date().toISOString() + }, null, 2)); +} diff --git a/src/db.ts b/src/db.ts index 6571250..670a048 100644 --- a/src/db.ts +++ b/src/db.ts @@ -75,9 +75,68 @@ export function initDatabase(): void { * Store chat metadata only (no message content). * Used for all chats to enable group discovery without storing sensitive content. */ -export function storeChatMetadata(chatJid: string, timestamp: string): void { - db.prepare(`INSERT OR REPLACE INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`) - .run(chatJid, chatJid, timestamp); +export function storeChatMetadata(chatJid: string, timestamp: string, name?: string): void { + if (name) { + // Update with name, preserving existing timestamp if newer + db.prepare(` + INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?) + ON CONFLICT(jid) DO UPDATE SET + name = excluded.name, + last_message_time = MAX(last_message_time, excluded.last_message_time) + `).run(chatJid, name, timestamp); + } else { + // Update timestamp only, preserve existing name if any + db.prepare(` + INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?) + ON CONFLICT(jid) DO UPDATE SET + last_message_time = MAX(last_message_time, excluded.last_message_time) + `).run(chatJid, chatJid, timestamp); + } +} + +/** + * Update chat name without changing 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(0).toISOString()); +} + +export interface ChatInfo { + jid: string; + name: string; + last_message_time: string; +} + +/** + * Get all known chats, ordered by most recent activity. + */ +export function getAllChats(): ChatInfo[] { + return db.prepare(` + SELECT jid, name, last_message_time + FROM chats + ORDER BY last_message_time DESC + `).all() as ChatInfo[]; +} + +/** + * Get timestamp of last group metadata sync. + */ +export function getLastGroupSync(): string | null { + // Store sync time in a special chat entry + const row = db.prepare(`SELECT last_message_time FROM chats WHERE jid = '__group_sync__'`).get() as { last_message_time: string } | undefined; + return row?.last_message_time || null; +} + +/** + * Record that group metadata was synced. + */ +export function setLastGroupSync(): void { + const now = new Date().toISOString(); + db.prepare(`INSERT OR REPLACE INTO chats (jid, name, last_message_time) VALUES ('__group_sync__', '__group_sync__', ?)`).run(now); } /** diff --git a/src/index.ts b/src/index.ts index 467b512..93c8e8f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,11 +19,13 @@ import { IPC_POLL_INTERVAL } from './config.js'; import { RegisteredGroup, Session, NewMessage } from './types.js'; -import { initDatabase, storeMessage, storeChatMetadata, getNewMessages, getMessagesSince, getAllTasks, getTaskById } from './db.js'; +import { initDatabase, storeMessage, storeChatMetadata, getNewMessages, getMessagesSince, getAllTasks, getTaskById, updateChatName, getAllChats, getLastGroupSync, setLastGroupSync } from './db.js'; import { startSchedulerLoop } from './task-scheduler.js'; -import { runContainerAgent, writeTasksSnapshot } from './container-runner.js'; +import { runContainerAgent, writeTasksSnapshot, writeGroupsSnapshot, AvailableGroup } from './container-runner.js'; import { loadJson, saveJson } from './utils.js'; +const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours + const logger = pino({ level: process.env.LOG_LEVEL || 'info', transport: { target: 'pino-pretty', options: { colorize: true } } @@ -58,6 +60,62 @@ function saveState(): void { saveJson(path.join(DATA_DIR, 'sessions.json'), sessions); } +/** + * 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 { + // 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[] { + const chats = getAllChats(); + const registeredJids = new Set(Object.keys(registeredGroups)); + + return chats + .filter(c => c.jid !== '__group_sync__' && c.jid.endsWith('@g.us')) + .map(c => ({ + jid: c.jid, + name: c.name, + lastActivity: c.last_message_time, + isRegistered: registeredJids.has(c.jid) + })); +} + async function processMessage(msg: NewMessage): Promise { const group = registeredGroups[msg.chat_jid]; if (!group) return; @@ -110,6 +168,10 @@ async function runAgent(group: RegisteredGroup, prompt: string, chatJid: string) next_run: t.next_run }))); + // Update available groups snapshot (main group only can see all groups) + const availableGroups = getAvailableGroups(); + writeGroupsSnapshot(group.folder, isMain, availableGroups, new Set(Object.keys(registeredGroups))); + try { const output = await runContainerAgent(group, { prompt, @@ -351,6 +413,20 @@ async function processTaskIpc( } 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(); + const { writeGroupsSnapshot: writeGroups } = await import('./container-runner.js'); + writeGroups(sourceGroup, true, availableGroups, new Set(Object.keys(registeredGroups))); + } else { + logger.warn({ sourceGroup }, 'Unauthorized refresh_groups attempt blocked'); + } + break; + default: logger.warn({ type: data.type }, 'Unknown IPC task type'); } @@ -393,6 +469,12 @@ async function connectWhatsApp(): Promise { } } else if (connection === 'open') { logger.info('Connected to WhatsApp'); + // Sync group metadata on startup (respects 24h cache) + syncGroupMetadata().catch(err => logger.error({ err }, 'Initial group sync failed')); + // Set up daily sync timer + setInterval(() => { + syncGroupMetadata().catch(err => logger.error({ err }, 'Periodic group sync failed')); + }, GROUP_SYNC_INTERVAL_MS); startSchedulerLoop({ sendMessage, registeredGroups: () => registeredGroups,