Add group metadata sync for easier group activation
- Sync group names from WhatsApp via groupFetchAllParticipating() - Store group names in chats table (jid -> name mapping) - Daily sync with 24h cache, on-demand refresh via IPC - Write available_groups.json snapshot for agent (main group only) - Agent can request refresh_groups via IPC if group not found - Update documentation in main CLAUDE.md and debug skill Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -113,14 +113,16 @@ container run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c 'ls -la /work
|
|||||||
Expected structure:
|
Expected structure:
|
||||||
```
|
```
|
||||||
/workspace/
|
/workspace/
|
||||||
├── env-dir/env # Environment file (CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY)
|
├── env-dir/env # Environment file (CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY)
|
||||||
├── group/ # Current group folder (cwd)
|
├── group/ # Current group folder (cwd)
|
||||||
├── project/ # Project root (main channel only)
|
├── project/ # Project root (main channel only)
|
||||||
├── global/ # Global CLAUDE.md (non-main only)
|
├── global/ # Global CLAUDE.md (non-main only)
|
||||||
├── ipc/ # Inter-process communication
|
├── ipc/ # Inter-process communication
|
||||||
│ ├── messages/ # Outgoing WhatsApp messages
|
│ ├── messages/ # Outgoing WhatsApp messages
|
||||||
│ └── tasks/ # Scheduled task commands
|
│ ├── tasks/ # Scheduled task commands
|
||||||
└── extra/ # Additional custom mounts
|
│ ├── 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
|
### 4. Permission Issues
|
||||||
@@ -304,8 +306,20 @@ ls -la data/ipc/tasks/
|
|||||||
|
|
||||||
# Read a specific IPC file
|
# Read a specific IPC file
|
||||||
cat data/ipc/messages/*.json
|
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
|
## Quick Diagnostic Script
|
||||||
|
|
||||||
Run this to check common issues:
|
Run this to check common issues:
|
||||||
|
|||||||
@@ -57,15 +57,40 @@ Key paths inside the container:
|
|||||||
|
|
||||||
### Finding Available Groups
|
### 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
|
```bash
|
||||||
sqlite3 /workspace/project/store/messages.db "
|
sqlite3 /workspace/project/store/messages.db "
|
||||||
SELECT DISTINCT chat_jid, MAX(timestamp) as last_message
|
SELECT jid, name, last_message_time
|
||||||
FROM messages
|
FROM chats
|
||||||
WHERE chat_jid LIKE '%@g.us'
|
WHERE jid LIKE '%@g.us' AND jid != '__group_sync__'
|
||||||
GROUP BY chat_jid
|
ORDER BY last_message_time DESC
|
||||||
ORDER BY last_message DESC
|
|
||||||
LIMIT 10;
|
LIMIT 10;
|
||||||
"
|
"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -400,3 +400,34 @@ export function writeTasksSnapshot(
|
|||||||
const tasksFile = path.join(groupIpcDir, 'current_tasks.json');
|
const tasksFile = path.join(groupIpcDir, 'current_tasks.json');
|
||||||
fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2));
|
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<string>
|
||||||
|
): 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));
|
||||||
|
}
|
||||||
|
|||||||
65
src/db.ts
65
src/db.ts
@@ -75,9 +75,68 @@ export function initDatabase(): void {
|
|||||||
* Store chat metadata only (no message content).
|
* Store chat metadata only (no message content).
|
||||||
* Used for all chats to enable group discovery without storing sensitive content.
|
* Used for all chats to enable group discovery without storing sensitive content.
|
||||||
*/
|
*/
|
||||||
export function storeChatMetadata(chatJid: string, timestamp: string): void {
|
export function storeChatMetadata(chatJid: string, timestamp: string, name?: string): void {
|
||||||
db.prepare(`INSERT OR REPLACE INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`)
|
if (name) {
|
||||||
.run(chatJid, chatJid, timestamp);
|
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
86
src/index.ts
86
src/index.ts
@@ -19,11 +19,13 @@ import {
|
|||||||
IPC_POLL_INTERVAL
|
IPC_POLL_INTERVAL
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
import { RegisteredGroup, Session, NewMessage } from './types.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 { 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';
|
import { loadJson, saveJson } from './utils.js';
|
||||||
|
|
||||||
|
const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
const logger = pino({
|
const logger = pino({
|
||||||
level: process.env.LOG_LEVEL || 'info',
|
level: process.env.LOG_LEVEL || 'info',
|
||||||
transport: { target: 'pino-pretty', options: { colorize: true } }
|
transport: { target: 'pino-pretty', options: { colorize: true } }
|
||||||
@@ -58,6 +60,62 @@ function saveState(): void {
|
|||||||
saveJson(path.join(DATA_DIR, 'sessions.json'), sessions);
|
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<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[] {
|
||||||
|
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<void> {
|
async function processMessage(msg: NewMessage): Promise<void> {
|
||||||
const group = registeredGroups[msg.chat_jid];
|
const group = registeredGroups[msg.chat_jid];
|
||||||
if (!group) return;
|
if (!group) return;
|
||||||
@@ -110,6 +168,10 @@ async function runAgent(group: RegisteredGroup, prompt: string, chatJid: string)
|
|||||||
next_run: t.next_run
|
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 {
|
try {
|
||||||
const output = await runContainerAgent(group, {
|
const output = await runContainerAgent(group, {
|
||||||
prompt,
|
prompt,
|
||||||
@@ -351,6 +413,20 @@ async function processTaskIpc(
|
|||||||
}
|
}
|
||||||
break;
|
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:
|
default:
|
||||||
logger.warn({ type: data.type }, 'Unknown IPC task type');
|
logger.warn({ type: data.type }, 'Unknown IPC task type');
|
||||||
}
|
}
|
||||||
@@ -393,6 +469,12 @@ async function connectWhatsApp(): Promise<void> {
|
|||||||
}
|
}
|
||||||
} else if (connection === 'open') {
|
} else if (connection === 'open') {
|
||||||
logger.info('Connected to WhatsApp');
|
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({
|
startSchedulerLoop({
|
||||||
sendMessage,
|
sendMessage,
|
||||||
registeredGroups: () => registeredGroups,
|
registeredGroups: () => registeredGroups,
|
||||||
|
|||||||
Reference in New Issue
Block a user