From 5b2bafd7bb0c7ef35401aa18f4f696fec181a088 Mon Sep 17 00:00:00 2001 From: glifocat Date: Sun, 8 Mar 2026 18:58:48 +0100 Subject: [PATCH 1/4] fix(whatsapp): use sender's JID for DM-with-bot registration, skip trigger (#751) Two bugs in the DM with dedicated bot number setup: 1. The skill asked for the bot's own phone number to use as the JID. But from the bot's perspective, incoming DMs appear with the SENDER's JID (the user's personal number), not the bot's own number. The registration must use the user's personal number as the JID. 2. DM with bot (1:1 conversation) should use --no-trigger-required, same as self-chat. A trigger prefix is unnecessary in a private DM. Co-authored-by: Claude Sonnet 4.6 --- .claude/skills/add-whatsapp/SKILL.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.claude/skills/add-whatsapp/SKILL.md b/.claude/skills/add-whatsapp/SKILL.md index 660123a..023e748 100644 --- a/.claude/skills/add-whatsapp/SKILL.md +++ b/.claude/skills/add-whatsapp/SKILL.md @@ -201,7 +201,11 @@ AskUserQuestion: Where do you want to chat with the assistant? node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')" ``` -**DM with bot:** Ask for the bot's phone number. JID = `NUMBER@s.whatsapp.net` +**DM with bot:** The JID is the **user's** phone number — the number they will message *from* (not the bot's own number). Ask: + +AskUserQuestion: What is your personal phone number? (The number you'll use to message the bot — include country code without +, e.g. 1234567890) + +JID = `@s.whatsapp.net` **Group (solo, existing):** Run group sync and list available groups: @@ -223,7 +227,7 @@ npx tsx setup/index.ts --step register \ --channel whatsapp \ --assistant-name "" \ --is-main \ - --no-trigger-required # Only for main/self-chat + --no-trigger-required # For self-chat and DM with bot (1:1 conversations don't need a trigger prefix) ``` For additional groups (trigger-required): From ab9abbb21a5d638b3dbd3a767e6e9057d93523c8 Mon Sep 17 00:00:00 2001 From: Yonatan Azrielant Date: Sun, 8 Mar 2026 14:02:20 -0400 Subject: [PATCH 2/4] feat(skill): add WhatsApp reactions skill (emoji reactions + status tracker) (#509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(skill): add reactions skill (emoji reactions + status tracker) * refactor(reactions): minimize overlays per upstream review Address gavrielc's review on qwibitai/nanoclaw#509: - SKILL.md: remove all inline code, follow add-telegram/add-whatsapp pattern (465→79 lines) - Rebuild overlays as minimal deltas against upstream/main base - ipc-mcp-stdio.ts: upstream base + only react_to_message tool (8% delta) - ipc.ts: upstream base + only reactions delta (14% delta) - group-queue.test.ts: upstream base + isActive tests only (5% delta) - Remove group-queue.ts overlay (isActive provided by container-hardening) - Remove group-queue.ts from manifest modifies list Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .claude/skills/add-reactions/SKILL.md | 103 ++ .../add/container/skills/reactions/SKILL.md | 63 ++ .../add/scripts/migrate-reactions.ts | 57 ++ .../add/src/status-tracker.test.ts | 450 +++++++++ .../add-reactions/add/src/status-tracker.ts | 324 ++++++ .claude/skills/add-reactions/manifest.yaml | 23 + .../agent-runner/src/ipc-mcp-stdio.ts | 440 ++++++++ .../modify/src/channels/whatsapp.test.ts | 952 ++++++++++++++++++ .../modify/src/channels/whatsapp.ts | 457 +++++++++ .../add-reactions/modify/src/db.test.ts | 715 +++++++++++++ .claude/skills/add-reactions/modify/src/db.ts | 801 +++++++++++++++ .../modify/src/group-queue.test.ts | 510 ++++++++++ .../skills/add-reactions/modify/src/index.ts | 726 +++++++++++++ .../add-reactions/modify/src/ipc-auth.test.ts | 807 +++++++++++++++ .../skills/add-reactions/modify/src/ipc.ts | 446 ++++++++ .../skills/add-reactions/modify/src/types.ts | 111 ++ 16 files changed, 6985 insertions(+) create mode 100644 .claude/skills/add-reactions/SKILL.md create mode 100644 .claude/skills/add-reactions/add/container/skills/reactions/SKILL.md create mode 100644 .claude/skills/add-reactions/add/scripts/migrate-reactions.ts create mode 100644 .claude/skills/add-reactions/add/src/status-tracker.test.ts create mode 100644 .claude/skills/add-reactions/add/src/status-tracker.ts create mode 100644 .claude/skills/add-reactions/manifest.yaml create mode 100644 .claude/skills/add-reactions/modify/container/agent-runner/src/ipc-mcp-stdio.ts create mode 100644 .claude/skills/add-reactions/modify/src/channels/whatsapp.test.ts create mode 100644 .claude/skills/add-reactions/modify/src/channels/whatsapp.ts create mode 100644 .claude/skills/add-reactions/modify/src/db.test.ts create mode 100644 .claude/skills/add-reactions/modify/src/db.ts create mode 100644 .claude/skills/add-reactions/modify/src/group-queue.test.ts create mode 100644 .claude/skills/add-reactions/modify/src/index.ts create mode 100644 .claude/skills/add-reactions/modify/src/ipc-auth.test.ts create mode 100644 .claude/skills/add-reactions/modify/src/ipc.ts create mode 100644 .claude/skills/add-reactions/modify/src/types.ts diff --git a/.claude/skills/add-reactions/SKILL.md b/.claude/skills/add-reactions/SKILL.md new file mode 100644 index 0000000..76f59ec --- /dev/null +++ b/.claude/skills/add-reactions/SKILL.md @@ -0,0 +1,103 @@ +--- +name: add-reactions +description: Add WhatsApp emoji reaction support — receive, send, store, and search reactions. +--- + +# Add Reactions + +This skill adds emoji reaction support to NanoClaw's WhatsApp channel: receive and store reactions, send reactions from the container agent via MCP tool, and query reaction history from SQLite. + +## Phase 1: Pre-flight + +### Check if already applied + +Read `.nanoclaw/state.yaml`. If `reactions` is in `applied_skills`, skip to Phase 3 (Verify). The code changes are already in place. + +## Phase 2: Apply Code Changes + +Run the skills engine to apply this skill's code package. The package files are in this directory alongside this SKILL.md. + +### Apply the skill + +```bash +npx tsx scripts/apply-skill.ts .claude/skills/add-reactions +``` + +This deterministically: +- Adds `scripts/migrate-reactions.ts` (database migration for `reactions` table with composite PK and indexes) +- Adds `src/status-tracker.ts` (forward-only emoji state machine for message lifecycle signaling, with persistence and retry) +- Adds `src/status-tracker.test.ts` (unit tests for StatusTracker) +- Adds `container/skills/reactions/SKILL.md` (agent-facing documentation for the `react_to_message` MCP tool) +- Modifies `src/db.ts` — adds `Reaction` interface, `reactions` table schema, `storeReaction`, `getReactionsForMessage`, `getMessagesByReaction`, `getReactionsByUser`, `getReactionStats`, `getLatestMessage`, `getMessageFromMe` +- Modifies `src/channels/whatsapp.ts` — adds `messages.reaction` event handler, `sendReaction()`, `reactToLatestMessage()` methods +- Modifies `src/types.ts` — adds optional `sendReaction` and `reactToLatestMessage` to `Channel` interface +- Modifies `src/ipc.ts` — adds `type: 'reaction'` IPC handler with group-scoped authorization +- Modifies `src/index.ts` — wires `sendReaction` dependency into IPC watcher +- Modifies `src/group-queue.ts` — `GroupQueue` class for per-group container concurrency with retry +- Modifies `container/agent-runner/src/ipc-mcp-stdio.ts` — adds `react_to_message` MCP tool exposed to container agents +- Records the application in `.nanoclaw/state.yaml` + +### Run database migration + +```bash +npx tsx scripts/migrate-reactions.ts +``` + +### Validate code changes + +```bash +npm test +npm run build +``` + +All tests must pass and build must be clean before proceeding. + +## Phase 3: Verify + +### Build and restart + +```bash +npm run build +``` + +Linux: +```bash +systemctl --user restart nanoclaw +``` + +macOS: +```bash +launchctl kickstart -k gui/$(id -u)/com.nanoclaw +``` + +### Test receiving reactions + +1. Send a message from your phone +2. React to it with an emoji on WhatsApp +3. Check the database: + +```bash +sqlite3 store/messages.db "SELECT * FROM reactions ORDER BY timestamp DESC LIMIT 5;" +``` + +### Test sending reactions + +Ask the agent to react to a message via the `react_to_message` MCP tool. Check your phone — the reaction should appear on the message. + +## Troubleshooting + +### Reactions not appearing in database + +- Check NanoClaw logs for `Failed to process reaction` errors +- Verify the chat is registered +- Confirm the service is running + +### Migration fails + +- Ensure `store/messages.db` exists and is accessible +- If "table reactions already exists", the migration already ran — skip it + +### Agent can't send reactions + +- Check IPC logs for `Unauthorized IPC reaction attempt blocked` — the agent can only react in its own group's chat +- Verify WhatsApp is connected: check logs for connection status diff --git a/.claude/skills/add-reactions/add/container/skills/reactions/SKILL.md b/.claude/skills/add-reactions/add/container/skills/reactions/SKILL.md new file mode 100644 index 0000000..4d8eeec --- /dev/null +++ b/.claude/skills/add-reactions/add/container/skills/reactions/SKILL.md @@ -0,0 +1,63 @@ +--- +name: reactions +description: React to WhatsApp messages with emoji. Use when the user asks you to react, when acknowledging a message with a reaction makes sense, or when you want to express a quick response without sending a full message. +--- + +# Reactions + +React to messages with emoji using the `mcp__nanoclaw__react_to_message` tool. + +## When to use + +- User explicitly asks you to react ("react with a thumbs up", "heart that message") +- Quick acknowledgment is more appropriate than a full text reply +- Expressing agreement, approval, or emotion about a specific message + +## How to use + +### React to the latest message + +``` +mcp__nanoclaw__react_to_message(emoji: "👍") +``` + +Omitting `message_id` reacts to the most recent message in the chat. + +### React to a specific message + +``` +mcp__nanoclaw__react_to_message(emoji: "❤️", message_id: "3EB0F4C9E7...") +``` + +Pass a `message_id` to react to a specific message. You can find message IDs by querying the messages database: + +```bash +sqlite3 /workspace/project/store/messages.db " + SELECT id, sender_name, substr(content, 1, 80), timestamp + FROM messages + WHERE chat_jid = '' + ORDER BY timestamp DESC + LIMIT 5; +" +``` + +### Remove a reaction + +Send an empty string to remove your reaction: + +``` +mcp__nanoclaw__react_to_message(emoji: "") +``` + +## Common emoji + +| Emoji | When to use | +|-------|-------------| +| 👍 | Acknowledgment, approval | +| ❤️ | Appreciation, love | +| 😂 | Something funny | +| 🔥 | Impressive, exciting | +| 🎉 | Celebration, congrats | +| 🙏 | Thanks, prayer | +| ✅ | Task done, confirmed | +| ❓ | Needs clarification | diff --git a/.claude/skills/add-reactions/add/scripts/migrate-reactions.ts b/.claude/skills/add-reactions/add/scripts/migrate-reactions.ts new file mode 100644 index 0000000..8dec46e --- /dev/null +++ b/.claude/skills/add-reactions/add/scripts/migrate-reactions.ts @@ -0,0 +1,57 @@ +// Database migration script for reactions table +// Run: npx tsx scripts/migrate-reactions.ts + +import Database from 'better-sqlite3'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const STORE_DIR = process.env.STORE_DIR || path.join(process.cwd(), 'store'); +const dbPath = path.join(STORE_DIR, 'messages.db'); + +console.log(`Migrating database at: ${dbPath}`); + +const db = new Database(dbPath); + +try { + db.transaction(() => { + db.exec(` + CREATE TABLE IF NOT EXISTS reactions ( + message_id TEXT NOT NULL, + message_chat_jid TEXT NOT NULL, + reactor_jid TEXT NOT NULL, + reactor_name TEXT, + emoji TEXT NOT NULL, + timestamp TEXT NOT NULL, + PRIMARY KEY (message_id, message_chat_jid, reactor_jid) + ); + `); + + console.log('Created reactions table'); + + db.exec(` + CREATE INDEX IF NOT EXISTS idx_reactions_message ON reactions(message_id, message_chat_jid); + CREATE INDEX IF NOT EXISTS idx_reactions_reactor ON reactions(reactor_jid); + CREATE INDEX IF NOT EXISTS idx_reactions_emoji ON reactions(emoji); + CREATE INDEX IF NOT EXISTS idx_reactions_timestamp ON reactions(timestamp); + `); + + console.log('Created indexes'); + })(); + + const tableInfo = db.prepare(`PRAGMA table_info(reactions)`).all(); + console.log('\nReactions table schema:'); + console.table(tableInfo); + + const count = db.prepare(`SELECT COUNT(*) as count FROM reactions`).get() as { + count: number; + }; + console.log(`\nCurrent reaction count: ${count.count}`); + + console.log('\nMigration complete!'); +} catch (err) { + console.error('Migration failed:', err); + process.exit(1); +} finally { + db.close(); +} diff --git a/.claude/skills/add-reactions/add/src/status-tracker.test.ts b/.claude/skills/add-reactions/add/src/status-tracker.test.ts new file mode 100644 index 0000000..53a439d --- /dev/null +++ b/.claude/skills/add-reactions/add/src/status-tracker.test.ts @@ -0,0 +1,450 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; + +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { + ...actual, + default: { + ...actual, + existsSync: vi.fn(() => false), + writeFileSync: vi.fn(), + readFileSync: vi.fn(() => '[]'), + mkdirSync: vi.fn(), + }, + }; +}); + +vi.mock('./logger.js', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +import { StatusTracker, StatusState, StatusTrackerDeps } from './status-tracker.js'; + +function makeDeps() { + return { + sendReaction: vi.fn(async () => {}), + sendMessage: vi.fn(async () => {}), + isMainGroup: vi.fn((jid) => jid === 'main@s.whatsapp.net'), + isContainerAlive: vi.fn(() => true), + }; +} + +describe('StatusTracker', () => { + let tracker: StatusTracker; + let deps: ReturnType; + + beforeEach(() => { + deps = makeDeps(); + tracker = new StatusTracker(deps); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('forward-only transitions', () => { + it('transitions RECEIVED -> THINKING -> WORKING -> DONE', async () => { + tracker.markReceived('msg1', 'main@s.whatsapp.net', false); + tracker.markThinking('msg1'); + tracker.markWorking('msg1'); + tracker.markDone('msg1'); + + // Wait for all reaction sends to complete + await tracker.flush(); + + expect(deps.sendReaction).toHaveBeenCalledTimes(4); + const emojis = deps.sendReaction.mock.calls.map((c) => c[2]); + expect(emojis).toEqual(['\u{1F440}', '\u{1F4AD}', '\u{1F504}', '\u{2705}']); + }); + + it('rejects backward transitions (WORKING -> THINKING is no-op)', async () => { + tracker.markReceived('msg1', 'main@s.whatsapp.net', false); + tracker.markThinking('msg1'); + tracker.markWorking('msg1'); + + const result = tracker.markThinking('msg1'); + expect(result).toBe(false); + + await tracker.flush(); + expect(deps.sendReaction).toHaveBeenCalledTimes(3); + }); + + it('rejects duplicate transitions (DONE -> DONE is no-op)', async () => { + tracker.markReceived('msg1', 'main@s.whatsapp.net', false); + tracker.markDone('msg1'); + + const result = tracker.markDone('msg1'); + expect(result).toBe(false); + + await tracker.flush(); + expect(deps.sendReaction).toHaveBeenCalledTimes(2); + }); + + it('allows FAILED from any non-terminal state', async () => { + tracker.markReceived('msg1', 'main@s.whatsapp.net', false); + tracker.markFailed('msg1'); + await tracker.flush(); + + const emojis = deps.sendReaction.mock.calls.map((c) => c[2]); + expect(emojis).toEqual(['\u{1F440}', '\u{274C}']); + }); + + it('rejects FAILED after DONE', async () => { + tracker.markReceived('msg1', 'main@s.whatsapp.net', false); + tracker.markDone('msg1'); + + const result = tracker.markFailed('msg1'); + expect(result).toBe(false); + + await tracker.flush(); + expect(deps.sendReaction).toHaveBeenCalledTimes(2); + }); + }); + + describe('main group gating', () => { + it('ignores messages from non-main groups', async () => { + tracker.markReceived('msg1', 'group@g.us', false); + await tracker.flush(); + expect(deps.sendReaction).not.toHaveBeenCalled(); + }); + }); + + describe('duplicate tracking', () => { + it('rejects duplicate markReceived for same messageId', async () => { + const first = tracker.markReceived('msg1', 'main@s.whatsapp.net', false); + const second = tracker.markReceived('msg1', 'main@s.whatsapp.net', false); + + expect(first).toBe(true); + expect(second).toBe(false); + + await tracker.flush(); + expect(deps.sendReaction).toHaveBeenCalledTimes(1); + }); + }); + + describe('unknown message handling', () => { + it('returns false for transitions on untracked messages', () => { + expect(tracker.markThinking('unknown')).toBe(false); + expect(tracker.markWorking('unknown')).toBe(false); + expect(tracker.markDone('unknown')).toBe(false); + expect(tracker.markFailed('unknown')).toBe(false); + }); + }); + + describe('batch operations', () => { + it('markAllDone transitions all tracked messages for a chatJid', async () => { + tracker.markReceived('msg1', 'main@s.whatsapp.net', false); + tracker.markReceived('msg2', 'main@s.whatsapp.net', false); + tracker.markAllDone('main@s.whatsapp.net'); + await tracker.flush(); + + const doneCalls = deps.sendReaction.mock.calls.filter((c) => c[2] === '\u{2705}'); + expect(doneCalls).toHaveLength(2); + }); + + it('markAllFailed transitions all tracked messages and sends error message', async () => { + tracker.markReceived('msg1', 'main@s.whatsapp.net', false); + tracker.markReceived('msg2', 'main@s.whatsapp.net', false); + tracker.markAllFailed('main@s.whatsapp.net', 'Task crashed'); + await tracker.flush(); + + const failCalls = deps.sendReaction.mock.calls.filter((c) => c[2] === '\u{274C}'); + expect(failCalls).toHaveLength(2); + expect(deps.sendMessage).toHaveBeenCalledWith('main@s.whatsapp.net', '[system] Task crashed'); + }); + }); + + describe('serialized sends', () => { + it('sends reactions in order even when transitions are rapid', async () => { + const order: string[] = []; + deps.sendReaction.mockImplementation(async (_jid, _key, emoji) => { + await new Promise((r) => setTimeout(r, Math.random() * 10)); + order.push(emoji); + }); + + tracker.markReceived('msg1', 'main@s.whatsapp.net', false); + tracker.markThinking('msg1'); + tracker.markWorking('msg1'); + tracker.markDone('msg1'); + + await tracker.flush(); + expect(order).toEqual(['\u{1F440}', '\u{1F4AD}', '\u{1F504}', '\u{2705}']); + }); + }); + + describe('recover', () => { + it('marks orphaned non-terminal entries as failed and sends error message', async () => { + const fs = await import('fs'); + const persisted = JSON.stringify([ + { messageId: 'orphan1', chatJid: 'main@s.whatsapp.net', fromMe: false, state: 0, terminal: null, trackedAt: 1000 }, + { messageId: 'orphan2', chatJid: 'main@s.whatsapp.net', fromMe: false, state: 2, terminal: null, trackedAt: 2000 }, + { messageId: 'done1', chatJid: 'main@s.whatsapp.net', fromMe: false, state: 3, terminal: 'done', trackedAt: 3000 }, + ]); + (fs.default.existsSync as ReturnType).mockReturnValue(true); + (fs.default.readFileSync as ReturnType).mockReturnValue(persisted); + + await tracker.recover(); + + // Should send ❌ reaction for the 2 non-terminal entries only + const failCalls = deps.sendReaction.mock.calls.filter((c) => c[2] === '❌'); + expect(failCalls).toHaveLength(2); + + // Should send one error message per chatJid + expect(deps.sendMessage).toHaveBeenCalledWith( + 'main@s.whatsapp.net', + '[system] Restarted — reprocessing your message.', + ); + expect(deps.sendMessage).toHaveBeenCalledTimes(1); + }); + + it('handles missing persistence file gracefully', async () => { + const fs = await import('fs'); + (fs.default.existsSync as ReturnType).mockReturnValue(false); + + await tracker.recover(); // should not throw + expect(deps.sendReaction).not.toHaveBeenCalled(); + }); + + it('skips error message when sendErrorMessage is false', async () => { + const fs = await import('fs'); + const persisted = JSON.stringify([ + { messageId: 'orphan1', chatJid: 'main@s.whatsapp.net', fromMe: false, state: 1, terminal: null, trackedAt: 1000 }, + ]); + (fs.default.existsSync as ReturnType).mockReturnValue(true); + (fs.default.readFileSync as ReturnType).mockReturnValue(persisted); + + await tracker.recover(false); + + // Still sends ❌ reaction + expect(deps.sendReaction).toHaveBeenCalledTimes(1); + expect(deps.sendReaction.mock.calls[0][2]).toBe('❌'); + // But no text message + expect(deps.sendMessage).not.toHaveBeenCalled(); + }); + }); + + describe('heartbeatCheck', () => { + it('marks messages as failed when container is dead', async () => { + deps.isContainerAlive.mockReturnValue(false); + tracker.markReceived('msg1', 'main@s.whatsapp.net', false); + tracker.markThinking('msg1'); + + tracker.heartbeatCheck(); + await tracker.flush(); + + const failCalls = deps.sendReaction.mock.calls.filter((c) => c[2] === '❌'); + expect(failCalls).toHaveLength(1); + expect(deps.sendMessage).toHaveBeenCalledWith( + 'main@s.whatsapp.net', + '[system] Task crashed — retrying.', + ); + }); + + it('does nothing when container is alive', async () => { + deps.isContainerAlive.mockReturnValue(true); + tracker.markReceived('msg1', 'main@s.whatsapp.net', false); + tracker.markThinking('msg1'); + + tracker.heartbeatCheck(); + await tracker.flush(); + + // Only the 👀 and 💭 reactions, no ❌ + expect(deps.sendReaction).toHaveBeenCalledTimes(2); + const emojis = deps.sendReaction.mock.calls.map((c) => c[2]); + expect(emojis).toEqual(['👀', '💭']); + }); + + it('skips RECEIVED messages within grace period even if container is dead', async () => { + vi.useFakeTimers(); + deps.isContainerAlive.mockReturnValue(false); + tracker.markReceived('msg1', 'main@s.whatsapp.net', false); + + // Only 10s elapsed — within 30s grace period + vi.advanceTimersByTime(10_000); + tracker.heartbeatCheck(); + await tracker.flush(); + + // Only the 👀 reaction, no ❌ + expect(deps.sendReaction).toHaveBeenCalledTimes(1); + expect(deps.sendReaction.mock.calls[0][2]).toBe('👀'); + }); + + it('fails RECEIVED messages after grace period when container is dead', async () => { + vi.useFakeTimers(); + deps.isContainerAlive.mockReturnValue(false); + tracker.markReceived('msg1', 'main@s.whatsapp.net', false); + + // 31s elapsed — past 30s grace period + vi.advanceTimersByTime(31_000); + tracker.heartbeatCheck(); + await tracker.flush(); + + const failCalls = deps.sendReaction.mock.calls.filter((c) => c[2] === '❌'); + expect(failCalls).toHaveLength(1); + expect(deps.sendMessage).toHaveBeenCalledWith( + 'main@s.whatsapp.net', + '[system] Task crashed — retrying.', + ); + }); + + it('does NOT fail RECEIVED messages after grace period when container is alive', async () => { + vi.useFakeTimers(); + deps.isContainerAlive.mockReturnValue(true); + tracker.markReceived('msg1', 'main@s.whatsapp.net', false); + + // 31s elapsed but container is alive — don't fail + vi.advanceTimersByTime(31_000); + tracker.heartbeatCheck(); + await tracker.flush(); + + expect(deps.sendReaction).toHaveBeenCalledTimes(1); + expect(deps.sendReaction.mock.calls[0][2]).toBe('👀'); + }); + + it('detects stuck messages beyond timeout', async () => { + vi.useFakeTimers(); + deps.isContainerAlive.mockReturnValue(true); // container "alive" but hung + + tracker.markReceived('msg1', 'main@s.whatsapp.net', false); + tracker.markThinking('msg1'); + + // Advance time beyond container timeout (default 1800000ms = 30min) + vi.advanceTimersByTime(1_800_001); + + tracker.heartbeatCheck(); + await tracker.flush(); + + const failCalls = deps.sendReaction.mock.calls.filter((c) => c[2] === '❌'); + expect(failCalls).toHaveLength(1); + expect(deps.sendMessage).toHaveBeenCalledWith( + 'main@s.whatsapp.net', + '[system] Task timed out — retrying.', + ); + }); + + it('does not timeout messages queued long in RECEIVED before reaching THINKING', async () => { + vi.useFakeTimers(); + deps.isContainerAlive.mockReturnValue(true); + + tracker.markReceived('msg1', 'main@s.whatsapp.net', false); + + // Message sits in RECEIVED for longer than CONTAINER_TIMEOUT (queued, waiting for slot) + vi.advanceTimersByTime(2_000_000); + + // Now container starts — trackedAt resets on THINKING transition + tracker.markThinking('msg1'); + + // Check immediately — should NOT timeout (trackedAt was just reset) + tracker.heartbeatCheck(); + await tracker.flush(); + + const failCalls = deps.sendReaction.mock.calls.filter((c) => c[2] === '❌'); + expect(failCalls).toHaveLength(0); + + // Advance past CONTAINER_TIMEOUT from THINKING — NOW it should timeout + vi.advanceTimersByTime(1_800_001); + + tracker.heartbeatCheck(); + await tracker.flush(); + + const failCallsAfter = deps.sendReaction.mock.calls.filter((c) => c[2] === '❌'); + expect(failCallsAfter).toHaveLength(1); + }); + }); + + describe('cleanup', () => { + it('removes terminal messages after delay', async () => { + vi.useFakeTimers(); + tracker.markReceived('msg1', 'main@s.whatsapp.net', false); + tracker.markDone('msg1'); + + // Message should still be tracked + expect(tracker.isTracked('msg1')).toBe(true); + + // Advance past cleanup delay + vi.advanceTimersByTime(6000); + + expect(tracker.isTracked('msg1')).toBe(false); + }); + }); + + describe('reaction retry', () => { + it('retries failed sends with exponential backoff (2s, 4s)', async () => { + vi.useFakeTimers(); + let callCount = 0; + deps.sendReaction.mockImplementation(async () => { + callCount++; + if (callCount <= 2) throw new Error('network error'); + }); + + tracker.markReceived('msg1', 'main@s.whatsapp.net', false); + + // First attempt fires immediately + await vi.advanceTimersByTimeAsync(0); + expect(callCount).toBe(1); + + // After 2s: second attempt (first retry delay = 2s) + await vi.advanceTimersByTimeAsync(2000); + expect(callCount).toBe(2); + + // After 1s more (3s total): still waiting for 4s delay + await vi.advanceTimersByTimeAsync(1000); + expect(callCount).toBe(2); + + // After 3s more (6s total): third attempt fires (second retry delay = 4s) + await vi.advanceTimersByTimeAsync(3000); + expect(callCount).toBe(3); + + await tracker.flush(); + }); + + it('gives up after max retries', async () => { + vi.useFakeTimers(); + let callCount = 0; + deps.sendReaction.mockImplementation(async () => { + callCount++; + throw new Error('permanent failure'); + }); + + tracker.markReceived('msg1', 'main@s.whatsapp.net', false); + + await vi.advanceTimersByTimeAsync(10_000); + await tracker.flush(); + + expect(callCount).toBe(3); // MAX_RETRIES = 3 + }); + }); + + describe('batch transitions', () => { + it('markThinking can be called on multiple messages independently', async () => { + tracker.markReceived('msg1', 'main@s.whatsapp.net', false); + tracker.markReceived('msg2', 'main@s.whatsapp.net', false); + tracker.markReceived('msg3', 'main@s.whatsapp.net', false); + + // Mark all as thinking (simulates batch behavior) + tracker.markThinking('msg1'); + tracker.markThinking('msg2'); + tracker.markThinking('msg3'); + + await tracker.flush(); + + const thinkingCalls = deps.sendReaction.mock.calls.filter((c) => c[2] === '💭'); + expect(thinkingCalls).toHaveLength(3); + }); + + it('markWorking can be called on multiple messages independently', async () => { + tracker.markReceived('msg1', 'main@s.whatsapp.net', false); + tracker.markReceived('msg2', 'main@s.whatsapp.net', false); + tracker.markThinking('msg1'); + tracker.markThinking('msg2'); + + tracker.markWorking('msg1'); + tracker.markWorking('msg2'); + + await tracker.flush(); + + const workingCalls = deps.sendReaction.mock.calls.filter((c) => c[2] === '🔄'); + expect(workingCalls).toHaveLength(2); + }); + }); +}); diff --git a/.claude/skills/add-reactions/add/src/status-tracker.ts b/.claude/skills/add-reactions/add/src/status-tracker.ts new file mode 100644 index 0000000..3753264 --- /dev/null +++ b/.claude/skills/add-reactions/add/src/status-tracker.ts @@ -0,0 +1,324 @@ +import fs from 'fs'; +import path from 'path'; + +import { DATA_DIR, CONTAINER_TIMEOUT } from './config.js'; +import { logger } from './logger.js'; + +// DONE and FAILED share value 3: both are terminal states with monotonic +// forward-only transitions (state >= current). The emoji differs but the +// ordering logic treats them identically. +export enum StatusState { + RECEIVED = 0, + THINKING = 1, + WORKING = 2, + DONE = 3, + FAILED = 3, +} + +const DONE_EMOJI = '\u{2705}'; +const FAILED_EMOJI = '\u{274C}'; + +const CLEANUP_DELAY_MS = 5000; +const RECEIVED_GRACE_MS = 30_000; +const REACTION_MAX_RETRIES = 3; +const REACTION_BASE_DELAY_MS = 2000; + +interface MessageKey { + id: string; + remoteJid: string; + fromMe?: boolean; +} + +interface TrackedMessage { + messageId: string; + chatJid: string; + fromMe: boolean; + state: number; + terminal: 'done' | 'failed' | null; + sendChain: Promise; + trackedAt: number; +} + +interface PersistedEntry { + messageId: string; + chatJid: string; + fromMe: boolean; + state: number; + terminal: 'done' | 'failed' | null; + trackedAt: number; +} + +export interface StatusTrackerDeps { + sendReaction: ( + chatJid: string, + messageKey: MessageKey, + emoji: string, + ) => Promise; + sendMessage: (chatJid: string, text: string) => Promise; + isMainGroup: (chatJid: string) => boolean; + isContainerAlive: (chatJid: string) => boolean; +} + +export class StatusTracker { + private tracked = new Map(); + private deps: StatusTrackerDeps; + private persistPath: string; + private _shuttingDown = false; + + constructor(deps: StatusTrackerDeps) { + this.deps = deps; + this.persistPath = path.join(DATA_DIR, 'status-tracker.json'); + } + + markReceived(messageId: string, chatJid: string, fromMe: boolean): boolean { + if (!this.deps.isMainGroup(chatJid)) return false; + if (this.tracked.has(messageId)) return false; + + const msg: TrackedMessage = { + messageId, + chatJid, + fromMe, + state: StatusState.RECEIVED, + terminal: null, + sendChain: Promise.resolve(), + trackedAt: Date.now(), + }; + + this.tracked.set(messageId, msg); + this.enqueueSend(msg, '\u{1F440}'); + this.persist(); + return true; + } + + markThinking(messageId: string): boolean { + return this.transition(messageId, StatusState.THINKING, '\u{1F4AD}'); + } + + markWorking(messageId: string): boolean { + return this.transition(messageId, StatusState.WORKING, '\u{1F504}'); + } + + markDone(messageId: string): boolean { + return this.transitionTerminal(messageId, 'done', DONE_EMOJI); + } + + markFailed(messageId: string): boolean { + return this.transitionTerminal(messageId, 'failed', FAILED_EMOJI); + } + + markAllDone(chatJid: string): void { + for (const [id, msg] of this.tracked) { + if (msg.chatJid === chatJid && msg.terminal === null) { + this.transitionTerminal(id, 'done', DONE_EMOJI); + } + } + } + + markAllFailed(chatJid: string, errorMessage: string): void { + let anyFailed = false; + for (const [id, msg] of this.tracked) { + if (msg.chatJid === chatJid && msg.terminal === null) { + this.transitionTerminal(id, 'failed', FAILED_EMOJI); + anyFailed = true; + } + } + if (anyFailed) { + this.deps.sendMessage(chatJid, `[system] ${errorMessage}`).catch((err) => + logger.error({ chatJid, err }, 'Failed to send status error message'), + ); + } + } + + isTracked(messageId: string): boolean { + return this.tracked.has(messageId); + } + + /** Wait for all pending reaction sends to complete. */ + async flush(): Promise { + const chains = Array.from(this.tracked.values()).map((m) => m.sendChain); + await Promise.allSettled(chains); + } + + /** Signal shutdown and flush. Prevents new retry sleeps so flush resolves quickly. */ + async shutdown(): Promise { + this._shuttingDown = true; + await this.flush(); + } + + /** + * Startup recovery: read persisted state and mark all non-terminal entries as failed. + * Call this before the message loop starts. + */ + async recover(sendErrorMessage: boolean = true): Promise { + let entries: PersistedEntry[] = []; + try { + if (fs.existsSync(this.persistPath)) { + const raw = fs.readFileSync(this.persistPath, 'utf-8'); + entries = JSON.parse(raw); + } + } catch (err) { + logger.warn({ err }, 'Failed to read status tracker persistence file'); + return; + } + + const orphanedByChat = new Map(); + for (const entry of entries) { + if (entry.terminal !== null) continue; + + // Reconstruct tracked message for the reaction send + const msg: TrackedMessage = { + messageId: entry.messageId, + chatJid: entry.chatJid, + fromMe: entry.fromMe, + state: entry.state, + terminal: null, + sendChain: Promise.resolve(), + trackedAt: entry.trackedAt, + }; + this.tracked.set(entry.messageId, msg); + this.transitionTerminal(entry.messageId, 'failed', FAILED_EMOJI); + orphanedByChat.set(entry.chatJid, (orphanedByChat.get(entry.chatJid) || 0) + 1); + } + + if (sendErrorMessage) { + for (const [chatJid] of orphanedByChat) { + this.deps.sendMessage( + chatJid, + `[system] Restarted \u{2014} reprocessing your message.`, + ).catch((err) => + logger.error({ chatJid, err }, 'Failed to send recovery message'), + ); + } + } + + await this.flush(); + this.clearPersistence(); + logger.info({ recoveredCount: entries.filter((e) => e.terminal === null).length }, 'Status tracker recovery complete'); + } + + /** + * Heartbeat: check for stale tracked messages where container has died. + * Call this from the IPC poll cycle. + */ + heartbeatCheck(): void { + const now = Date.now(); + for (const [id, msg] of this.tracked) { + if (msg.terminal !== null) continue; + + // For RECEIVED messages, only fail if container is dead AND grace period elapsed. + // This closes the gap where a container dies before advancing to THINKING. + if (msg.state < StatusState.THINKING) { + if (!this.deps.isContainerAlive(msg.chatJid) && now - msg.trackedAt > RECEIVED_GRACE_MS) { + logger.warn({ messageId: id, chatJid: msg.chatJid, age: now - msg.trackedAt }, 'Heartbeat: RECEIVED message stuck with dead container'); + this.markAllFailed(msg.chatJid, 'Task crashed \u{2014} retrying.'); + return; // Safe for main-chat-only scope. If expanded to multiple chats, loop instead of return. + } + continue; + } + + if (!this.deps.isContainerAlive(msg.chatJid)) { + logger.warn({ messageId: id, chatJid: msg.chatJid }, 'Heartbeat: container dead, marking failed'); + this.markAllFailed(msg.chatJid, 'Task crashed \u{2014} retrying.'); + return; // Safe for main-chat-only scope. If expanded to multiple chats, loop instead of return. + } + + if (now - msg.trackedAt > CONTAINER_TIMEOUT) { + logger.warn({ messageId: id, chatJid: msg.chatJid, age: now - msg.trackedAt }, 'Heartbeat: message stuck beyond timeout'); + this.markAllFailed(msg.chatJid, 'Task timed out \u{2014} retrying.'); + return; // See above re: single-chat scope. + } + } + } + + private transition(messageId: string, newState: number, emoji: string): boolean { + const msg = this.tracked.get(messageId); + if (!msg) return false; + if (msg.terminal !== null) return false; + if (newState <= msg.state) return false; + + msg.state = newState; + // Reset trackedAt on THINKING so heartbeat timeout measures from container start, not message receipt + if (newState === StatusState.THINKING) { + msg.trackedAt = Date.now(); + } + this.enqueueSend(msg, emoji); + this.persist(); + return true; + } + + private transitionTerminal(messageId: string, terminal: 'done' | 'failed', emoji: string): boolean { + const msg = this.tracked.get(messageId); + if (!msg) return false; + if (msg.terminal !== null) return false; + + msg.state = StatusState.DONE; // DONE and FAILED both = 3 + msg.terminal = terminal; + this.enqueueSend(msg, emoji); + this.persist(); + this.scheduleCleanup(messageId); + return true; + } + + private enqueueSend(msg: TrackedMessage, emoji: string): void { + const key: MessageKey = { + id: msg.messageId, + remoteJid: msg.chatJid, + fromMe: msg.fromMe, + }; + msg.sendChain = msg.sendChain.then(async () => { + for (let attempt = 1; attempt <= REACTION_MAX_RETRIES; attempt++) { + try { + await this.deps.sendReaction(msg.chatJid, key, emoji); + return; + } catch (err) { + if (attempt === REACTION_MAX_RETRIES) { + logger.error({ messageId: msg.messageId, emoji, err, attempts: attempt }, 'Failed to send status reaction after retries'); + } else if (this._shuttingDown) { + logger.warn({ messageId: msg.messageId, emoji, attempt, err }, 'Reaction send failed, skipping retry (shutting down)'); + return; + } else { + const delay = REACTION_BASE_DELAY_MS * Math.pow(2, attempt - 1); + logger.warn({ messageId: msg.messageId, emoji, attempt, delay, err }, 'Reaction send failed, retrying'); + await new Promise((r) => setTimeout(r, delay)); + } + } + } + }); + } + + /** Must remain async (setTimeout) — synchronous deletion would break iteration in markAllDone/markAllFailed. */ + private scheduleCleanup(messageId: string): void { + setTimeout(() => { + this.tracked.delete(messageId); + this.persist(); + }, CLEANUP_DELAY_MS); + } + + private persist(): void { + try { + const entries: PersistedEntry[] = []; + for (const msg of this.tracked.values()) { + entries.push({ + messageId: msg.messageId, + chatJid: msg.chatJid, + fromMe: msg.fromMe, + state: msg.state, + terminal: msg.terminal, + trackedAt: msg.trackedAt, + }); + } + fs.mkdirSync(path.dirname(this.persistPath), { recursive: true }); + fs.writeFileSync(this.persistPath, JSON.stringify(entries)); + } catch (err) { + logger.warn({ err }, 'Failed to persist status tracker state'); + } + } + + private clearPersistence(): void { + try { + fs.writeFileSync(this.persistPath, '[]'); + } catch { + // ignore + } + } +} diff --git a/.claude/skills/add-reactions/manifest.yaml b/.claude/skills/add-reactions/manifest.yaml new file mode 100644 index 0000000..e26a419 --- /dev/null +++ b/.claude/skills/add-reactions/manifest.yaml @@ -0,0 +1,23 @@ +skill: reactions +version: 1.0.0 +description: "WhatsApp emoji reaction support with status tracking" +core_version: 0.1.0 +adds: + - scripts/migrate-reactions.ts + - container/skills/reactions/SKILL.md + - src/status-tracker.ts + - src/status-tracker.test.ts +modifies: + - src/db.ts + - src/db.test.ts + - src/channels/whatsapp.ts + - src/types.ts + - src/ipc.ts + - src/index.ts + - container/agent-runner/src/ipc-mcp-stdio.ts + - src/channels/whatsapp.test.ts + - src/group-queue.test.ts + - src/ipc-auth.test.ts +conflicts: [] +depends: [] +test: "npx tsc --noEmit" diff --git a/.claude/skills/add-reactions/modify/container/agent-runner/src/ipc-mcp-stdio.ts b/.claude/skills/add-reactions/modify/container/agent-runner/src/ipc-mcp-stdio.ts new file mode 100644 index 0000000..042d809 --- /dev/null +++ b/.claude/skills/add-reactions/modify/container/agent-runner/src/ipc-mcp-stdio.ts @@ -0,0 +1,440 @@ +/** + * Stdio MCP Server for NanoClaw + * Standalone process that agent teams subagents can inherit. + * Reads context from environment variables, writes IPC files for the host. + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; +import fs from 'fs'; +import path from 'path'; +import { CronExpressionParser } from 'cron-parser'; + +const IPC_DIR = '/workspace/ipc'; +const MESSAGES_DIR = path.join(IPC_DIR, 'messages'); +const TASKS_DIR = path.join(IPC_DIR, 'tasks'); + +// Context from environment variables (set by the agent runner) +const chatJid = process.env.NANOCLAW_CHAT_JID!; +const groupFolder = process.env.NANOCLAW_GROUP_FOLDER!; +const isMain = process.env.NANOCLAW_IS_MAIN === '1'; + +function writeIpcFile(dir: string, data: object): string { + fs.mkdirSync(dir, { recursive: true }); + + const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`; + const filepath = path.join(dir, filename); + + // Atomic write: temp file then rename + const tempPath = `${filepath}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify(data, null, 2)); + fs.renameSync(tempPath, filepath); + + return filename; +} + +const server = new McpServer({ + name: 'nanoclaw', + version: '1.0.0', +}); + +server.tool( + 'send_message', + "Send a message to the user or group immediately while you're still running. Use this for progress updates or to send multiple messages. You can call this multiple times. Note: when running as a scheduled task, your final output is NOT sent to the user — use this tool if you need to communicate with the user or group.", + { + text: z.string().describe('The message text to send'), + sender: z + .string() + .optional() + .describe( + 'Your role/identity name (e.g. "Researcher"). When set, messages appear from a dedicated bot in Telegram.', + ), + }, + async (args) => { + const data: Record = { + type: 'message', + chatJid, + text: args.text, + sender: args.sender || undefined, + groupFolder, + timestamp: new Date().toISOString(), + }; + + writeIpcFile(MESSAGES_DIR, data); + + return { content: [{ type: 'text' as const, text: 'Message sent.' }] }; + }, +); + +server.tool( + 'react_to_message', + 'React to a message with an emoji. Omit message_id to react to the most recent message in the chat.', + { + emoji: z + .string() + .describe('The emoji to react with (e.g. "👍", "❤️", "🔥")'), + message_id: z + .string() + .optional() + .describe( + 'The message ID to react to. If omitted, reacts to the latest message in the chat.', + ), + }, + async (args) => { + const data: Record = { + type: 'reaction', + chatJid, + emoji: args.emoji, + messageId: args.message_id || undefined, + groupFolder, + timestamp: new Date().toISOString(), + }; + + writeIpcFile(MESSAGES_DIR, data); + + return { + content: [ + { type: 'text' as const, text: `Reaction ${args.emoji} sent.` }, + ], + }; + }, +); + +server.tool( + 'schedule_task', + `Schedule a recurring or one-time task. The task will run as a full agent with access to all tools. + +CONTEXT MODE - Choose based on task type: +\u2022 "group": Task runs in the group's conversation context, with access to chat history. Use for tasks that need context about ongoing discussions, user preferences, or recent interactions. +\u2022 "isolated": Task runs in a fresh session with no conversation history. Use for independent tasks that don't need prior context. When using isolated mode, include all necessary context in the prompt itself. + +If unsure which mode to use, you can ask the user. Examples: +- "Remind me about our discussion" \u2192 group (needs conversation context) +- "Check the weather every morning" \u2192 isolated (self-contained task) +- "Follow up on my request" \u2192 group (needs to know what was requested) +- "Generate a daily report" \u2192 isolated (just needs instructions in prompt) + +MESSAGING BEHAVIOR - The task agent's output is sent to the user or group. It can also use send_message for immediate delivery, or wrap output in tags to suppress it. Include guidance in the prompt about whether the agent should: +\u2022 Always send a message (e.g., reminders, daily briefings) +\u2022 Only send a message when there's something to report (e.g., "notify me if...") +\u2022 Never send a message (background maintenance tasks) + +SCHEDULE VALUE FORMAT (all times are LOCAL timezone): +\u2022 cron: Standard cron expression (e.g., "*/5 * * * *" for every 5 minutes, "0 9 * * *" for daily at 9am LOCAL time) +\u2022 interval: Milliseconds between runs (e.g., "300000" for 5 minutes, "3600000" for 1 hour) +\u2022 once: Local time WITHOUT "Z" suffix (e.g., "2026-02-01T15:30:00"). Do NOT use UTC/Z suffix.`, + { + prompt: z + .string() + .describe( + 'What the agent should do when the task runs. For isolated mode, include all necessary context here.', + ), + schedule_type: z + .enum(['cron', 'interval', 'once']) + .describe( + 'cron=recurring at specific times, interval=recurring every N ms, once=run once at specific time', + ), + schedule_value: z + .string() + .describe( + 'cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)', + ), + context_mode: z + .enum(['group', 'isolated']) + .default('group') + .describe( + 'group=runs with chat history and memory, isolated=fresh session (include context in prompt)', + ), + target_group_jid: z + .string() + .optional() + .describe( + '(Main group only) JID of the group to schedule the task for. Defaults to the current group.', + ), + }, + async (args) => { + // Validate schedule_value before writing IPC + if (args.schedule_type === 'cron') { + try { + CronExpressionParser.parse(args.schedule_value); + } catch { + return { + content: [ + { + type: 'text' as const, + text: `Invalid cron: "${args.schedule_value}". Use format like "0 9 * * *" (daily 9am) or "*/5 * * * *" (every 5 min).`, + }, + ], + isError: true, + }; + } + } else if (args.schedule_type === 'interval') { + const ms = parseInt(args.schedule_value, 10); + if (isNaN(ms) || ms <= 0) { + return { + content: [ + { + type: 'text' as const, + text: `Invalid interval: "${args.schedule_value}". Must be positive milliseconds (e.g., "300000" for 5 min).`, + }, + ], + isError: true, + }; + } + } else if (args.schedule_type === 'once') { + if ( + /[Zz]$/.test(args.schedule_value) || + /[+-]\d{2}:\d{2}$/.test(args.schedule_value) + ) { + return { + content: [ + { + type: 'text' as const, + text: `Timestamp must be local time without timezone suffix. Got "${args.schedule_value}" — use format like "2026-02-01T15:30:00".`, + }, + ], + isError: true, + }; + } + const date = new Date(args.schedule_value); + if (isNaN(date.getTime())) { + return { + content: [ + { + type: 'text' as const, + text: `Invalid timestamp: "${args.schedule_value}". Use local time format like "2026-02-01T15:30:00".`, + }, + ], + isError: true, + }; + } + } + + // Non-main groups can only schedule for themselves + const targetJid = + isMain && args.target_group_jid ? args.target_group_jid : chatJid; + + const data = { + type: 'schedule_task', + prompt: args.prompt, + schedule_type: args.schedule_type, + schedule_value: args.schedule_value, + context_mode: args.context_mode || 'group', + targetJid, + createdBy: groupFolder, + timestamp: new Date().toISOString(), + }; + + const filename = writeIpcFile(TASKS_DIR, data); + + return { + content: [ + { + type: 'text' as const, + text: `Task scheduled (${filename}): ${args.schedule_type} - ${args.schedule_value}`, + }, + ], + }; + }, +); + +server.tool( + 'list_tasks', + "List all scheduled tasks. From main: shows all tasks. From other groups: shows only that group's tasks.", + {}, + async () => { + const tasksFile = path.join(IPC_DIR, 'current_tasks.json'); + + try { + if (!fs.existsSync(tasksFile)) { + return { + content: [ + { type: 'text' as const, text: 'No scheduled tasks found.' }, + ], + }; + } + + const allTasks = JSON.parse(fs.readFileSync(tasksFile, 'utf-8')); + + const tasks = isMain + ? allTasks + : allTasks.filter( + (t: { groupFolder: string }) => t.groupFolder === groupFolder, + ); + + if (tasks.length === 0) { + return { + content: [ + { type: 'text' as const, text: 'No scheduled tasks found.' }, + ], + }; + } + + const formatted = tasks + .map( + (t: { + id: string; + prompt: string; + schedule_type: string; + schedule_value: string; + status: string; + next_run: string; + }) => + `- [${t.id}] ${t.prompt.slice(0, 50)}... (${t.schedule_type}: ${t.schedule_value}) - ${t.status}, next: ${t.next_run || 'N/A'}`, + ) + .join('\n'); + + return { + content: [ + { type: 'text' as const, text: `Scheduled tasks:\n${formatted}` }, + ], + }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Error reading tasks: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + }; + } + }, +); + +server.tool( + 'pause_task', + 'Pause a scheduled task. It will not run until resumed.', + { task_id: z.string().describe('The task ID to pause') }, + async (args) => { + const data = { + type: 'pause_task', + taskId: args.task_id, + groupFolder, + isMain, + timestamp: new Date().toISOString(), + }; + + writeIpcFile(TASKS_DIR, data); + + return { + content: [ + { + type: 'text' as const, + text: `Task ${args.task_id} pause requested.`, + }, + ], + }; + }, +); + +server.tool( + 'resume_task', + 'Resume a paused task.', + { task_id: z.string().describe('The task ID to resume') }, + async (args) => { + const data = { + type: 'resume_task', + taskId: args.task_id, + groupFolder, + isMain, + timestamp: new Date().toISOString(), + }; + + writeIpcFile(TASKS_DIR, data); + + return { + content: [ + { + type: 'text' as const, + text: `Task ${args.task_id} resume requested.`, + }, + ], + }; + }, +); + +server.tool( + 'cancel_task', + 'Cancel and delete a scheduled task.', + { task_id: z.string().describe('The task ID to cancel') }, + async (args) => { + const data = { + type: 'cancel_task', + taskId: args.task_id, + groupFolder, + isMain, + timestamp: new Date().toISOString(), + }; + + writeIpcFile(TASKS_DIR, data); + + return { + content: [ + { + type: 'text' as const, + text: `Task ${args.task_id} cancellation requested.`, + }, + ], + }; + }, +); + +server.tool( + 'register_group', + `Register a new chat/group so the agent can respond to messages there. Main group only. + +Use available_groups.json to find the JID for a group. The folder name must be channel-prefixed: "{channel}_{group-name}" (e.g., "whatsapp_family-chat", "telegram_dev-team", "discord_general"). Use lowercase with hyphens for the group name part.`, + { + jid: z + .string() + .describe( + 'The chat JID (e.g., "120363336345536173@g.us", "tg:-1001234567890", "dc:1234567890123456")', + ), + name: z.string().describe('Display name for the group'), + folder: z + .string() + .describe( + 'Channel-prefixed folder name (e.g., "whatsapp_family-chat", "telegram_dev-team")', + ), + trigger: z.string().describe('Trigger word (e.g., "@Andy")'), + }, + async (args) => { + if (!isMain) { + return { + content: [ + { + type: 'text' as const, + text: 'Only the main group can register new groups.', + }, + ], + isError: true, + }; + } + + const data = { + type: 'register_group', + jid: args.jid, + name: args.name, + folder: args.folder, + trigger: args.trigger, + timestamp: new Date().toISOString(), + }; + + writeIpcFile(TASKS_DIR, data); + + return { + content: [ + { + type: 'text' as const, + text: `Group "${args.name}" registered. It will start receiving messages immediately.`, + }, + ], + }; + }, +); + +// Start the stdio transport +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/.claude/skills/add-reactions/modify/src/channels/whatsapp.test.ts b/.claude/skills/add-reactions/modify/src/channels/whatsapp.test.ts new file mode 100644 index 0000000..f332811 --- /dev/null +++ b/.claude/skills/add-reactions/modify/src/channels/whatsapp.test.ts @@ -0,0 +1,952 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; + +// --- Mocks --- + +// Mock config +vi.mock('../config.js', () => ({ + STORE_DIR: '/tmp/nanoclaw-test-store', + ASSISTANT_NAME: 'Andy', + ASSISTANT_HAS_OWN_NUMBER: false, +})); + +// Mock logger +vi.mock('../logger.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +// Mock db +vi.mock('../db.js', () => ({ + getLastGroupSync: vi.fn(() => null), + getLatestMessage: vi.fn(() => undefined), + getMessageFromMe: vi.fn(() => false), + setLastGroupSync: vi.fn(), + storeReaction: vi.fn(), + updateChatName: vi.fn(), +})); + +// Mock fs +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { + ...actual, + default: { + ...actual, + existsSync: vi.fn(() => true), + mkdirSync: vi.fn(), + }, + }; +}); + +// Mock child_process (used for osascript notification) +vi.mock('child_process', () => ({ + exec: vi.fn(), +})); + +// Build a fake WASocket that's an EventEmitter with the methods we need +function createFakeSocket() { + const ev = new EventEmitter(); + const sock = { + ev: { + on: (event: string, handler: (...args: unknown[]) => void) => { + ev.on(event, handler); + }, + }, + user: { + id: '1234567890:1@s.whatsapp.net', + lid: '9876543210:1@lid', + }, + sendMessage: vi.fn().mockResolvedValue(undefined), + sendPresenceUpdate: vi.fn().mockResolvedValue(undefined), + groupFetchAllParticipating: vi.fn().mockResolvedValue({}), + end: vi.fn(), + // Expose the event emitter for triggering events in tests + _ev: ev, + }; + return sock; +} + +let fakeSocket: ReturnType; + +// Mock Baileys +vi.mock('@whiskeysockets/baileys', () => { + return { + default: vi.fn(() => fakeSocket), + Browsers: { macOS: vi.fn(() => ['macOS', 'Chrome', '']) }, + DisconnectReason: { + loggedOut: 401, + badSession: 500, + connectionClosed: 428, + connectionLost: 408, + connectionReplaced: 440, + timedOut: 408, + restartRequired: 515, + }, + fetchLatestWaWebVersion: vi + .fn() + .mockResolvedValue({ version: [2, 3000, 0] }), + makeCacheableSignalKeyStore: vi.fn((keys: unknown) => keys), + useMultiFileAuthState: vi.fn().mockResolvedValue({ + state: { + creds: {}, + keys: {}, + }, + saveCreds: vi.fn(), + }), + }; +}); + +import { WhatsAppChannel, WhatsAppChannelOpts } from './whatsapp.js'; +import { getLastGroupSync, updateChatName, setLastGroupSync } from '../db.js'; + +// --- Test helpers --- + +function createTestOpts( + overrides?: Partial, +): WhatsAppChannelOpts { + return { + onMessage: vi.fn(), + onChatMetadata: vi.fn(), + registeredGroups: vi.fn(() => ({ + 'registered@g.us': { + name: 'Test Group', + folder: 'test-group', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + }, + })), + ...overrides, + }; +} + +function triggerConnection(state: string, extra?: Record) { + fakeSocket._ev.emit('connection.update', { connection: state, ...extra }); +} + +function triggerDisconnect(statusCode: number) { + fakeSocket._ev.emit('connection.update', { + connection: 'close', + lastDisconnect: { + error: { output: { statusCode } }, + }, + }); +} + +async function triggerMessages(messages: unknown[]) { + fakeSocket._ev.emit('messages.upsert', { messages }); + // Flush microtasks so the async messages.upsert handler completes + await new Promise((r) => setTimeout(r, 0)); +} + +// --- Tests --- + +describe('WhatsAppChannel', () => { + beforeEach(() => { + fakeSocket = createFakeSocket(); + vi.mocked(getLastGroupSync).mockReturnValue(null); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + /** + * Helper: start connect, flush microtasks so event handlers are registered, + * then trigger the connection open event. Returns the resolved promise. + */ + async function connectChannel(channel: WhatsAppChannel): Promise { + const p = channel.connect(); + // Flush microtasks so connectInternal completes its await and registers handlers + await new Promise((r) => setTimeout(r, 0)); + triggerConnection('open'); + return p; + } + + // --- Version fetch --- + + describe('version fetch', () => { + it('connects with fetched version', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + await connectChannel(channel); + + const { fetchLatestWaWebVersion } = + await import('@whiskeysockets/baileys'); + expect(fetchLatestWaWebVersion).toHaveBeenCalledWith({}); + }); + + it('falls back gracefully when version fetch fails', async () => { + const { fetchLatestWaWebVersion } = + await import('@whiskeysockets/baileys'); + vi.mocked(fetchLatestWaWebVersion).mockRejectedValueOnce( + new Error('network error'), + ); + + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + await connectChannel(channel); + + // Should still connect successfully despite fetch failure + expect(channel.isConnected()).toBe(true); + }); + }); + + // --- Connection lifecycle --- + + describe('connection lifecycle', () => { + it('resolves connect() when connection opens', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + expect(channel.isConnected()).toBe(true); + }); + + it('sets up LID to phone mapping on open', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + // The channel should have mapped the LID from sock.user + // We can verify by sending a message from a LID JID + // and checking the translated JID in the callback + }); + + it('flushes outgoing queue on reconnect', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + // Disconnect + (channel as any).connected = false; + + // Queue a message while disconnected + await channel.sendMessage('test@g.us', 'Queued message'); + expect(fakeSocket.sendMessage).not.toHaveBeenCalled(); + + // Reconnect + (channel as any).connected = true; + await (channel as any).flushOutgoingQueue(); + + // Group messages get prefixed when flushed + expect(fakeSocket.sendMessage).toHaveBeenCalledWith('test@g.us', { + text: 'Andy: Queued message', + }); + }); + + it('disconnects cleanly', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await channel.disconnect(); + expect(channel.isConnected()).toBe(false); + expect(fakeSocket.end).toHaveBeenCalled(); + }); + }); + + // --- QR code and auth --- + + describe('authentication', () => { + it('exits process when QR code is emitted (no auth state)', async () => { + vi.useFakeTimers(); + const mockExit = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + // Start connect but don't await (it won't resolve - process exits) + channel.connect().catch(() => {}); + + // Flush microtasks so connectInternal registers handlers + await vi.advanceTimersByTimeAsync(0); + + // Emit QR code event + fakeSocket._ev.emit('connection.update', { qr: 'some-qr-data' }); + + // Advance timer past the 1000ms setTimeout before exit + await vi.advanceTimersByTimeAsync(1500); + + expect(mockExit).toHaveBeenCalledWith(1); + mockExit.mockRestore(); + vi.useRealTimers(); + }); + }); + + // --- Reconnection behavior --- + + describe('reconnection', () => { + it('reconnects on non-loggedOut disconnect', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + expect(channel.isConnected()).toBe(true); + + // Disconnect with a non-loggedOut reason (e.g., connectionClosed = 428) + triggerDisconnect(428); + + expect(channel.isConnected()).toBe(false); + // The channel should attempt to reconnect (calls connectInternal again) + }); + + it('exits on loggedOut disconnect', async () => { + const mockExit = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + // Disconnect with loggedOut reason (401) + triggerDisconnect(401); + + expect(channel.isConnected()).toBe(false); + expect(mockExit).toHaveBeenCalledWith(0); + mockExit.mockRestore(); + }); + + it('retries reconnection after 5s on failure', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + // Disconnect with stream error 515 + triggerDisconnect(515); + + // The channel sets a 5s retry — just verify it doesn't crash + await new Promise((r) => setTimeout(r, 100)); + }); + }); + + // --- Message handling --- + + describe('message handling', () => { + it('delivers message for registered group', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await triggerMessages([ + { + key: { + id: 'msg-1', + remoteJid: 'registered@g.us', + participant: '5551234@s.whatsapp.net', + fromMe: false, + }, + message: { conversation: 'Hello Andy' }, + pushName: 'Alice', + messageTimestamp: Math.floor(Date.now() / 1000), + }, + ]); + + expect(opts.onChatMetadata).toHaveBeenCalledWith( + 'registered@g.us', + expect.any(String), + undefined, + 'whatsapp', + true, + ); + expect(opts.onMessage).toHaveBeenCalledWith( + 'registered@g.us', + expect.objectContaining({ + id: 'msg-1', + content: 'Hello Andy', + sender_name: 'Alice', + is_from_me: false, + }), + ); + }); + + it('only emits metadata for unregistered groups', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await triggerMessages([ + { + key: { + id: 'msg-2', + remoteJid: 'unregistered@g.us', + participant: '5551234@s.whatsapp.net', + fromMe: false, + }, + message: { conversation: 'Hello' }, + pushName: 'Bob', + messageTimestamp: Math.floor(Date.now() / 1000), + }, + ]); + + expect(opts.onChatMetadata).toHaveBeenCalledWith( + 'unregistered@g.us', + expect.any(String), + undefined, + 'whatsapp', + true, + ); + expect(opts.onMessage).not.toHaveBeenCalled(); + }); + + it('ignores status@broadcast messages', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await triggerMessages([ + { + key: { + id: 'msg-3', + remoteJid: 'status@broadcast', + fromMe: false, + }, + message: { conversation: 'Status update' }, + messageTimestamp: Math.floor(Date.now() / 1000), + }, + ]); + + expect(opts.onChatMetadata).not.toHaveBeenCalled(); + expect(opts.onMessage).not.toHaveBeenCalled(); + }); + + it('ignores messages with no content', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await triggerMessages([ + { + key: { + id: 'msg-4', + remoteJid: 'registered@g.us', + fromMe: false, + }, + message: null, + messageTimestamp: Math.floor(Date.now() / 1000), + }, + ]); + + expect(opts.onMessage).not.toHaveBeenCalled(); + }); + + it('extracts text from extendedTextMessage', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await triggerMessages([ + { + key: { + id: 'msg-5', + remoteJid: 'registered@g.us', + participant: '5551234@s.whatsapp.net', + fromMe: false, + }, + message: { + extendedTextMessage: { text: 'A reply message' }, + }, + pushName: 'Charlie', + messageTimestamp: Math.floor(Date.now() / 1000), + }, + ]); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'registered@g.us', + expect.objectContaining({ content: 'A reply message' }), + ); + }); + + it('extracts caption from imageMessage', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await triggerMessages([ + { + key: { + id: 'msg-6', + remoteJid: 'registered@g.us', + participant: '5551234@s.whatsapp.net', + fromMe: false, + }, + message: { + imageMessage: { + caption: 'Check this photo', + mimetype: 'image/jpeg', + }, + }, + pushName: 'Diana', + messageTimestamp: Math.floor(Date.now() / 1000), + }, + ]); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'registered@g.us', + expect.objectContaining({ content: 'Check this photo' }), + ); + }); + + it('extracts caption from videoMessage', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await triggerMessages([ + { + key: { + id: 'msg-7', + remoteJid: 'registered@g.us', + participant: '5551234@s.whatsapp.net', + fromMe: false, + }, + message: { + videoMessage: { caption: 'Watch this', mimetype: 'video/mp4' }, + }, + pushName: 'Eve', + messageTimestamp: Math.floor(Date.now() / 1000), + }, + ]); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'registered@g.us', + expect.objectContaining({ content: 'Watch this' }), + ); + }); + + it('handles message with no extractable text (e.g. voice note without caption)', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await triggerMessages([ + { + key: { + id: 'msg-8', + remoteJid: 'registered@g.us', + participant: '5551234@s.whatsapp.net', + fromMe: false, + }, + message: { + audioMessage: { mimetype: 'audio/ogg; codecs=opus', ptt: true }, + }, + pushName: 'Frank', + messageTimestamp: Math.floor(Date.now() / 1000), + }, + ]); + + // Skipped — no text content to process + expect(opts.onMessage).not.toHaveBeenCalled(); + }); + + it('uses sender JID when pushName is absent', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await triggerMessages([ + { + key: { + id: 'msg-9', + remoteJid: 'registered@g.us', + participant: '5551234@s.whatsapp.net', + fromMe: false, + }, + message: { conversation: 'No push name' }, + // pushName is undefined + messageTimestamp: Math.floor(Date.now() / 1000), + }, + ]); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'registered@g.us', + expect.objectContaining({ sender_name: '5551234' }), + ); + }); + }); + + // --- LID ↔ JID translation --- + + describe('LID to JID translation', () => { + it('translates known LID to phone JID', async () => { + const opts = createTestOpts({ + registeredGroups: vi.fn(() => ({ + '1234567890@s.whatsapp.net': { + name: 'Self Chat', + folder: 'self-chat', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + }, + })), + }); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + // The socket has lid '9876543210:1@lid' → phone '1234567890@s.whatsapp.net' + // Send a message from the LID + await triggerMessages([ + { + key: { + id: 'msg-lid', + remoteJid: '9876543210@lid', + fromMe: false, + }, + message: { conversation: 'From LID' }, + pushName: 'Self', + messageTimestamp: Math.floor(Date.now() / 1000), + }, + ]); + + // Should be translated to phone JID + expect(opts.onChatMetadata).toHaveBeenCalledWith( + '1234567890@s.whatsapp.net', + expect.any(String), + undefined, + 'whatsapp', + false, + ); + }); + + it('passes through non-LID JIDs unchanged', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await triggerMessages([ + { + key: { + id: 'msg-normal', + remoteJid: 'registered@g.us', + participant: '5551234@s.whatsapp.net', + fromMe: false, + }, + message: { conversation: 'Normal JID' }, + pushName: 'Grace', + messageTimestamp: Math.floor(Date.now() / 1000), + }, + ]); + + expect(opts.onChatMetadata).toHaveBeenCalledWith( + 'registered@g.us', + expect.any(String), + undefined, + 'whatsapp', + true, + ); + }); + + it('passes through unknown LID JIDs unchanged', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await triggerMessages([ + { + key: { + id: 'msg-unknown-lid', + remoteJid: '0000000000@lid', + fromMe: false, + }, + message: { conversation: 'Unknown LID' }, + pushName: 'Unknown', + messageTimestamp: Math.floor(Date.now() / 1000), + }, + ]); + + // Unknown LID passes through unchanged + expect(opts.onChatMetadata).toHaveBeenCalledWith( + '0000000000@lid', + expect.any(String), + undefined, + 'whatsapp', + false, + ); + }); + }); + + // --- Outgoing message queue --- + + describe('outgoing message queue', () => { + it('sends message directly when connected', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await channel.sendMessage('test@g.us', 'Hello'); + // Group messages get prefixed with assistant name + expect(fakeSocket.sendMessage).toHaveBeenCalledWith('test@g.us', { + text: 'Andy: Hello', + }); + }); + + it('prefixes direct chat messages on shared number', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await channel.sendMessage('123@s.whatsapp.net', 'Hello'); + // Shared number: DMs also get prefixed (needed for self-chat distinction) + expect(fakeSocket.sendMessage).toHaveBeenCalledWith( + '123@s.whatsapp.net', + { text: 'Andy: Hello' }, + ); + }); + + it('queues message when disconnected', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + // Don't connect — channel starts disconnected + await channel.sendMessage('test@g.us', 'Queued'); + expect(fakeSocket.sendMessage).not.toHaveBeenCalled(); + }); + + it('queues message on send failure', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + // Make sendMessage fail + fakeSocket.sendMessage.mockRejectedValueOnce(new Error('Network error')); + + await channel.sendMessage('test@g.us', 'Will fail'); + + // Should not throw, message queued for retry + // The queue should have the message + }); + + it('flushes multiple queued messages in order', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + // Queue messages while disconnected + await channel.sendMessage('test@g.us', 'First'); + await channel.sendMessage('test@g.us', 'Second'); + await channel.sendMessage('test@g.us', 'Third'); + + // Connect — flush happens automatically on open + await connectChannel(channel); + + // Give the async flush time to complete + await new Promise((r) => setTimeout(r, 50)); + + expect(fakeSocket.sendMessage).toHaveBeenCalledTimes(3); + // Group messages get prefixed + expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(1, 'test@g.us', { + text: 'Andy: First', + }); + expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(2, 'test@g.us', { + text: 'Andy: Second', + }); + expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(3, 'test@g.us', { + text: 'Andy: Third', + }); + }); + }); + + // --- Group metadata sync --- + + describe('group metadata sync', () => { + it('syncs group metadata on first connection', async () => { + fakeSocket.groupFetchAllParticipating.mockResolvedValue({ + 'group1@g.us': { subject: 'Group One' }, + 'group2@g.us': { subject: 'Group Two' }, + }); + + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + // Wait for async sync to complete + await new Promise((r) => setTimeout(r, 50)); + + expect(fakeSocket.groupFetchAllParticipating).toHaveBeenCalled(); + expect(updateChatName).toHaveBeenCalledWith('group1@g.us', 'Group One'); + expect(updateChatName).toHaveBeenCalledWith('group2@g.us', 'Group Two'); + expect(setLastGroupSync).toHaveBeenCalled(); + }); + + it('skips sync when synced recently', async () => { + // Last sync was 1 hour ago (within 24h threshold) + vi.mocked(getLastGroupSync).mockReturnValue( + new Date(Date.now() - 60 * 60 * 1000).toISOString(), + ); + + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await new Promise((r) => setTimeout(r, 50)); + + expect(fakeSocket.groupFetchAllParticipating).not.toHaveBeenCalled(); + }); + + it('forces sync regardless of cache', async () => { + vi.mocked(getLastGroupSync).mockReturnValue( + new Date(Date.now() - 60 * 60 * 1000).toISOString(), + ); + + fakeSocket.groupFetchAllParticipating.mockResolvedValue({ + 'group@g.us': { subject: 'Forced Group' }, + }); + + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await channel.syncGroupMetadata(true); + + expect(fakeSocket.groupFetchAllParticipating).toHaveBeenCalled(); + expect(updateChatName).toHaveBeenCalledWith('group@g.us', 'Forced Group'); + }); + + it('handles group sync failure gracefully', async () => { + fakeSocket.groupFetchAllParticipating.mockRejectedValue( + new Error('Network timeout'), + ); + + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + // Should not throw + await expect(channel.syncGroupMetadata(true)).resolves.toBeUndefined(); + }); + + it('skips groups with no subject', async () => { + fakeSocket.groupFetchAllParticipating.mockResolvedValue({ + 'group1@g.us': { subject: 'Has Subject' }, + 'group2@g.us': { subject: '' }, + 'group3@g.us': {}, + }); + + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + // Clear any calls from the automatic sync on connect + vi.mocked(updateChatName).mockClear(); + + await channel.syncGroupMetadata(true); + + expect(updateChatName).toHaveBeenCalledTimes(1); + expect(updateChatName).toHaveBeenCalledWith('group1@g.us', 'Has Subject'); + }); + }); + + // --- JID ownership --- + + describe('ownsJid', () => { + it('owns @g.us JIDs (WhatsApp groups)', () => { + const channel = new WhatsAppChannel(createTestOpts()); + expect(channel.ownsJid('12345@g.us')).toBe(true); + }); + + it('owns @s.whatsapp.net JIDs (WhatsApp DMs)', () => { + const channel = new WhatsAppChannel(createTestOpts()); + expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(true); + }); + + it('does not own Telegram JIDs', () => { + const channel = new WhatsAppChannel(createTestOpts()); + expect(channel.ownsJid('tg:12345')).toBe(false); + }); + + it('does not own unknown JID formats', () => { + const channel = new WhatsAppChannel(createTestOpts()); + expect(channel.ownsJid('random-string')).toBe(false); + }); + }); + + // --- Typing indicator --- + + describe('setTyping', () => { + it('sends composing presence when typing', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await channel.setTyping('test@g.us', true); + expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith( + 'composing', + 'test@g.us', + ); + }); + + it('sends paused presence when stopping', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + await channel.setTyping('test@g.us', false); + expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith( + 'paused', + 'test@g.us', + ); + }); + + it('handles typing indicator failure gracefully', async () => { + const opts = createTestOpts(); + const channel = new WhatsAppChannel(opts); + + await connectChannel(channel); + + fakeSocket.sendPresenceUpdate.mockRejectedValueOnce(new Error('Failed')); + + // Should not throw + await expect( + channel.setTyping('test@g.us', true), + ).resolves.toBeUndefined(); + }); + }); + + // --- Channel properties --- + + describe('channel properties', () => { + it('has name "whatsapp"', () => { + const channel = new WhatsAppChannel(createTestOpts()); + expect(channel.name).toBe('whatsapp'); + }); + + it('does not expose prefixAssistantName (prefix handled internally)', () => { + const channel = new WhatsAppChannel(createTestOpts()); + expect('prefixAssistantName' in channel).toBe(false); + }); + }); +}); diff --git a/.claude/skills/add-reactions/modify/src/channels/whatsapp.ts b/.claude/skills/add-reactions/modify/src/channels/whatsapp.ts new file mode 100644 index 0000000..f718ee4 --- /dev/null +++ b/.claude/skills/add-reactions/modify/src/channels/whatsapp.ts @@ -0,0 +1,457 @@ +import { exec } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +import makeWASocket, { + Browsers, + DisconnectReason, + WASocket, + fetchLatestWaWebVersion, + makeCacheableSignalKeyStore, + useMultiFileAuthState, +} from '@whiskeysockets/baileys'; + +import { + ASSISTANT_HAS_OWN_NUMBER, + ASSISTANT_NAME, + STORE_DIR, +} from '../config.js'; +import { getLastGroupSync, getLatestMessage, setLastGroupSync, storeReaction, updateChatName } from '../db.js'; +import { logger } from '../logger.js'; +import { + Channel, + OnInboundMessage, + OnChatMetadata, + RegisteredGroup, +} from '../types.js'; + +const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours + +export interface WhatsAppChannelOpts { + onMessage: OnInboundMessage; + onChatMetadata: OnChatMetadata; + registeredGroups: () => Record; +} + +export class WhatsAppChannel implements Channel { + name = 'whatsapp'; + + private sock!: WASocket; + private connected = false; + private lidToPhoneMap: Record = {}; + private outgoingQueue: Array<{ jid: string; text: string }> = []; + private flushing = false; + private groupSyncTimerStarted = false; + + private opts: WhatsAppChannelOpts; + + constructor(opts: WhatsAppChannelOpts) { + this.opts = opts; + } + + async connect(): Promise { + return new Promise((resolve, reject) => { + this.connectInternal(resolve).catch(reject); + }); + } + + private async connectInternal(onFirstOpen?: () => void): Promise { + const authDir = path.join(STORE_DIR, 'auth'); + fs.mkdirSync(authDir, { recursive: true }); + + const { state, saveCreds } = await useMultiFileAuthState(authDir); + + const { version } = await fetchLatestWaWebVersion({}).catch((err) => { + logger.warn( + { err }, + 'Failed to fetch latest WA Web version, using default', + ); + return { version: undefined }; + }); + this.sock = makeWASocket({ + version, + auth: { + creds: state.creds, + keys: makeCacheableSignalKeyStore(state.keys, logger), + }, + printQRInTerminal: false, + logger, + browser: Browsers.macOS('Chrome'), + }); + + this.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') { + this.connected = false; + const reason = ( + lastDisconnect?.error as { output?: { statusCode?: number } } + )?.output?.statusCode; + const shouldReconnect = reason !== DisconnectReason.loggedOut; + logger.info( + { + reason, + shouldReconnect, + queuedMessages: this.outgoingQueue.length, + }, + 'Connection closed', + ); + + if (shouldReconnect) { + logger.info('Reconnecting...'); + this.connectInternal().catch((err) => { + logger.error({ err }, 'Failed to reconnect, retrying in 5s'); + setTimeout(() => { + this.connectInternal().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') { + this.connected = true; + logger.info('Connected to WhatsApp'); + + // Announce availability so WhatsApp relays subsequent presence updates (typing indicators) + this.sock.sendPresenceUpdate('available').catch((err) => { + logger.warn({ err }, 'Failed to send presence update'); + }); + + // Build LID to phone mapping from auth state for self-chat translation + if (this.sock.user) { + const phoneUser = this.sock.user.id.split(':')[0]; + const lidUser = this.sock.user.lid?.split(':')[0]; + if (lidUser && phoneUser) { + this.lidToPhoneMap[lidUser] = `${phoneUser}@s.whatsapp.net`; + logger.debug({ lidUser, phoneUser }, 'LID to phone mapping set'); + } + } + + // Flush any messages queued while disconnected + this.flushOutgoingQueue().catch((err) => + logger.error({ err }, 'Failed to flush outgoing queue'), + ); + + // Sync group metadata on startup (respects 24h cache) + this.syncGroupMetadata().catch((err) => + logger.error({ err }, 'Initial group sync failed'), + ); + // Set up daily sync timer (only once) + if (!this.groupSyncTimerStarted) { + this.groupSyncTimerStarted = true; + setInterval(() => { + this.syncGroupMetadata().catch((err) => + logger.error({ err }, 'Periodic group sync failed'), + ); + }, GROUP_SYNC_INTERVAL_MS); + } + + // Signal first connection to caller + if (onFirstOpen) { + onFirstOpen(); + onFirstOpen = undefined; + } + } + }); + + this.sock.ev.on('creds.update', saveCreds); + + this.sock.ev.on('messages.upsert', async ({ 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 = await this.translateJid(rawJid); + + const timestamp = new Date( + Number(msg.messageTimestamp) * 1000, + ).toISOString(); + + // Always notify about chat metadata for group discovery + const isGroup = chatJid.endsWith('@g.us'); + this.opts.onChatMetadata( + chatJid, + timestamp, + undefined, + 'whatsapp', + isGroup, + ); + + // Only deliver full message for registered groups + const groups = this.opts.registeredGroups(); + if (groups[chatJid]) { + const content = + msg.message?.conversation || + msg.message?.extendedTextMessage?.text || + msg.message?.imageMessage?.caption || + msg.message?.videoMessage?.caption || + ''; + + // Skip protocol messages with no text content (encryption keys, read receipts, etc.) + if (!content) continue; + + const sender = msg.key.participant || msg.key.remoteJid || ''; + const senderName = msg.pushName || sender.split('@')[0]; + + const fromMe = msg.key.fromMe || false; + // Detect bot messages: with own number, fromMe is reliable + // since only the bot sends from that number. + // With shared number, bot messages carry the assistant name prefix + // (even in DMs/self-chat) so we check for that. + const isBotMessage = ASSISTANT_HAS_OWN_NUMBER + ? fromMe + : content.startsWith(`${ASSISTANT_NAME}:`); + + this.opts.onMessage(chatJid, { + id: msg.key.id || '', + chat_jid: chatJid, + sender, + sender_name: senderName, + content, + timestamp, + is_from_me: fromMe, + is_bot_message: isBotMessage, + }); + } + } + }); + + // Listen for message reactions + this.sock.ev.on('messages.reaction', async (reactions) => { + for (const { key, reaction } of reactions) { + try { + const messageId = key.id; + if (!messageId) continue; + const rawChatJid = key.remoteJid; + if (!rawChatJid || rawChatJid === 'status@broadcast') continue; + const chatJid = await this.translateJid(rawChatJid); + const groups = this.opts.registeredGroups(); + if (!groups[chatJid]) continue; + const reactorJid = reaction.key?.participant || reaction.key?.remoteJid || ''; + const emoji = reaction.text || ''; + const timestamp = reaction.senderTimestampMs + ? new Date(Number(reaction.senderTimestampMs)).toISOString() + : new Date().toISOString(); + storeReaction({ + message_id: messageId, + message_chat_jid: chatJid, + reactor_jid: reactorJid, + reactor_name: reactorJid.split('@')[0], + emoji, + timestamp, + }); + logger.info( + { + chatJid, + messageId: messageId.slice(0, 10) + '...', + reactor: reactorJid.split('@')[0], + emoji: emoji || '(removed)', + }, + emoji ? 'Reaction added' : 'Reaction removed' + ); + } catch (err) { + logger.error({ err }, 'Failed to process reaction'); + } + } + }); + } + + async sendMessage(jid: string, text: string): Promise { + // Prefix bot messages with assistant name so users know who's speaking. + // On a shared number, prefix is also needed in DMs (including self-chat) + // to distinguish bot output from user messages. + // Skip only when the assistant has its own dedicated phone number. + const prefixed = ASSISTANT_HAS_OWN_NUMBER + ? text + : `${ASSISTANT_NAME}: ${text}`; + + if (!this.connected) { + this.outgoingQueue.push({ jid, text: prefixed }); + logger.info( + { jid, length: prefixed.length, queueSize: this.outgoingQueue.length }, + 'WA disconnected, message queued', + ); + return; + } + try { + await this.sock.sendMessage(jid, { text: prefixed }); + logger.info({ jid, length: prefixed.length }, 'Message sent'); + } catch (err) { + // If send fails, queue it for retry on reconnect + this.outgoingQueue.push({ jid, text: prefixed }); + logger.warn( + { jid, err, queueSize: this.outgoingQueue.length }, + 'Failed to send, message queued', + ); + } + } + + async sendReaction( + chatJid: string, + messageKey: { id: string; remoteJid: string; fromMe?: boolean; participant?: string }, + emoji: string + ): Promise { + if (!this.connected) { + logger.warn({ chatJid, emoji }, 'Cannot send reaction - not connected'); + throw new Error('Not connected to WhatsApp'); + } + try { + await this.sock.sendMessage(chatJid, { + react: { text: emoji, key: messageKey }, + }); + logger.info( + { + chatJid, + messageId: messageKey.id?.slice(0, 10) + '...', + emoji: emoji || '(removed)', + }, + emoji ? 'Reaction sent' : 'Reaction removed' + ); + } catch (err) { + logger.error({ chatJid, emoji, err }, 'Failed to send reaction'); + throw err; + } + } + + async reactToLatestMessage(chatJid: string, emoji: string): Promise { + const latest = getLatestMessage(chatJid); + if (!latest) { + throw new Error(`No messages found for chat ${chatJid}`); + } + const messageKey = { + id: latest.id, + remoteJid: chatJid, + fromMe: latest.fromMe, + }; + await this.sendReaction(chatJid, messageKey, emoji); + } + + isConnected(): boolean { + return this.connected; + } + + ownsJid(jid: string): boolean { + return jid.endsWith('@g.us') || jid.endsWith('@s.whatsapp.net'); + } + + async disconnect(): Promise { + this.connected = false; + this.sock?.end(undefined); + } + + async setTyping(jid: string, isTyping: boolean): Promise { + try { + const status = isTyping ? 'composing' : 'paused'; + logger.debug({ jid, status }, 'Sending presence update'); + await this.sock.sendPresenceUpdate(status, jid); + } catch (err) { + logger.debug({ jid, err }, 'Failed to update typing status'); + } + } + + /** + * 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 syncGroupMetadata(force = false): Promise { + if (!force) { + const lastSync = getLastGroupSync(); + if (lastSync) { + const lastSyncTime = new Date(lastSync).getTime(); + if (Date.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 this.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'); + } + } + + private async translateJid(jid: string): Promise { + if (!jid.endsWith('@lid')) return jid; + const lidUser = jid.split('@')[0].split(':')[0]; + + // Check local cache first + const cached = this.lidToPhoneMap[lidUser]; + if (cached) { + logger.debug( + { lidJid: jid, phoneJid: cached }, + 'Translated LID to phone JID (cached)', + ); + return cached; + } + + // Query Baileys' signal repository for the mapping + try { + const pn = await this.sock.signalRepository?.lidMapping?.getPNForLID(jid); + if (pn) { + const phoneJid = `${pn.split('@')[0].split(':')[0]}@s.whatsapp.net`; + this.lidToPhoneMap[lidUser] = phoneJid; + logger.info( + { lidJid: jid, phoneJid }, + 'Translated LID to phone JID (signalRepository)', + ); + return phoneJid; + } + } catch (err) { + logger.debug({ err, jid }, 'Failed to resolve LID via signalRepository'); + } + + return jid; + } + + private async flushOutgoingQueue(): Promise { + if (this.flushing || this.outgoingQueue.length === 0) return; + this.flushing = true; + try { + logger.info( + { count: this.outgoingQueue.length }, + 'Flushing outgoing message queue', + ); + while (this.outgoingQueue.length > 0) { + const item = this.outgoingQueue.shift()!; + // Send directly — queued items are already prefixed by sendMessage + await this.sock.sendMessage(item.jid, { text: item.text }); + logger.info( + { jid: item.jid, length: item.text.length }, + 'Queued message sent', + ); + } + } finally { + this.flushing = false; + } + } +} diff --git a/.claude/skills/add-reactions/modify/src/db.test.ts b/.claude/skills/add-reactions/modify/src/db.test.ts new file mode 100644 index 0000000..0732542 --- /dev/null +++ b/.claude/skills/add-reactions/modify/src/db.test.ts @@ -0,0 +1,715 @@ +import { describe, it, expect, beforeEach } from 'vitest'; + +import { + _initTestDatabase, + createTask, + deleteTask, + getAllChats, + getLatestMessage, + getMessageFromMe, + getMessagesByReaction, + getMessagesSince, + getNewMessages, + getReactionsForMessage, + getReactionsByUser, + getReactionStats, + getTaskById, + storeChatMetadata, + storeMessage, + storeReaction, + updateTask, +} from './db.js'; + +beforeEach(() => { + _initTestDatabase(); +}); + +// Helper to store a message using the normalized NewMessage interface +function store(overrides: { + id: string; + chat_jid: string; + sender: string; + sender_name: string; + content: string; + timestamp: string; + is_from_me?: boolean; +}) { + storeMessage({ + id: overrides.id, + chat_jid: overrides.chat_jid, + sender: overrides.sender, + sender_name: overrides.sender_name, + content: overrides.content, + timestamp: overrides.timestamp, + is_from_me: overrides.is_from_me ?? false, + }); +} + +// --- storeMessage (NewMessage format) --- + +describe('storeMessage', () => { + it('stores a message and retrieves it', () => { + storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); + + store({ + id: 'msg-1', + chat_jid: 'group@g.us', + sender: '123@s.whatsapp.net', + sender_name: 'Alice', + content: 'hello world', + timestamp: '2024-01-01T00:00:01.000Z', + }); + + const messages = getMessagesSince( + 'group@g.us', + '2024-01-01T00:00:00.000Z', + 'Andy', + ); + expect(messages).toHaveLength(1); + expect(messages[0].id).toBe('msg-1'); + expect(messages[0].sender).toBe('123@s.whatsapp.net'); + expect(messages[0].sender_name).toBe('Alice'); + expect(messages[0].content).toBe('hello world'); + }); + + it('filters out empty content', () => { + storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); + + store({ + id: 'msg-2', + chat_jid: 'group@g.us', + sender: '111@s.whatsapp.net', + sender_name: 'Dave', + content: '', + timestamp: '2024-01-01T00:00:04.000Z', + }); + + const messages = getMessagesSince( + 'group@g.us', + '2024-01-01T00:00:00.000Z', + 'Andy', + ); + expect(messages).toHaveLength(0); + }); + + it('stores is_from_me flag', () => { + storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); + + store({ + id: 'msg-3', + chat_jid: 'group@g.us', + sender: 'me@s.whatsapp.net', + sender_name: 'Me', + content: 'my message', + timestamp: '2024-01-01T00:00:05.000Z', + is_from_me: true, + }); + + // Message is stored (we can retrieve it — is_from_me doesn't affect retrieval) + const messages = getMessagesSince( + 'group@g.us', + '2024-01-01T00:00:00.000Z', + 'Andy', + ); + expect(messages).toHaveLength(1); + }); + + it('upserts on duplicate id+chat_jid', () => { + storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); + + store({ + id: 'msg-dup', + chat_jid: 'group@g.us', + sender: '123@s.whatsapp.net', + sender_name: 'Alice', + content: 'original', + timestamp: '2024-01-01T00:00:01.000Z', + }); + + store({ + id: 'msg-dup', + chat_jid: 'group@g.us', + sender: '123@s.whatsapp.net', + sender_name: 'Alice', + content: 'updated', + timestamp: '2024-01-01T00:00:01.000Z', + }); + + const messages = getMessagesSince( + 'group@g.us', + '2024-01-01T00:00:00.000Z', + 'Andy', + ); + expect(messages).toHaveLength(1); + expect(messages[0].content).toBe('updated'); + }); +}); + +// --- getMessagesSince --- + +describe('getMessagesSince', () => { + beforeEach(() => { + storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); + + store({ + id: 'm1', + chat_jid: 'group@g.us', + sender: 'Alice@s.whatsapp.net', + sender_name: 'Alice', + content: 'first', + timestamp: '2024-01-01T00:00:01.000Z', + }); + store({ + id: 'm2', + chat_jid: 'group@g.us', + sender: 'Bob@s.whatsapp.net', + sender_name: 'Bob', + content: 'second', + timestamp: '2024-01-01T00:00:02.000Z', + }); + storeMessage({ + id: 'm3', + chat_jid: 'group@g.us', + sender: 'Bot@s.whatsapp.net', + sender_name: 'Bot', + content: 'bot reply', + timestamp: '2024-01-01T00:00:03.000Z', + is_bot_message: true, + }); + store({ + id: 'm4', + chat_jid: 'group@g.us', + sender: 'Carol@s.whatsapp.net', + sender_name: 'Carol', + content: 'third', + timestamp: '2024-01-01T00:00:04.000Z', + }); + }); + + it('returns messages after the given timestamp', () => { + const msgs = getMessagesSince( + 'group@g.us', + '2024-01-01T00:00:02.000Z', + 'Andy', + ); + // Should exclude m1, m2 (before/at timestamp), m3 (bot message) + expect(msgs).toHaveLength(1); + expect(msgs[0].content).toBe('third'); + }); + + it('excludes bot messages via is_bot_message flag', () => { + const msgs = getMessagesSince( + 'group@g.us', + '2024-01-01T00:00:00.000Z', + 'Andy', + ); + const botMsgs = msgs.filter((m) => m.content === 'bot reply'); + expect(botMsgs).toHaveLength(0); + }); + + it('returns all non-bot messages when sinceTimestamp is empty', () => { + const msgs = getMessagesSince('group@g.us', '', 'Andy'); + // 3 user messages (bot message excluded) + expect(msgs).toHaveLength(3); + }); + + it('filters pre-migration bot messages via content prefix backstop', () => { + // Simulate a message written before migration: has prefix but is_bot_message = 0 + store({ + id: 'm5', + chat_jid: 'group@g.us', + sender: 'Bot@s.whatsapp.net', + sender_name: 'Bot', + content: 'Andy: old bot reply', + timestamp: '2024-01-01T00:00:05.000Z', + }); + const msgs = getMessagesSince( + 'group@g.us', + '2024-01-01T00:00:04.000Z', + 'Andy', + ); + expect(msgs).toHaveLength(0); + }); +}); + +// --- getNewMessages --- + +describe('getNewMessages', () => { + beforeEach(() => { + storeChatMetadata('group1@g.us', '2024-01-01T00:00:00.000Z'); + storeChatMetadata('group2@g.us', '2024-01-01T00:00:00.000Z'); + + store({ + id: 'a1', + chat_jid: 'group1@g.us', + sender: 'user@s.whatsapp.net', + sender_name: 'User', + content: 'g1 msg1', + timestamp: '2024-01-01T00:00:01.000Z', + }); + store({ + id: 'a2', + chat_jid: 'group2@g.us', + sender: 'user@s.whatsapp.net', + sender_name: 'User', + content: 'g2 msg1', + timestamp: '2024-01-01T00:00:02.000Z', + }); + storeMessage({ + id: 'a3', + chat_jid: 'group1@g.us', + sender: 'user@s.whatsapp.net', + sender_name: 'User', + content: 'bot reply', + timestamp: '2024-01-01T00:00:03.000Z', + is_bot_message: true, + }); + store({ + id: 'a4', + chat_jid: 'group1@g.us', + sender: 'user@s.whatsapp.net', + sender_name: 'User', + content: 'g1 msg2', + timestamp: '2024-01-01T00:00:04.000Z', + }); + }); + + it('returns new messages across multiple groups', () => { + const { messages, newTimestamp } = getNewMessages( + ['group1@g.us', 'group2@g.us'], + '2024-01-01T00:00:00.000Z', + 'Andy', + ); + // Excludes bot message, returns 3 user messages + expect(messages).toHaveLength(3); + expect(newTimestamp).toBe('2024-01-01T00:00:04.000Z'); + }); + + it('filters by timestamp', () => { + const { messages } = getNewMessages( + ['group1@g.us', 'group2@g.us'], + '2024-01-01T00:00:02.000Z', + 'Andy', + ); + // Only g1 msg2 (after ts, not bot) + expect(messages).toHaveLength(1); + expect(messages[0].content).toBe('g1 msg2'); + }); + + it('returns empty for no registered groups', () => { + const { messages, newTimestamp } = getNewMessages([], '', 'Andy'); + expect(messages).toHaveLength(0); + expect(newTimestamp).toBe(''); + }); +}); + +// --- storeChatMetadata --- + +describe('storeChatMetadata', () => { + it('stores chat with JID as default name', () => { + storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); + const chats = getAllChats(); + expect(chats).toHaveLength(1); + expect(chats[0].jid).toBe('group@g.us'); + expect(chats[0].name).toBe('group@g.us'); + }); + + it('stores chat with explicit name', () => { + storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z', 'My Group'); + const chats = getAllChats(); + expect(chats[0].name).toBe('My Group'); + }); + + it('updates name on subsequent call with name', () => { + storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); + storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z', 'Updated Name'); + const chats = getAllChats(); + expect(chats).toHaveLength(1); + expect(chats[0].name).toBe('Updated Name'); + }); + + it('preserves newer timestamp on conflict', () => { + storeChatMetadata('group@g.us', '2024-01-01T00:00:05.000Z'); + storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z'); + const chats = getAllChats(); + expect(chats[0].last_message_time).toBe('2024-01-01T00:00:05.000Z'); + }); +}); + +// --- Task CRUD --- + +describe('task CRUD', () => { + it('creates and retrieves a task', () => { + createTask({ + id: 'task-1', + group_folder: 'main', + chat_jid: 'group@g.us', + prompt: 'do something', + schedule_type: 'once', + schedule_value: '2024-06-01T00:00:00.000Z', + context_mode: 'isolated', + next_run: '2024-06-01T00:00:00.000Z', + status: 'active', + created_at: '2024-01-01T00:00:00.000Z', + }); + + const task = getTaskById('task-1'); + expect(task).toBeDefined(); + expect(task!.prompt).toBe('do something'); + expect(task!.status).toBe('active'); + }); + + it('updates task status', () => { + createTask({ + id: 'task-2', + group_folder: 'main', + chat_jid: 'group@g.us', + prompt: 'test', + schedule_type: 'once', + schedule_value: '2024-06-01T00:00:00.000Z', + context_mode: 'isolated', + next_run: null, + status: 'active', + created_at: '2024-01-01T00:00:00.000Z', + }); + + updateTask('task-2', { status: 'paused' }); + expect(getTaskById('task-2')!.status).toBe('paused'); + }); + + it('deletes a task and its run logs', () => { + createTask({ + id: 'task-3', + group_folder: 'main', + chat_jid: 'group@g.us', + prompt: 'delete me', + schedule_type: 'once', + schedule_value: '2024-06-01T00:00:00.000Z', + context_mode: 'isolated', + next_run: null, + status: 'active', + created_at: '2024-01-01T00:00:00.000Z', + }); + + deleteTask('task-3'); + expect(getTaskById('task-3')).toBeUndefined(); + }); +}); + +// --- getLatestMessage --- + +describe('getLatestMessage', () => { + it('returns the most recent message for a chat', () => { + storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); + store({ + id: 'old', + chat_jid: 'group@g.us', + sender: 'a@s.whatsapp.net', + sender_name: 'A', + content: 'old', + timestamp: '2024-01-01T00:00:01.000Z', + }); + store({ + id: 'new', + chat_jid: 'group@g.us', + sender: 'b@s.whatsapp.net', + sender_name: 'B', + content: 'new', + timestamp: '2024-01-01T00:00:02.000Z', + }); + + const latest = getLatestMessage('group@g.us'); + expect(latest).toEqual({ id: 'new', fromMe: false }); + }); + + it('returns fromMe: true for own messages', () => { + storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); + store({ + id: 'mine', + chat_jid: 'group@g.us', + sender: 'me@s.whatsapp.net', + sender_name: 'Me', + content: 'my msg', + timestamp: '2024-01-01T00:00:01.000Z', + is_from_me: true, + }); + + const latest = getLatestMessage('group@g.us'); + expect(latest).toEqual({ id: 'mine', fromMe: true }); + }); + + it('returns undefined for empty chat', () => { + expect(getLatestMessage('nonexistent@g.us')).toBeUndefined(); + }); +}); + +// --- getMessageFromMe --- + +describe('getMessageFromMe', () => { + it('returns true for own messages', () => { + storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); + store({ + id: 'mine', + chat_jid: 'group@g.us', + sender: 'me@s.whatsapp.net', + sender_name: 'Me', + content: 'my msg', + timestamp: '2024-01-01T00:00:01.000Z', + is_from_me: true, + }); + + expect(getMessageFromMe('mine', 'group@g.us')).toBe(true); + }); + + it('returns false for other messages', () => { + storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); + store({ + id: 'theirs', + chat_jid: 'group@g.us', + sender: 'a@s.whatsapp.net', + sender_name: 'A', + content: 'their msg', + timestamp: '2024-01-01T00:00:01.000Z', + }); + + expect(getMessageFromMe('theirs', 'group@g.us')).toBe(false); + }); + + it('returns false for nonexistent message', () => { + expect(getMessageFromMe('nonexistent', 'group@g.us')).toBe(false); + }); +}); + +// --- storeReaction --- + +describe('storeReaction', () => { + it('stores and retrieves a reaction', () => { + storeReaction({ + message_id: 'msg-1', + message_chat_jid: 'group@g.us', + reactor_jid: 'user@s.whatsapp.net', + reactor_name: 'Alice', + emoji: '👍', + timestamp: '2024-01-01T00:00:01.000Z', + }); + + const reactions = getReactionsForMessage('msg-1', 'group@g.us'); + expect(reactions).toHaveLength(1); + expect(reactions[0].emoji).toBe('👍'); + expect(reactions[0].reactor_name).toBe('Alice'); + }); + + it('upserts on same reactor + message', () => { + const base = { + message_id: 'msg-1', + message_chat_jid: 'group@g.us', + reactor_jid: 'user@s.whatsapp.net', + reactor_name: 'Alice', + timestamp: '2024-01-01T00:00:01.000Z', + }; + storeReaction({ ...base, emoji: '👍' }); + storeReaction({ + ...base, + emoji: '❤️', + timestamp: '2024-01-01T00:00:02.000Z', + }); + + const reactions = getReactionsForMessage('msg-1', 'group@g.us'); + expect(reactions).toHaveLength(1); + expect(reactions[0].emoji).toBe('❤️'); + }); + + it('removes reaction when emoji is empty', () => { + storeReaction({ + message_id: 'msg-1', + message_chat_jid: 'group@g.us', + reactor_jid: 'user@s.whatsapp.net', + emoji: '👍', + timestamp: '2024-01-01T00:00:01.000Z', + }); + storeReaction({ + message_id: 'msg-1', + message_chat_jid: 'group@g.us', + reactor_jid: 'user@s.whatsapp.net', + emoji: '', + timestamp: '2024-01-01T00:00:02.000Z', + }); + + expect(getReactionsForMessage('msg-1', 'group@g.us')).toHaveLength(0); + }); +}); + +// --- getReactionsForMessage --- + +describe('getReactionsForMessage', () => { + it('returns multiple reactions ordered by timestamp', () => { + storeReaction({ + message_id: 'msg-1', + message_chat_jid: 'group@g.us', + reactor_jid: 'b@s.whatsapp.net', + emoji: '❤️', + timestamp: '2024-01-01T00:00:02.000Z', + }); + storeReaction({ + message_id: 'msg-1', + message_chat_jid: 'group@g.us', + reactor_jid: 'a@s.whatsapp.net', + emoji: '👍', + timestamp: '2024-01-01T00:00:01.000Z', + }); + + const reactions = getReactionsForMessage('msg-1', 'group@g.us'); + expect(reactions).toHaveLength(2); + expect(reactions[0].reactor_jid).toBe('a@s.whatsapp.net'); + expect(reactions[1].reactor_jid).toBe('b@s.whatsapp.net'); + }); + + it('returns empty array for message with no reactions', () => { + expect(getReactionsForMessage('nonexistent', 'group@g.us')).toEqual([]); + }); +}); + +// --- getMessagesByReaction --- + +describe('getMessagesByReaction', () => { + beforeEach(() => { + storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); + store({ + id: 'msg-1', + chat_jid: 'group@g.us', + sender: 'author@s.whatsapp.net', + sender_name: 'Author', + content: 'bookmarked msg', + timestamp: '2024-01-01T00:00:01.000Z', + }); + storeReaction({ + message_id: 'msg-1', + message_chat_jid: 'group@g.us', + reactor_jid: 'user@s.whatsapp.net', + emoji: '📌', + timestamp: '2024-01-01T00:00:02.000Z', + }); + }); + + it('joins reactions with messages', () => { + const results = getMessagesByReaction('user@s.whatsapp.net', '📌'); + expect(results).toHaveLength(1); + expect(results[0].content).toBe('bookmarked msg'); + expect(results[0].sender_name).toBe('Author'); + }); + + it('filters by chatJid when provided', () => { + const results = getMessagesByReaction( + 'user@s.whatsapp.net', + '📌', + 'group@g.us', + ); + expect(results).toHaveLength(1); + + const empty = getMessagesByReaction( + 'user@s.whatsapp.net', + '📌', + 'other@g.us', + ); + expect(empty).toHaveLength(0); + }); + + it('returns empty when no matching reactions', () => { + expect(getMessagesByReaction('user@s.whatsapp.net', '🔥')).toHaveLength(0); + }); +}); + +// --- getReactionsByUser --- + +describe('getReactionsByUser', () => { + it('returns reactions for a user ordered by timestamp desc', () => { + storeReaction({ + message_id: 'msg-1', + message_chat_jid: 'group@g.us', + reactor_jid: 'user@s.whatsapp.net', + emoji: '👍', + timestamp: '2024-01-01T00:00:01.000Z', + }); + storeReaction({ + message_id: 'msg-2', + message_chat_jid: 'group@g.us', + reactor_jid: 'user@s.whatsapp.net', + emoji: '❤️', + timestamp: '2024-01-01T00:00:02.000Z', + }); + + const reactions = getReactionsByUser('user@s.whatsapp.net'); + expect(reactions).toHaveLength(2); + expect(reactions[0].emoji).toBe('❤️'); // newer first + expect(reactions[1].emoji).toBe('👍'); + }); + + it('respects the limit parameter', () => { + for (let i = 0; i < 5; i++) { + storeReaction({ + message_id: `msg-${i}`, + message_chat_jid: 'group@g.us', + reactor_jid: 'user@s.whatsapp.net', + emoji: '👍', + timestamp: `2024-01-01T00:00:0${i}.000Z`, + }); + } + + expect(getReactionsByUser('user@s.whatsapp.net', 3)).toHaveLength(3); + }); + + it('returns empty for user with no reactions', () => { + expect(getReactionsByUser('nobody@s.whatsapp.net')).toEqual([]); + }); +}); + +// --- getReactionStats --- + +describe('getReactionStats', () => { + beforeEach(() => { + storeReaction({ + message_id: 'msg-1', + message_chat_jid: 'group@g.us', + reactor_jid: 'a@s.whatsapp.net', + emoji: '👍', + timestamp: '2024-01-01T00:00:01.000Z', + }); + storeReaction({ + message_id: 'msg-2', + message_chat_jid: 'group@g.us', + reactor_jid: 'b@s.whatsapp.net', + emoji: '👍', + timestamp: '2024-01-01T00:00:02.000Z', + }); + storeReaction({ + message_id: 'msg-1', + message_chat_jid: 'group@g.us', + reactor_jid: 'c@s.whatsapp.net', + emoji: '❤️', + timestamp: '2024-01-01T00:00:03.000Z', + }); + storeReaction({ + message_id: 'msg-1', + message_chat_jid: 'other@g.us', + reactor_jid: 'a@s.whatsapp.net', + emoji: '🔥', + timestamp: '2024-01-01T00:00:04.000Z', + }); + }); + + it('returns global stats ordered by count desc', () => { + const stats = getReactionStats(); + expect(stats[0]).toEqual({ emoji: '👍', count: 2 }); + expect(stats).toHaveLength(3); + }); + + it('filters by chatJid', () => { + const stats = getReactionStats('group@g.us'); + expect(stats).toHaveLength(2); + expect(stats.find((s) => s.emoji === '🔥')).toBeUndefined(); + }); + + it('returns empty for chat with no reactions', () => { + expect(getReactionStats('empty@g.us')).toEqual([]); + }); +}); diff --git a/.claude/skills/add-reactions/modify/src/db.ts b/.claude/skills/add-reactions/modify/src/db.ts new file mode 100644 index 0000000..5200c9f --- /dev/null +++ b/.claude/skills/add-reactions/modify/src/db.ts @@ -0,0 +1,801 @@ +import Database from 'better-sqlite3'; +import fs from 'fs'; +import path from 'path'; + +import { ASSISTANT_NAME, DATA_DIR, STORE_DIR } from './config.js'; +import { isValidGroupFolder } from './group-folder.js'; +import { logger } from './logger.js'; +import { + NewMessage, + RegisteredGroup, + ScheduledTask, + TaskRunLog, +} from './types.js'; + +let db: Database.Database; + +export interface Reaction { + message_id: string; + message_chat_jid: string; + reactor_jid: string; + reactor_name?: string; + emoji: string; + timestamp: string; +} + +function createSchema(database: Database.Database): void { + database.exec(` + CREATE TABLE IF NOT EXISTS chats ( + jid TEXT PRIMARY KEY, + name TEXT, + last_message_time TEXT, + channel TEXT, + is_group INTEGER DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS messages ( + id TEXT, + chat_jid TEXT, + sender TEXT, + sender_name TEXT, + content TEXT, + timestamp TEXT, + is_from_me INTEGER, + is_bot_message INTEGER DEFAULT 0, + PRIMARY KEY (id, chat_jid), + FOREIGN KEY (chat_jid) REFERENCES chats(jid) + ); + CREATE INDEX IF NOT EXISTS idx_timestamp ON messages(timestamp); + + CREATE TABLE IF NOT EXISTS scheduled_tasks ( + id TEXT PRIMARY KEY, + group_folder TEXT NOT NULL, + chat_jid TEXT NOT NULL, + prompt TEXT NOT NULL, + schedule_type TEXT NOT NULL, + schedule_value TEXT NOT NULL, + next_run TEXT, + last_run TEXT, + last_result TEXT, + status TEXT DEFAULT 'active', + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_next_run ON scheduled_tasks(next_run); + CREATE INDEX IF NOT EXISTS idx_status ON scheduled_tasks(status); + + CREATE TABLE IF NOT EXISTS task_run_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + run_at TEXT NOT NULL, + duration_ms INTEGER NOT NULL, + status TEXT NOT NULL, + result TEXT, + error TEXT, + FOREIGN KEY (task_id) REFERENCES scheduled_tasks(id) + ); + CREATE INDEX IF NOT EXISTS idx_task_run_logs ON task_run_logs(task_id, run_at); + + CREATE TABLE IF NOT EXISTS router_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS sessions ( + group_folder TEXT PRIMARY KEY, + session_id TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS registered_groups ( + jid TEXT PRIMARY KEY, + name TEXT NOT NULL, + folder TEXT NOT NULL UNIQUE, + trigger_pattern TEXT NOT NULL, + added_at TEXT NOT NULL, + container_config TEXT, + requires_trigger INTEGER DEFAULT 1 + ); + + CREATE TABLE IF NOT EXISTS reactions ( + message_id TEXT NOT NULL, + message_chat_jid TEXT NOT NULL, + reactor_jid TEXT NOT NULL, + reactor_name TEXT, + emoji TEXT NOT NULL, + timestamp TEXT NOT NULL, + PRIMARY KEY (message_id, message_chat_jid, reactor_jid) + ); + CREATE INDEX IF NOT EXISTS idx_reactions_message ON reactions(message_id, message_chat_jid); + CREATE INDEX IF NOT EXISTS idx_reactions_reactor ON reactions(reactor_jid); + CREATE INDEX IF NOT EXISTS idx_reactions_emoji ON reactions(emoji); + CREATE INDEX IF NOT EXISTS idx_reactions_timestamp ON reactions(timestamp); + `); + + // Add context_mode column if it doesn't exist (migration for existing DBs) + try { + database.exec( + `ALTER TABLE scheduled_tasks ADD COLUMN context_mode TEXT DEFAULT 'isolated'`, + ); + } catch { + /* column already exists */ + } + + // Add is_bot_message column if it doesn't exist (migration for existing DBs) + try { + database.exec( + `ALTER TABLE messages ADD COLUMN is_bot_message INTEGER DEFAULT 0`, + ); + // Backfill: mark existing bot messages that used the content prefix pattern + database + .prepare(`UPDATE messages SET is_bot_message = 1 WHERE content LIKE ?`) + .run(`${ASSISTANT_NAME}:%`); + } catch { + /* column already exists */ + } + + // Add channel and is_group columns if they don't exist (migration for existing DBs) + try { + database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`); + database.exec(`ALTER TABLE chats ADD COLUMN is_group INTEGER DEFAULT 0`); + // Backfill from JID patterns + database.exec( + `UPDATE chats SET channel = 'whatsapp', is_group = 1 WHERE jid LIKE '%@g.us'`, + ); + database.exec( + `UPDATE chats SET channel = 'whatsapp', is_group = 0 WHERE jid LIKE '%@s.whatsapp.net'`, + ); + database.exec( + `UPDATE chats SET channel = 'discord', is_group = 1 WHERE jid LIKE 'dc:%'`, + ); + database.exec( + `UPDATE chats SET channel = 'telegram', is_group = 1 WHERE jid LIKE 'tg:%'`, + ); + } catch { + /* columns already exist */ + } +} + +export function initDatabase(): void { + const dbPath = path.join(STORE_DIR, 'messages.db'); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + + db = new Database(dbPath); + createSchema(db); + + // Migrate from JSON files if they exist + migrateJsonState(); +} + +/** @internal - for tests only. Creates a fresh in-memory database. */ +export function _initTestDatabase(): void { + db = new Database(':memory:'); + createSchema(db); +} + +/** + * 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, + name?: string, + channel?: string, + isGroup?: boolean, +): void { + const ch = channel ?? null; + const group = isGroup === undefined ? null : isGroup ? 1 : 0; + + if (name) { + // Update with name, preserving existing timestamp if newer + db.prepare( + ` + INSERT INTO chats (jid, name, last_message_time, channel, is_group) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(jid) DO UPDATE SET + name = excluded.name, + last_message_time = MAX(last_message_time, excluded.last_message_time), + channel = COALESCE(excluded.channel, channel), + is_group = COALESCE(excluded.is_group, is_group) + `, + ).run(chatJid, name, timestamp, ch, group); + } else { + // Update timestamp only, preserve existing name if any + db.prepare( + ` + INSERT INTO chats (jid, name, last_message_time, channel, is_group) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(jid) DO UPDATE SET + last_message_time = MAX(last_message_time, excluded.last_message_time), + channel = COALESCE(excluded.channel, channel), + is_group = COALESCE(excluded.is_group, is_group) + `, + ).run(chatJid, chatJid, timestamp, ch, group); + } +} + +/** + * 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; + last_message_time: string; + channel: string; + is_group: number; +} + +/** + * Get all known chats, ordered by most recent activity. + */ +export function getAllChats(): ChatInfo[] { + return db + .prepare( + ` + SELECT jid, name, last_message_time, channel, is_group + 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); +} + +/** + * Store a message with full content. + * Only call this for registered groups where message history is needed. + */ +export function storeMessage(msg: NewMessage): 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, + ); +} + +/** + * Store a message directly (for non-WhatsApp channels that don't use Baileys proto). + */ +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, + botPrefix: string, +): { messages: NewMessage[]; newTimestamp: string } { + 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. + const sql = ` + SELECT id, chat_jid, sender, sender_name, content, timestamp + FROM messages + WHERE timestamp > ? AND chat_jid IN (${placeholders}) + AND is_bot_message = 0 AND content NOT LIKE ? + AND content != '' AND content IS NOT NULL + ORDER BY timestamp + `; + + const rows = db + .prepare(sql) + .all(lastTimestamp, ...jids, `${botPrefix}:%`) as NewMessage[]; + + let newTimestamp = lastTimestamp; + for (const row of rows) { + if (row.timestamp > newTimestamp) newTimestamp = row.timestamp; + } + + return { messages: rows, newTimestamp }; +} + +export function getMessagesSince( + chatJid: string, + sinceTimestamp: string, + botPrefix: string, +): 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. + const sql = ` + SELECT id, chat_jid, sender, sender_name, content, timestamp + FROM messages + WHERE chat_jid = ? AND timestamp > ? + AND is_bot_message = 0 AND content NOT LIKE ? + AND content != '' AND content IS NOT NULL + ORDER BY timestamp + `; + return db + .prepare(sql) + .all(chatJid, sinceTimestamp, `${botPrefix}:%`) as NewMessage[]; +} + +export function getMessageFromMe(messageId: string, chatJid: string): boolean { + const row = db + .prepare(`SELECT is_from_me FROM messages WHERE id = ? AND chat_jid = ? LIMIT 1`) + .get(messageId, chatJid) as { is_from_me: number | null } | undefined; + return row?.is_from_me === 1; +} + +export function getLatestMessage(chatJid: string): { id: string; fromMe: boolean } | undefined { + const row = db + .prepare(`SELECT id, is_from_me FROM messages WHERE chat_jid = ? ORDER BY timestamp DESC LIMIT 1`) + .get(chatJid) as { id: string; is_from_me: number | null } | undefined; + if (!row) return undefined; + return { id: row.id, fromMe: row.is_from_me === 1 }; +} + +export function storeReaction(reaction: Reaction): void { + if (!reaction.emoji) { + db.prepare( + `DELETE FROM reactions WHERE message_id = ? AND message_chat_jid = ? AND reactor_jid = ?` + ).run(reaction.message_id, reaction.message_chat_jid, reaction.reactor_jid); + return; + } + db.prepare( + `INSERT OR REPLACE INTO reactions (message_id, message_chat_jid, reactor_jid, reactor_name, emoji, timestamp) + VALUES (?, ?, ?, ?, ?, ?)` + ).run( + reaction.message_id, + reaction.message_chat_jid, + reaction.reactor_jid, + reaction.reactor_name || null, + reaction.emoji, + reaction.timestamp + ); +} + +export function getReactionsForMessage( + messageId: string, + chatJid: string +): Reaction[] { + return db + .prepare( + `SELECT * FROM reactions WHERE message_id = ? AND message_chat_jid = ? ORDER BY timestamp` + ) + .all(messageId, chatJid) as Reaction[]; +} + +export function getMessagesByReaction( + reactorJid: string, + emoji: string, + chatJid?: string +): Array { + const sql = chatJid + ? ` + SELECT r.*, m.content, m.sender_name, m.timestamp as message_timestamp + FROM reactions r + JOIN messages m ON r.message_id = m.id AND r.message_chat_jid = m.chat_jid + WHERE r.reactor_jid = ? AND r.emoji = ? AND r.message_chat_jid = ? + ORDER BY r.timestamp DESC + ` + : ` + SELECT r.*, m.content, m.sender_name, m.timestamp as message_timestamp + FROM reactions r + JOIN messages m ON r.message_id = m.id AND r.message_chat_jid = m.chat_jid + WHERE r.reactor_jid = ? AND r.emoji = ? + ORDER BY r.timestamp DESC + `; + + type Result = Reaction & { content: string; sender_name: string; message_timestamp: string }; + return chatJid + ? (db.prepare(sql).all(reactorJid, emoji, chatJid) as Result[]) + : (db.prepare(sql).all(reactorJid, emoji) as Result[]); +} + +export function getReactionsByUser( + reactorJid: string, + limit: number = 50 +): Reaction[] { + return db + .prepare( + `SELECT * FROM reactions WHERE reactor_jid = ? ORDER BY timestamp DESC LIMIT ?` + ) + .all(reactorJid, limit) as Reaction[]; +} + +export function getReactionStats(chatJid?: string): Array<{ + emoji: string; + count: number; +}> { + const sql = chatJid + ? ` + SELECT emoji, COUNT(*) as count + FROM reactions + WHERE message_chat_jid = ? + GROUP BY emoji + ORDER BY count DESC + ` + : ` + SELECT emoji, COUNT(*) as count + FROM reactions + GROUP BY emoji + ORDER BY count DESC + `; + + type Result = { emoji: string; count: number }; + return chatJid + ? (db.prepare(sql).all(chatJid) as Result[]) + : (db.prepare(sql).all() as Result[]); +} + +export function createTask( + task: Omit, +): void { + db.prepare( + ` + INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ).run( + task.id, + task.group_folder, + task.chat_jid, + task.prompt, + task.schedule_type, + task.schedule_value, + task.context_mode || 'isolated', + task.next_run, + task.status, + task.created_at, + ); +} + +export function getTaskById(id: string): ScheduledTask | undefined { + return db.prepare('SELECT * FROM scheduled_tasks WHERE id = ?').get(id) as + | ScheduledTask + | undefined; +} + +export function getTasksForGroup(groupFolder: string): ScheduledTask[] { + return db + .prepare( + 'SELECT * FROM scheduled_tasks WHERE group_folder = ? ORDER BY created_at DESC', + ) + .all(groupFolder) as ScheduledTask[]; +} + +export function getAllTasks(): ScheduledTask[] { + return db + .prepare('SELECT * FROM scheduled_tasks ORDER BY created_at DESC') + .all() as ScheduledTask[]; +} + +export function updateTask( + id: string, + updates: Partial< + Pick< + ScheduledTask, + 'prompt' | 'schedule_type' | 'schedule_value' | 'next_run' | 'status' + > + >, +): void { + const fields: string[] = []; + const values: unknown[] = []; + + if (updates.prompt !== undefined) { + fields.push('prompt = ?'); + values.push(updates.prompt); + } + if (updates.schedule_type !== undefined) { + fields.push('schedule_type = ?'); + values.push(updates.schedule_type); + } + if (updates.schedule_value !== undefined) { + fields.push('schedule_value = ?'); + values.push(updates.schedule_value); + } + if (updates.next_run !== undefined) { + fields.push('next_run = ?'); + values.push(updates.next_run); + } + if (updates.status !== undefined) { + fields.push('status = ?'); + values.push(updates.status); + } + + if (fields.length === 0) return; + + values.push(id); + db.prepare( + `UPDATE scheduled_tasks SET ${fields.join(', ')} WHERE id = ?`, + ).run(...values); +} + +export function deleteTask(id: string): void { + // Delete child records first (FK constraint) + db.prepare('DELETE FROM task_run_logs WHERE task_id = ?').run(id); + db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id); +} + +export function getDueTasks(): ScheduledTask[] { + const now = new Date().toISOString(); + return db + .prepare( + ` + SELECT * FROM scheduled_tasks + WHERE status = 'active' AND next_run IS NOT NULL AND next_run <= ? + ORDER BY next_run + `, + ) + .all(now) as ScheduledTask[]; +} + +export function updateTaskAfterRun( + id: string, + nextRun: string | null, + lastResult: string, +): void { + const now = new Date().toISOString(); + db.prepare( + ` + UPDATE scheduled_tasks + SET next_run = ?, last_run = ?, last_result = ?, status = CASE WHEN ? IS NULL THEN 'completed' ELSE status END + WHERE id = ? + `, + ).run(nextRun, now, lastResult, nextRun, id); +} + +export function logTaskRun(log: TaskRunLog): void { + db.prepare( + ` + INSERT INTO task_run_logs (task_id, run_at, duration_ms, status, result, error) + VALUES (?, ?, ?, ?, ?, ?) + `, + ).run( + log.task_id, + log.run_at, + log.duration_ms, + log.status, + log.result, + log.error, + ); +} + +// --- Router state accessors --- + +export function getRouterState(key: string): string | undefined { + const row = db + .prepare('SELECT value FROM router_state WHERE key = ?') + .get(key) as { value: string } | undefined; + return row?.value; +} + +export function setRouterState(key: string, value: string): void { + db.prepare( + 'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)', + ).run(key, 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; + 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); +} + +export function getAllSessions(): Record { + const rows = db + .prepare('SELECT group_folder, session_id FROM sessions') + .all() as Array<{ group_folder: string; session_id: string }>; + const result: Record = {}; + for (const row of rows) { + result[row.group_folder] = row.session_id; + } + return result; +} + +// --- Registered group accessors --- + +export function getRegisteredGroup( + jid: string, +): (RegisteredGroup & { jid: string }) | undefined { + const row = db + .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; + } + | undefined; + if (!row) return undefined; + if (!isValidGroupFolder(row.folder)) { + logger.warn( + { jid: row.jid, folder: row.folder }, + 'Skipping registered group with invalid folder', + ); + return undefined; + } + return { + jid: row.jid, + name: row.name, + folder: row.folder, + trigger: row.trigger_pattern, + added_at: row.added_at, + containerConfig: row.container_config + ? JSON.parse(row.container_config) + : undefined, + requiresTrigger: + row.requires_trigger === null ? undefined : row.requires_trigger === 1, + }; +} + +export function setRegisteredGroup(jid: string, group: RegisteredGroup): void { + if (!isValidGroupFolder(group.folder)) { + 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) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run( + jid, + group.name, + group.folder, + group.trigger, + group.added_at, + group.containerConfig ? JSON.stringify(group.containerConfig) : null, + group.requiresTrigger === undefined ? 1 : group.requiresTrigger ? 1 : 0, + ); +} + +export function getAllRegisteredGroups(): Record { + const rows = 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; + }>; + const result: Record = {}; + for (const row of rows) { + if (!isValidGroupFolder(row.folder)) { + logger.warn( + { jid: row.jid, folder: row.folder }, + 'Skipping registered group with invalid folder', + ); + continue; + } + result[row.jid] = { + name: row.name, + folder: row.folder, + trigger: row.trigger_pattern, + added_at: row.added_at, + containerConfig: row.container_config + ? JSON.parse(row.container_config) + : undefined, + requiresTrigger: + row.requires_trigger === null ? undefined : row.requires_trigger === 1, + }; + } + return result; +} + +// --- JSON migration --- + +function migrateJsonState(): void { + const migrateFile = (filename: string) => { + const filePath = path.join(DATA_DIR, filename); + if (!fs.existsSync(filePath)) return null; + try { + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + fs.renameSync(filePath, `${filePath}.migrated`); + return data; + } catch { + return null; + } + }; + + // Migrate router_state.json + const routerState = migrateFile('router_state.json') as { + last_timestamp?: string; + last_agent_timestamp?: Record; + } | null; + if (routerState) { + if (routerState.last_timestamp) { + setRouterState('last_timestamp', routerState.last_timestamp); + } + if (routerState.last_agent_timestamp) { + setRouterState( + 'last_agent_timestamp', + JSON.stringify(routerState.last_agent_timestamp), + ); + } + } + + // Migrate sessions.json + const sessions = migrateFile('sessions.json') as Record< + string, + string + > | null; + if (sessions) { + for (const [folder, sessionId] of Object.entries(sessions)) { + setSession(folder, sessionId); + } + } + + // Migrate registered_groups.json + const groups = migrateFile('registered_groups.json') as Record< + string, + RegisteredGroup + > | null; + if (groups) { + for (const [jid, group] of Object.entries(groups)) { + try { + setRegisteredGroup(jid, group); + } catch (err) { + logger.warn( + { jid, folder: group.folder, err }, + 'Skipping migrated registered group with invalid folder', + ); + } + } + } +} diff --git a/.claude/skills/add-reactions/modify/src/group-queue.test.ts b/.claude/skills/add-reactions/modify/src/group-queue.test.ts new file mode 100644 index 0000000..6c0447a --- /dev/null +++ b/.claude/skills/add-reactions/modify/src/group-queue.test.ts @@ -0,0 +1,510 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; + +import { GroupQueue } from './group-queue.js'; + +// Mock config to control concurrency limit +vi.mock('./config.js', () => ({ + DATA_DIR: '/tmp/nanoclaw-test-data', + MAX_CONCURRENT_CONTAINERS: 2, +})); + +// Mock fs operations used by sendMessage/closeStdin +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { + ...actual, + default: { + ...actual, + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + renameSync: vi.fn(), + }, + }; +}); + +describe('GroupQueue', () => { + let queue: GroupQueue; + + beforeEach(() => { + vi.useFakeTimers(); + queue = new GroupQueue(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // --- Single group at a time --- + + it('only runs one container per group at a time', async () => { + let concurrentCount = 0; + let maxConcurrent = 0; + + const processMessages = vi.fn(async (groupJid: string) => { + concurrentCount++; + maxConcurrent = Math.max(maxConcurrent, concurrentCount); + // Simulate async work + await new Promise((resolve) => setTimeout(resolve, 100)); + concurrentCount--; + return true; + }); + + queue.setProcessMessagesFn(processMessages); + + // Enqueue two messages for the same group + queue.enqueueMessageCheck('group1@g.us'); + queue.enqueueMessageCheck('group1@g.us'); + + // Advance timers to let the first process complete + await vi.advanceTimersByTimeAsync(200); + + // Second enqueue should have been queued, not concurrent + expect(maxConcurrent).toBe(1); + }); + + // --- Global concurrency limit --- + + it('respects global concurrency limit', async () => { + let activeCount = 0; + let maxActive = 0; + const completionCallbacks: Array<() => void> = []; + + const processMessages = vi.fn(async (groupJid: string) => { + activeCount++; + maxActive = Math.max(maxActive, activeCount); + await new Promise((resolve) => completionCallbacks.push(resolve)); + activeCount--; + return true; + }); + + queue.setProcessMessagesFn(processMessages); + + // Enqueue 3 groups (limit is 2) + queue.enqueueMessageCheck('group1@g.us'); + queue.enqueueMessageCheck('group2@g.us'); + queue.enqueueMessageCheck('group3@g.us'); + + // Let promises settle + await vi.advanceTimersByTimeAsync(10); + + // Only 2 should be active (MAX_CONCURRENT_CONTAINERS = 2) + expect(maxActive).toBe(2); + expect(activeCount).toBe(2); + + // Complete one — third should start + completionCallbacks[0](); + await vi.advanceTimersByTimeAsync(10); + + expect(processMessages).toHaveBeenCalledTimes(3); + }); + + // --- Tasks prioritized over messages --- + + it('drains tasks before messages for same group', async () => { + const executionOrder: string[] = []; + let resolveFirst: () => void; + + const processMessages = vi.fn(async (groupJid: string) => { + if (executionOrder.length === 0) { + // First call: block until we release it + await new Promise((resolve) => { + resolveFirst = resolve; + }); + } + executionOrder.push('messages'); + return true; + }); + + queue.setProcessMessagesFn(processMessages); + + // Start processing messages (takes the active slot) + queue.enqueueMessageCheck('group1@g.us'); + await vi.advanceTimersByTimeAsync(10); + + // While active, enqueue both a task and pending messages + const taskFn = vi.fn(async () => { + executionOrder.push('task'); + }); + queue.enqueueTask('group1@g.us', 'task-1', taskFn); + queue.enqueueMessageCheck('group1@g.us'); + + // Release the first processing + resolveFirst!(); + await vi.advanceTimersByTimeAsync(10); + + // Task should have run before the second message check + expect(executionOrder[0]).toBe('messages'); // first call + expect(executionOrder[1]).toBe('task'); // task runs first in drain + // Messages would run after task completes + }); + + // --- Retry with backoff on failure --- + + it('retries with exponential backoff on failure', async () => { + let callCount = 0; + + const processMessages = vi.fn(async () => { + callCount++; + return false; // failure + }); + + queue.setProcessMessagesFn(processMessages); + queue.enqueueMessageCheck('group1@g.us'); + + // First call happens immediately + await vi.advanceTimersByTimeAsync(10); + expect(callCount).toBe(1); + + // First retry after 5000ms (BASE_RETRY_MS * 2^0) + await vi.advanceTimersByTimeAsync(5000); + await vi.advanceTimersByTimeAsync(10); + expect(callCount).toBe(2); + + // Second retry after 10000ms (BASE_RETRY_MS * 2^1) + await vi.advanceTimersByTimeAsync(10000); + await vi.advanceTimersByTimeAsync(10); + expect(callCount).toBe(3); + }); + + // --- Shutdown prevents new enqueues --- + + it('prevents new enqueues after shutdown', async () => { + const processMessages = vi.fn(async () => true); + queue.setProcessMessagesFn(processMessages); + + await queue.shutdown(1000); + + queue.enqueueMessageCheck('group1@g.us'); + await vi.advanceTimersByTimeAsync(100); + + expect(processMessages).not.toHaveBeenCalled(); + }); + + // --- Max retries exceeded --- + + it('stops retrying after MAX_RETRIES and resets', async () => { + let callCount = 0; + + const processMessages = vi.fn(async () => { + callCount++; + return false; // always fail + }); + + queue.setProcessMessagesFn(processMessages); + queue.enqueueMessageCheck('group1@g.us'); + + // Run through all 5 retries (MAX_RETRIES = 5) + // Initial call + await vi.advanceTimersByTimeAsync(10); + expect(callCount).toBe(1); + + // Retry 1: 5000ms, Retry 2: 10000ms, Retry 3: 20000ms, Retry 4: 40000ms, Retry 5: 80000ms + const retryDelays = [5000, 10000, 20000, 40000, 80000]; + for (let i = 0; i < retryDelays.length; i++) { + await vi.advanceTimersByTimeAsync(retryDelays[i] + 10); + expect(callCount).toBe(i + 2); + } + + // After 5 retries (6 total calls), should stop — no more retries + const countAfterMaxRetries = callCount; + await vi.advanceTimersByTimeAsync(200000); // Wait a long time + expect(callCount).toBe(countAfterMaxRetries); + }); + + // --- Waiting groups get drained when slots free up --- + + it('drains waiting groups when active slots free up', async () => { + const processed: string[] = []; + const completionCallbacks: Array<() => void> = []; + + const processMessages = vi.fn(async (groupJid: string) => { + processed.push(groupJid); + await new Promise((resolve) => completionCallbacks.push(resolve)); + return true; + }); + + queue.setProcessMessagesFn(processMessages); + + // Fill both slots + queue.enqueueMessageCheck('group1@g.us'); + queue.enqueueMessageCheck('group2@g.us'); + await vi.advanceTimersByTimeAsync(10); + + // Queue a third + queue.enqueueMessageCheck('group3@g.us'); + await vi.advanceTimersByTimeAsync(10); + + expect(processed).toEqual(['group1@g.us', 'group2@g.us']); + + // Free up a slot + completionCallbacks[0](); + await vi.advanceTimersByTimeAsync(10); + + expect(processed).toContain('group3@g.us'); + }); + + // --- Running task dedup (Issue #138) --- + + it('rejects duplicate enqueue of a currently-running task', async () => { + let resolveTask: () => void; + let taskCallCount = 0; + + const taskFn = vi.fn(async () => { + taskCallCount++; + await new Promise((resolve) => { + resolveTask = resolve; + }); + }); + + // Start the task (runs immediately — slot available) + queue.enqueueTask('group1@g.us', 'task-1', taskFn); + await vi.advanceTimersByTimeAsync(10); + expect(taskCallCount).toBe(1); + + // Scheduler poll re-discovers the same task while it's running — + // this must be silently dropped + const dupFn = vi.fn(async () => {}); + queue.enqueueTask('group1@g.us', 'task-1', dupFn); + await vi.advanceTimersByTimeAsync(10); + + // Duplicate was NOT queued + expect(dupFn).not.toHaveBeenCalled(); + + // Complete the original task + resolveTask!(); + await vi.advanceTimersByTimeAsync(10); + + // Only one execution total + expect(taskCallCount).toBe(1); + }); + + // --- Idle preemption --- + + it('does NOT preempt active container when not idle', async () => { + const fs = await import('fs'); + let resolveProcess: () => void; + + const processMessages = vi.fn(async () => { + await new Promise((resolve) => { + resolveProcess = resolve; + }); + return true; + }); + + queue.setProcessMessagesFn(processMessages); + + // Start processing (takes the active slot) + queue.enqueueMessageCheck('group1@g.us'); + await vi.advanceTimersByTimeAsync(10); + + // Register a process so closeStdin has a groupFolder + queue.registerProcess( + 'group1@g.us', + {} as any, + 'container-1', + 'test-group', + ); + + // Enqueue a task while container is active but NOT idle + const taskFn = vi.fn(async () => {}); + queue.enqueueTask('group1@g.us', 'task-1', taskFn); + + // _close should NOT have been written (container is working, not idle) + const writeFileSync = vi.mocked(fs.default.writeFileSync); + const closeWrites = writeFileSync.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].endsWith('_close'), + ); + expect(closeWrites).toHaveLength(0); + + resolveProcess!(); + await vi.advanceTimersByTimeAsync(10); + }); + + it('preempts idle container when task is enqueued', async () => { + const fs = await import('fs'); + let resolveProcess: () => void; + + const processMessages = vi.fn(async () => { + await new Promise((resolve) => { + resolveProcess = resolve; + }); + return true; + }); + + queue.setProcessMessagesFn(processMessages); + + // Start processing + queue.enqueueMessageCheck('group1@g.us'); + await vi.advanceTimersByTimeAsync(10); + + // Register process and mark idle + queue.registerProcess( + 'group1@g.us', + {} as any, + 'container-1', + 'test-group', + ); + queue.notifyIdle('group1@g.us'); + + // Clear previous writes, then enqueue a task + const writeFileSync = vi.mocked(fs.default.writeFileSync); + writeFileSync.mockClear(); + + const taskFn = vi.fn(async () => {}); + queue.enqueueTask('group1@g.us', 'task-1', taskFn); + + // _close SHOULD have been written (container is idle) + const closeWrites = writeFileSync.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].endsWith('_close'), + ); + expect(closeWrites).toHaveLength(1); + + resolveProcess!(); + await vi.advanceTimersByTimeAsync(10); + }); + + it('sendMessage resets idleWaiting so a subsequent task enqueue does not preempt', async () => { + const fs = await import('fs'); + let resolveProcess: () => void; + + const processMessages = vi.fn(async () => { + await new Promise((resolve) => { + resolveProcess = resolve; + }); + return true; + }); + + queue.setProcessMessagesFn(processMessages); + queue.enqueueMessageCheck('group1@g.us'); + await vi.advanceTimersByTimeAsync(10); + queue.registerProcess( + 'group1@g.us', + {} as any, + 'container-1', + 'test-group', + ); + + // Container becomes idle + queue.notifyIdle('group1@g.us'); + + // A new user message arrives — resets idleWaiting + queue.sendMessage('group1@g.us', 'hello'); + + // Task enqueued after message reset — should NOT preempt (agent is working) + const writeFileSync = vi.mocked(fs.default.writeFileSync); + writeFileSync.mockClear(); + + const taskFn = vi.fn(async () => {}); + queue.enqueueTask('group1@g.us', 'task-1', taskFn); + + const closeWrites = writeFileSync.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].endsWith('_close'), + ); + expect(closeWrites).toHaveLength(0); + + resolveProcess!(); + await vi.advanceTimersByTimeAsync(10); + }); + + it('sendMessage returns false for task containers so user messages queue up', async () => { + let resolveTask: () => void; + + const taskFn = vi.fn(async () => { + await new Promise((resolve) => { + resolveTask = resolve; + }); + }); + + // Start a task (sets isTaskContainer = true) + queue.enqueueTask('group1@g.us', 'task-1', taskFn); + await vi.advanceTimersByTimeAsync(10); + queue.registerProcess( + 'group1@g.us', + {} as any, + 'container-1', + 'test-group', + ); + + // sendMessage should return false — user messages must not go to task containers + const result = queue.sendMessage('group1@g.us', 'hello'); + expect(result).toBe(false); + + resolveTask!(); + await vi.advanceTimersByTimeAsync(10); + }); + + it('preempts when idle arrives with pending tasks', async () => { + const fs = await import('fs'); + let resolveProcess: () => void; + + const processMessages = vi.fn(async () => { + await new Promise((resolve) => { + resolveProcess = resolve; + }); + return true; + }); + + queue.setProcessMessagesFn(processMessages); + + // Start processing + queue.enqueueMessageCheck('group1@g.us'); + await vi.advanceTimersByTimeAsync(10); + + // Register process and enqueue a task (no idle yet — no preemption) + queue.registerProcess( + 'group1@g.us', + {} as any, + 'container-1', + 'test-group', + ); + + const writeFileSync = vi.mocked(fs.default.writeFileSync); + writeFileSync.mockClear(); + + const taskFn = vi.fn(async () => {}); + queue.enqueueTask('group1@g.us', 'task-1', taskFn); + + let closeWrites = writeFileSync.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].endsWith('_close'), + ); + expect(closeWrites).toHaveLength(0); + + // Now container becomes idle — should preempt because task is pending + writeFileSync.mockClear(); + queue.notifyIdle('group1@g.us'); + + closeWrites = writeFileSync.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].endsWith('_close'), + ); + expect(closeWrites).toHaveLength(1); + + resolveProcess!(); + await vi.advanceTimersByTimeAsync(10); + }); + + describe('isActive', () => { + it('returns false for unknown groups', () => { + expect(queue.isActive('unknown@g.us')).toBe(false); + }); + + it('returns true when group has active container', async () => { + let resolve: () => void; + const block = new Promise((r) => { + resolve = r; + }); + + queue.setProcessMessagesFn(async () => { + await block; + return true; + }); + queue.enqueueMessageCheck('group@g.us'); + + // Let the microtask start running + await vi.advanceTimersByTimeAsync(0); + expect(queue.isActive('group@g.us')).toBe(true); + + resolve!(); + await vi.advanceTimersByTimeAsync(0); + }); + }); +}); diff --git a/.claude/skills/add-reactions/modify/src/index.ts b/.claude/skills/add-reactions/modify/src/index.ts new file mode 100644 index 0000000..15e63db --- /dev/null +++ b/.claude/skills/add-reactions/modify/src/index.ts @@ -0,0 +1,726 @@ +import fs from 'fs'; +import path from 'path'; + +import { + ASSISTANT_NAME, + IDLE_TIMEOUT, + POLL_INTERVAL, + TRIGGER_PATTERN, +} from './config.js'; +import './channels/index.js'; +import { + getChannelFactory, + getRegisteredChannelNames, +} from './channels/registry.js'; +import { + ContainerOutput, + runContainerAgent, + writeGroupsSnapshot, + writeTasksSnapshot, +} from './container-runner.js'; +import { + cleanupOrphans, + ensureContainerRuntimeRunning, +} from './container-runtime.js'; +import { + getAllChats, + getAllRegisteredGroups, + getAllSessions, + getAllTasks, + getMessageFromMe, + getMessagesSince, + getNewMessages, + getRouterState, + initDatabase, + setRegisteredGroup, + setRouterState, + setSession, + storeChatMetadata, + storeMessage, +} from './db.js'; +import { GroupQueue } from './group-queue.js'; +import { resolveGroupFolderPath } from './group-folder.js'; +import { startIpcWatcher } from './ipc.js'; +import { findChannel, formatMessages, formatOutbound } from './router.js'; +import { + isSenderAllowed, + isTriggerAllowed, + loadSenderAllowlist, + shouldDropMessage, +} from './sender-allowlist.js'; +import { startSchedulerLoop } from './task-scheduler.js'; +import { Channel, NewMessage, RegisteredGroup } from './types.js'; +import { StatusTracker } from './status-tracker.js'; +import { logger } from './logger.js'; + +// Re-export for backwards compatibility during refactor +export { escapeXml, formatMessages } from './router.js'; + +let lastTimestamp = ''; +let sessions: Record = {}; +let registeredGroups: Record = {}; +let lastAgentTimestamp: Record = {}; +// Tracks cursor value before messages were piped to an active container. +// Used to roll back if the container dies after piping. +let cursorBeforePipe: Record = {}; +let messageLoopRunning = false; + +const channels: Channel[] = []; +const queue = new GroupQueue(); +let statusTracker: StatusTracker; + +function loadState(): void { + lastTimestamp = getRouterState('last_timestamp') || ''; + const agentTs = getRouterState('last_agent_timestamp'); + try { + lastAgentTimestamp = agentTs ? JSON.parse(agentTs) : {}; + } catch { + logger.warn('Corrupted last_agent_timestamp in DB, resetting'); + lastAgentTimestamp = {}; + } + const pipeCursor = getRouterState('cursor_before_pipe'); + try { + cursorBeforePipe = pipeCursor ? JSON.parse(pipeCursor) : {}; + } catch { + logger.warn('Corrupted cursor_before_pipe in DB, resetting'); + cursorBeforePipe = {}; + } + sessions = getAllSessions(); + registeredGroups = getAllRegisteredGroups(); + logger.info( + { groupCount: Object.keys(registeredGroups).length }, + 'State loaded', + ); +} + +function saveState(): void { + setRouterState('last_timestamp', lastTimestamp); + setRouterState('last_agent_timestamp', JSON.stringify(lastAgentTimestamp)); + setRouterState('cursor_before_pipe', JSON.stringify(cursorBeforePipe)); +} + +function registerGroup(jid: string, group: RegisteredGroup): void { + let groupDir: string; + try { + groupDir = resolveGroupFolderPath(group.folder); + } catch (err) { + logger.warn( + { jid, folder: group.folder, err }, + 'Rejecting group registration with invalid folder', + ); + return; + } + + registeredGroups[jid] = group; + setRegisteredGroup(jid, group); + + // Create group folder + fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true }); + + logger.info( + { jid, name: group.name, folder: group.folder }, + 'Group registered', + ); +} + +/** + * Get available groups list for the agent. + * Returns groups ordered by most recent activity. + */ +export function getAvailableGroups(): import('./container-runner.js').AvailableGroup[] { + const chats = getAllChats(); + const registeredJids = new Set(Object.keys(registeredGroups)); + + return chats + .filter((c) => c.jid !== '__group_sync__' && c.is_group) + .map((c) => ({ + jid: c.jid, + name: c.name, + lastActivity: c.last_message_time, + isRegistered: registeredJids.has(c.jid), + })); +} + +/** @internal - exported for testing */ +export function _setRegisteredGroups( + groups: Record, +): void { + registeredGroups = groups; +} + +/** + * Process all pending messages for a group. + * Called by the GroupQueue when it's this group's turn. + */ +async function processGroupMessages(chatJid: string): Promise { + const group = registeredGroups[chatJid]; + if (!group) return true; + + const channel = findChannel(channels, chatJid); + if (!channel) { + logger.warn({ chatJid }, 'No channel owns JID, skipping messages'); + return true; + } + + const isMainGroup = group.isMain === true; + + const sinceTimestamp = lastAgentTimestamp[chatJid] || ''; + const missedMessages = getMessagesSince( + chatJid, + sinceTimestamp, + ASSISTANT_NAME, + ); + + if (missedMessages.length === 0) return true; + + // For non-main groups, check if trigger is required and present + if (!isMainGroup && group.requiresTrigger !== false) { + const allowlistCfg = loadSenderAllowlist(); + const hasTrigger = missedMessages.some( + (m) => + TRIGGER_PATTERN.test(m.content.trim()) && + (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)), + ); + if (!hasTrigger) return true; + } + + // Ensure all user messages are tracked — recovery messages enter processGroupMessages + // directly via the queue, bypassing startMessageLoop where markReceived normally fires. + // markReceived is idempotent (rejects duplicates), so this is safe for normal-path messages too. + for (const msg of missedMessages) { + statusTracker.markReceived(msg.id, chatJid, false); + } + + // Mark all user messages as thinking (container is spawning) + const userMessages = missedMessages.filter( + (m) => !m.is_from_me && !m.is_bot_message, + ); + for (const msg of userMessages) { + statusTracker.markThinking(msg.id); + } + + const prompt = formatMessages(missedMessages); + + // Advance cursor so the piping path in startMessageLoop won't re-fetch + // these messages. Save the old cursor so we can roll back on error. + const previousCursor = lastAgentTimestamp[chatJid] || ''; + lastAgentTimestamp[chatJid] = + missedMessages[missedMessages.length - 1].timestamp; + saveState(); + + logger.info( + { group: group.name, messageCount: missedMessages.length }, + 'Processing messages', + ); + + // Track idle timer for closing stdin when agent is idle + let idleTimer: ReturnType | null = null; + + const resetIdleTimer = () => { + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + logger.debug( + { group: group.name }, + 'Idle timeout, closing container stdin', + ); + queue.closeStdin(chatJid); + }, IDLE_TIMEOUT); + }; + + await channel.setTyping?.(chatJid, true); + let hadError = false; + let outputSentToUser = false; + let firstOutputSeen = false; + + const output = await runAgent(group, prompt, chatJid, async (result) => { + // Streaming output callback — called for each agent result + if (result.result) { + if (!firstOutputSeen) { + firstOutputSeen = true; + for (const um of userMessages) { + statusTracker.markWorking(um.id); + } + } + const raw = + typeof result.result === 'string' + ? result.result + : JSON.stringify(result.result); + // Strip ... blocks — agent uses these for internal reasoning + const text = raw.replace(/[\s\S]*?<\/internal>/g, '').trim(); + logger.info({ group: group.name }, `Agent output: ${raw.slice(0, 200)}`); + if (text) { + await channel.sendMessage(chatJid, text); + outputSentToUser = true; + } + // Only reset idle timer on actual results, not session-update markers (result: null) + resetIdleTimer(); + } + + if (result.status === 'success') { + statusTracker.markAllDone(chatJid); + queue.notifyIdle(chatJid); + } + + if (result.status === 'error') { + hadError = true; + } + }); + + await channel.setTyping?.(chatJid, false); + if (idleTimer) clearTimeout(idleTimer); + + if (output === 'error' || hadError) { + if (outputSentToUser) { + // Output was sent for the initial batch, so don't roll those back. + // But if messages were piped AFTER that output, roll back to recover them. + if (cursorBeforePipe[chatJid]) { + lastAgentTimestamp[chatJid] = cursorBeforePipe[chatJid]; + delete cursorBeforePipe[chatJid]; + saveState(); + logger.warn( + { group: group.name }, + 'Agent error after output, rolled back piped messages for retry', + ); + statusTracker.markAllFailed(chatJid, 'Task crashed — retrying.'); + return false; + } + logger.warn( + { group: group.name }, + 'Agent error after output was sent, no piped messages to recover', + ); + statusTracker.markAllDone(chatJid); + return true; + } + // No output sent — roll back everything so the full batch is retried + lastAgentTimestamp[chatJid] = previousCursor; + delete cursorBeforePipe[chatJid]; + saveState(); + logger.warn( + { group: group.name }, + 'Agent error, rolled back message cursor for retry', + ); + statusTracker.markAllFailed(chatJid, 'Task crashed — retrying.'); + return false; + } + + // Success — clear pipe tracking (markAllDone already fired in streaming callback) + delete cursorBeforePipe[chatJid]; + saveState(); + return true; +} + +async function runAgent( + group: RegisteredGroup, + prompt: string, + chatJid: string, + onOutput?: (output: ContainerOutput) => Promise, +): Promise<'success' | 'error'> { + const isMain = group.isMain === true; + const sessionId = sessions[group.folder]; + + // Update tasks snapshot for container to read (filtered by group) + const tasks = getAllTasks(); + writeTasksSnapshot( + group.folder, + isMain, + tasks.map((t) => ({ + id: t.id, + groupFolder: t.group_folder, + prompt: t.prompt, + schedule_type: t.schedule_type, + schedule_value: t.schedule_value, + status: t.status, + 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)), + ); + + // Wrap onOutput to track session ID from streamed results + const wrappedOnOutput = onOutput + ? async (output: ContainerOutput) => { + if (output.newSessionId) { + sessions[group.folder] = output.newSessionId; + setSession(group.folder, output.newSessionId); + } + await onOutput(output); + } + : undefined; + + try { + const output = await runContainerAgent( + group, + { + prompt, + sessionId, + groupFolder: group.folder, + chatJid, + isMain, + assistantName: ASSISTANT_NAME, + }, + (proc, containerName) => + queue.registerProcess(chatJid, proc, containerName, group.folder), + wrappedOnOutput, + ); + + if (output.newSessionId) { + sessions[group.folder] = output.newSessionId; + setSession(group.folder, output.newSessionId); + } + + if (output.status === 'error') { + logger.error( + { group: group.name, error: output.error }, + 'Container agent error', + ); + return 'error'; + } + + return 'success'; + } catch (err) { + logger.error({ group: group.name, err }, 'Agent error'); + return 'error'; + } +} + +async function startMessageLoop(): Promise { + if (messageLoopRunning) { + logger.debug('Message loop already running, skipping duplicate start'); + return; + } + messageLoopRunning = true; + + logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`); + + while (true) { + try { + const jids = Object.keys(registeredGroups); + const { messages, newTimestamp } = getNewMessages( + jids, + lastTimestamp, + ASSISTANT_NAME, + ); + + if (messages.length > 0) { + logger.info({ count: messages.length }, 'New messages'); + + // Advance the "seen" cursor for all messages immediately + lastTimestamp = newTimestamp; + saveState(); + + // Deduplicate by group + const messagesByGroup = new Map(); + for (const msg of messages) { + const existing = messagesByGroup.get(msg.chat_jid); + if (existing) { + existing.push(msg); + } else { + messagesByGroup.set(msg.chat_jid, [msg]); + } + } + + for (const [chatJid, groupMessages] of messagesByGroup) { + const group = registeredGroups[chatJid]; + if (!group) continue; + + const channel = findChannel(channels, chatJid); + if (!channel) { + logger.warn({ chatJid }, 'No channel owns JID, skipping messages'); + continue; + } + + const isMainGroup = group.isMain === true; + const needsTrigger = !isMainGroup && group.requiresTrigger !== false; + + // For non-main groups, only act on trigger messages. + // Non-trigger messages accumulate in DB and get pulled as + // context when a trigger eventually arrives. + if (needsTrigger) { + const allowlistCfg = loadSenderAllowlist(); + const hasTrigger = groupMessages.some( + (m) => + TRIGGER_PATTERN.test(m.content.trim()) && + (m.is_from_me || + isTriggerAllowed(chatJid, m.sender, allowlistCfg)), + ); + if (!hasTrigger) continue; + } + + // Mark each user message as received (status emoji) + for (const msg of groupMessages) { + if (!msg.is_from_me && !msg.is_bot_message) { + statusTracker.markReceived(msg.id, chatJid, false); + } + } + + // Pull all messages since lastAgentTimestamp so non-trigger + // context that accumulated between triggers is included. + const allPending = getMessagesSince( + chatJid, + lastAgentTimestamp[chatJid] || '', + ASSISTANT_NAME, + ); + const messagesToSend = + allPending.length > 0 ? allPending : groupMessages; + const formatted = formatMessages(messagesToSend); + + if (queue.sendMessage(chatJid, formatted)) { + logger.debug( + { chatJid, count: messagesToSend.length }, + 'Piped messages to active container', + ); + // Mark new user messages as thinking (only groupMessages were markReceived'd; + // accumulated allPending context messages are untracked and would no-op) + for (const msg of groupMessages) { + if (!msg.is_from_me && !msg.is_bot_message) { + statusTracker.markThinking(msg.id); + } + } + // Save cursor before first pipe so we can roll back if container dies + if (!cursorBeforePipe[chatJid]) { + cursorBeforePipe[chatJid] = lastAgentTimestamp[chatJid] || ''; + } + lastAgentTimestamp[chatJid] = + messagesToSend[messagesToSend.length - 1].timestamp; + saveState(); + // Show typing indicator while the container processes the piped message + channel + .setTyping?.(chatJid, true) + ?.catch((err) => + logger.warn({ chatJid, err }, 'Failed to set typing indicator'), + ); + } else { + // No active container — enqueue for a new one + queue.enqueueMessageCheck(chatJid); + } + } + } + } catch (err) { + logger.error({ err }, 'Error in message loop'); + } + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL)); + } +} + +/** + * Startup recovery: check for unprocessed messages in registered groups. + * Handles crash between advancing lastTimestamp and processing messages. + */ +function recoverPendingMessages(): void { + // Roll back any piped-message cursors that were persisted before a crash. + // This ensures messages piped to a now-dead container are re-fetched. + // IMPORTANT: Only roll back if the container is no longer running — rolling + // back while the container is alive causes duplicate processing. + let rolledBack = false; + for (const [chatJid, savedCursor] of Object.entries(cursorBeforePipe)) { + if (queue.isActive(chatJid)) { + logger.debug( + { chatJid }, + 'Recovery: skipping piped-cursor rollback, container still active', + ); + continue; + } + logger.info( + { chatJid, rolledBackTo: savedCursor }, + 'Recovery: rolling back piped-message cursor', + ); + lastAgentTimestamp[chatJid] = savedCursor; + delete cursorBeforePipe[chatJid]; + rolledBack = true; + } + if (rolledBack) { + saveState(); + } + + for (const [chatJid, group] of Object.entries(registeredGroups)) { + const sinceTimestamp = lastAgentTimestamp[chatJid] || ''; + const pending = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME); + if (pending.length > 0) { + logger.info( + { group: group.name, pendingCount: pending.length }, + 'Recovery: found unprocessed messages', + ); + queue.enqueueMessageCheck(chatJid); + } + } +} + +function ensureContainerSystemRunning(): void { + ensureContainerRuntimeRunning(); + cleanupOrphans(); +} + +async function main(): Promise { + ensureContainerSystemRunning(); + initDatabase(); + logger.info('Database initialized'); + loadState(); + + // Graceful shutdown handlers + const shutdown = async (signal: string) => { + logger.info({ signal }, 'Shutdown signal received'); + await queue.shutdown(10000); + for (const ch of channels) await ch.disconnect(); + await statusTracker.shutdown(); + process.exit(0); + }; + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); + + // Channel callbacks (shared by all channels) + const channelOpts = { + onMessage: (chatJid: string, msg: NewMessage) => { + // Sender allowlist drop mode: discard messages from denied senders before storing + if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) { + const cfg = loadSenderAllowlist(); + if ( + shouldDropMessage(chatJid, cfg) && + !isSenderAllowed(chatJid, msg.sender, cfg) + ) { + if (cfg.logDenied) { + logger.debug( + { chatJid, sender: msg.sender }, + 'sender-allowlist: dropping message (drop mode)', + ); + } + return; + } + } + storeMessage(msg); + }, + onChatMetadata: ( + chatJid: string, + timestamp: string, + name?: string, + channel?: string, + isGroup?: boolean, + ) => storeChatMetadata(chatJid, timestamp, name, channel, isGroup), + registeredGroups: () => registeredGroups, + }; + + // Initialize status tracker (uses channels via callbacks, channels don't need to be connected yet) + statusTracker = new StatusTracker({ + sendReaction: async (chatJid, messageKey, emoji) => { + const channel = findChannel(channels, chatJid); + if (!channel?.sendReaction) return; + await channel.sendReaction(chatJid, messageKey, emoji); + }, + sendMessage: async (chatJid, text) => { + const channel = findChannel(channels, chatJid); + if (!channel) return; + await channel.sendMessage(chatJid, text); + }, + isMainGroup: (chatJid) => { + const group = registeredGroups[chatJid]; + return group?.isMain === true; + }, + isContainerAlive: (chatJid) => queue.isActive(chatJid), + }); + + // Create and connect all registered channels. + // Each channel self-registers via the barrel import above. + // Factories return null when credentials are missing, so unconfigured channels are skipped. + for (const channelName of getRegisteredChannelNames()) { + const factory = getChannelFactory(channelName)!; + const channel = factory(channelOpts); + if (!channel) { + logger.warn( + { channel: channelName }, + 'Channel installed but credentials missing — skipping. Check .env or re-run the channel skill.', + ); + continue; + } + channels.push(channel); + await channel.connect(); + } + if (channels.length === 0) { + logger.fatal('No channels connected'); + process.exit(1); + } + + // 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 channel = findChannel(channels, jid); + if (!channel) { + logger.warn({ jid }, 'No channel owns JID, cannot send message'); + return; + } + const text = formatOutbound(rawText); + if (text) await channel.sendMessage(jid, text); + }, + }); + startIpcWatcher({ + sendMessage: (jid, text) => { + const channel = findChannel(channels, jid); + if (!channel) throw new Error(`No channel for JID: ${jid}`); + return channel.sendMessage(jid, text); + }, + sendReaction: async (jid, emoji, messageId) => { + const channel = findChannel(channels, jid); + if (!channel) throw new Error(`No channel for JID: ${jid}`); + if (messageId) { + if (!channel.sendReaction) + throw new Error('Channel does not support sendReaction'); + const messageKey = { + id: messageId, + remoteJid: jid, + fromMe: getMessageFromMe(messageId, jid), + }; + await channel.sendReaction(jid, messageKey, emoji); + } else { + if (!channel.reactToLatestMessage) + throw new Error('Channel does not support reactions'); + await channel.reactToLatestMessage(jid, emoji); + } + }, + registeredGroups: () => registeredGroups, + registerGroup, + syncGroups: async (force: boolean) => { + await Promise.all( + channels + .filter((ch) => ch.syncGroups) + .map((ch) => ch.syncGroups!(force)), + ); + }, + getAvailableGroups, + writeGroupsSnapshot: (gf, im, ag, rj) => + writeGroupsSnapshot(gf, im, ag, rj), + statusHeartbeat: () => statusTracker.heartbeatCheck(), + recoverPendingMessages, + }); + // Recover status tracker AFTER channels connect, so recovery reactions + // can actually be sent via the WhatsApp channel. + await statusTracker.recover(); + queue.setProcessMessagesFn(processGroupMessages); + recoverPendingMessages(); + startMessageLoop().catch((err) => { + logger.fatal({ err }, 'Message loop crashed unexpectedly'); + 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); + }); +} diff --git a/.claude/skills/add-reactions/modify/src/ipc-auth.test.ts b/.claude/skills/add-reactions/modify/src/ipc-auth.test.ts new file mode 100644 index 0000000..9637850 --- /dev/null +++ b/.claude/skills/add-reactions/modify/src/ipc-auth.test.ts @@ -0,0 +1,807 @@ +import { describe, it, expect, beforeEach } from 'vitest'; + +import { + _initTestDatabase, + createTask, + getAllTasks, + getRegisteredGroup, + getTaskById, + setRegisteredGroup, +} from './db.js'; +import { processTaskIpc, IpcDeps } from './ipc.js'; +import { RegisteredGroup } from './types.js'; + +// Set up registered groups used across tests +const MAIN_GROUP: RegisteredGroup = { + name: 'Main', + folder: 'main', + trigger: 'always', + added_at: '2024-01-01T00:00:00.000Z', +}; + +const OTHER_GROUP: RegisteredGroup = { + name: 'Other', + folder: 'other-group', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', +}; + +const THIRD_GROUP: RegisteredGroup = { + name: 'Third', + folder: 'third-group', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', +}; + +let groups: Record; +let deps: IpcDeps; + +beforeEach(() => { + _initTestDatabase(); + + groups = { + 'main@g.us': MAIN_GROUP, + 'other@g.us': OTHER_GROUP, + 'third@g.us': THIRD_GROUP, + }; + + // Populate DB as well + setRegisteredGroup('main@g.us', MAIN_GROUP); + setRegisteredGroup('other@g.us', OTHER_GROUP); + setRegisteredGroup('third@g.us', THIRD_GROUP); + + deps = { + sendMessage: async () => {}, + sendReaction: async () => {}, + registeredGroups: () => groups, + registerGroup: (jid, group) => { + groups[jid] = group; + setRegisteredGroup(jid, group); + }, + unregisterGroup: (jid) => { + const existed = jid in groups; + delete groups[jid]; + return existed; + }, + syncGroupMetadata: async () => {}, + getAvailableGroups: () => [], + writeGroupsSnapshot: () => {}, + }; +}); + +// --- schedule_task authorization --- + +describe('schedule_task authorization', () => { + it('main group can schedule for another group', async () => { + await processTaskIpc( + { + type: 'schedule_task', + prompt: 'do something', + schedule_type: 'once', + schedule_value: '2025-06-01T00:00:00.000Z', + targetJid: 'other@g.us', + }, + 'main', + true, + deps, + ); + + // Verify task was created in DB for the other group + const allTasks = getAllTasks(); + expect(allTasks.length).toBe(1); + expect(allTasks[0].group_folder).toBe('other-group'); + }); + + it('non-main group can schedule for itself', async () => { + await processTaskIpc( + { + type: 'schedule_task', + prompt: 'self task', + schedule_type: 'once', + schedule_value: '2025-06-01T00:00:00.000Z', + targetJid: 'other@g.us', + }, + 'other-group', + false, + deps, + ); + + const allTasks = getAllTasks(); + expect(allTasks.length).toBe(1); + expect(allTasks[0].group_folder).toBe('other-group'); + }); + + it('non-main group cannot schedule for another group', async () => { + await processTaskIpc( + { + type: 'schedule_task', + prompt: 'unauthorized', + schedule_type: 'once', + schedule_value: '2025-06-01T00:00:00.000Z', + targetJid: 'main@g.us', + }, + 'other-group', + false, + deps, + ); + + const allTasks = getAllTasks(); + expect(allTasks.length).toBe(0); + }); + + it('rejects schedule_task for unregistered target JID', async () => { + await processTaskIpc( + { + type: 'schedule_task', + prompt: 'no target', + schedule_type: 'once', + schedule_value: '2025-06-01T00:00:00.000Z', + targetJid: 'unknown@g.us', + }, + 'main', + true, + deps, + ); + + const allTasks = getAllTasks(); + expect(allTasks.length).toBe(0); + }); +}); + +// --- pause_task authorization --- + +describe('pause_task authorization', () => { + beforeEach(() => { + createTask({ + id: 'task-main', + group_folder: 'main', + chat_jid: 'main@g.us', + prompt: 'main task', + schedule_type: 'once', + schedule_value: '2025-06-01T00:00:00.000Z', + context_mode: 'isolated', + next_run: '2025-06-01T00:00:00.000Z', + status: 'active', + created_at: '2024-01-01T00:00:00.000Z', + }); + createTask({ + id: 'task-other', + group_folder: 'other-group', + chat_jid: 'other@g.us', + prompt: 'other task', + schedule_type: 'once', + schedule_value: '2025-06-01T00:00:00.000Z', + context_mode: 'isolated', + next_run: '2025-06-01T00:00:00.000Z', + status: 'active', + created_at: '2024-01-01T00:00:00.000Z', + }); + }); + + it('main group can pause any task', async () => { + await processTaskIpc( + { type: 'pause_task', taskId: 'task-other' }, + 'main', + true, + deps, + ); + expect(getTaskById('task-other')!.status).toBe('paused'); + }); + + it('non-main group can pause its own task', async () => { + await processTaskIpc( + { type: 'pause_task', taskId: 'task-other' }, + 'other-group', + false, + deps, + ); + expect(getTaskById('task-other')!.status).toBe('paused'); + }); + + it('non-main group cannot pause another groups task', async () => { + await processTaskIpc( + { type: 'pause_task', taskId: 'task-main' }, + 'other-group', + false, + deps, + ); + expect(getTaskById('task-main')!.status).toBe('active'); + }); +}); + +// --- resume_task authorization --- + +describe('resume_task authorization', () => { + beforeEach(() => { + createTask({ + id: 'task-paused', + group_folder: 'other-group', + chat_jid: 'other@g.us', + prompt: 'paused task', + schedule_type: 'once', + schedule_value: '2025-06-01T00:00:00.000Z', + context_mode: 'isolated', + next_run: '2025-06-01T00:00:00.000Z', + status: 'paused', + created_at: '2024-01-01T00:00:00.000Z', + }); + }); + + it('main group can resume any task', async () => { + await processTaskIpc( + { type: 'resume_task', taskId: 'task-paused' }, + 'main', + true, + deps, + ); + expect(getTaskById('task-paused')!.status).toBe('active'); + }); + + it('non-main group can resume its own task', async () => { + await processTaskIpc( + { type: 'resume_task', taskId: 'task-paused' }, + 'other-group', + false, + deps, + ); + expect(getTaskById('task-paused')!.status).toBe('active'); + }); + + it('non-main group cannot resume another groups task', async () => { + await processTaskIpc( + { type: 'resume_task', taskId: 'task-paused' }, + 'third-group', + false, + deps, + ); + expect(getTaskById('task-paused')!.status).toBe('paused'); + }); +}); + +// --- cancel_task authorization --- + +describe('cancel_task authorization', () => { + it('main group can cancel any task', async () => { + createTask({ + id: 'task-to-cancel', + group_folder: 'other-group', + chat_jid: 'other@g.us', + prompt: 'cancel me', + schedule_type: 'once', + schedule_value: '2025-06-01T00:00:00.000Z', + context_mode: 'isolated', + next_run: null, + status: 'active', + created_at: '2024-01-01T00:00:00.000Z', + }); + + await processTaskIpc( + { type: 'cancel_task', taskId: 'task-to-cancel' }, + 'main', + true, + deps, + ); + expect(getTaskById('task-to-cancel')).toBeUndefined(); + }); + + it('non-main group can cancel its own task', async () => { + createTask({ + id: 'task-own', + group_folder: 'other-group', + chat_jid: 'other@g.us', + prompt: 'my task', + schedule_type: 'once', + schedule_value: '2025-06-01T00:00:00.000Z', + context_mode: 'isolated', + next_run: null, + status: 'active', + created_at: '2024-01-01T00:00:00.000Z', + }); + + await processTaskIpc( + { type: 'cancel_task', taskId: 'task-own' }, + 'other-group', + false, + deps, + ); + expect(getTaskById('task-own')).toBeUndefined(); + }); + + it('non-main group cannot cancel another groups task', async () => { + createTask({ + id: 'task-foreign', + group_folder: 'main', + chat_jid: 'main@g.us', + prompt: 'not yours', + schedule_type: 'once', + schedule_value: '2025-06-01T00:00:00.000Z', + context_mode: 'isolated', + next_run: null, + status: 'active', + created_at: '2024-01-01T00:00:00.000Z', + }); + + await processTaskIpc( + { type: 'cancel_task', taskId: 'task-foreign' }, + 'other-group', + false, + deps, + ); + expect(getTaskById('task-foreign')).toBeDefined(); + }); +}); + +// --- register_group authorization --- + +describe('register_group authorization', () => { + it('non-main group cannot register a group', async () => { + await processTaskIpc( + { + type: 'register_group', + jid: 'new@g.us', + name: 'New Group', + folder: 'new-group', + trigger: '@Andy', + }, + 'other-group', + false, + deps, + ); + + // registeredGroups should not have changed + expect(groups['new@g.us']).toBeUndefined(); + }); + + it('main group cannot register with unsafe folder path', async () => { + await processTaskIpc( + { + type: 'register_group', + jid: 'new@g.us', + name: 'New Group', + folder: '../../outside', + trigger: '@Andy', + }, + 'main', + true, + deps, + ); + + expect(groups['new@g.us']).toBeUndefined(); + }); +}); + +// --- refresh_groups authorization --- + +describe('refresh_groups authorization', () => { + it('non-main group cannot trigger refresh', async () => { + // This should be silently blocked (no crash, no effect) + await processTaskIpc( + { type: 'refresh_groups' }, + 'other-group', + false, + deps, + ); + // If we got here without error, the auth gate worked + }); +}); + +// --- IPC message authorization --- +// Tests the authorization pattern from startIpcWatcher (ipc.ts). +// The logic: isMain || (targetGroup && targetGroup.folder === sourceGroup) + +describe('IPC message authorization', () => { + // Replicate the exact check from the IPC watcher + function isMessageAuthorized( + sourceGroup: string, + isMain: boolean, + targetChatJid: string, + registeredGroups: Record, + ): boolean { + const targetGroup = registeredGroups[targetChatJid]; + return isMain || (!!targetGroup && targetGroup.folder === sourceGroup); + } + + it('main group can send to any group', () => { + expect(isMessageAuthorized('main', true, 'other@g.us', groups)).toBe(true); + expect(isMessageAuthorized('main', true, 'third@g.us', groups)).toBe(true); + }); + + it('non-main group can send to its own chat', () => { + expect( + isMessageAuthorized('other-group', false, 'other@g.us', groups), + ).toBe(true); + }); + + it('non-main group cannot send to another groups chat', () => { + expect(isMessageAuthorized('other-group', false, 'main@g.us', groups)).toBe( + false, + ); + expect( + isMessageAuthorized('other-group', false, 'third@g.us', groups), + ).toBe(false); + }); + + it('non-main group cannot send to unregistered JID', () => { + expect( + isMessageAuthorized('other-group', false, 'unknown@g.us', groups), + ).toBe(false); + }); + + it('main group can send to unregistered JID', () => { + // Main is always authorized regardless of target + expect(isMessageAuthorized('main', true, 'unknown@g.us', groups)).toBe( + true, + ); + }); +}); + +// --- IPC reaction authorization --- +// Same authorization pattern as message sending (ipc.ts lines 104-127). + +describe('IPC reaction authorization', () => { + // Replicate the exact check from the IPC watcher for reactions + function isReactionAuthorized( + sourceGroup: string, + isMain: boolean, + targetChatJid: string, + registeredGroups: Record, + ): boolean { + const targetGroup = registeredGroups[targetChatJid]; + return isMain || (!!targetGroup && targetGroup.folder === sourceGroup); + } + + it('main group can react in any chat', () => { + expect(isReactionAuthorized('main', true, 'other@g.us', groups)).toBe(true); + expect(isReactionAuthorized('main', true, 'third@g.us', groups)).toBe(true); + }); + + it('non-main group can react in its own chat', () => { + expect( + isReactionAuthorized('other-group', false, 'other@g.us', groups), + ).toBe(true); + }); + + it('non-main group cannot react in another groups chat', () => { + expect( + isReactionAuthorized('other-group', false, 'main@g.us', groups), + ).toBe(false); + expect( + isReactionAuthorized('other-group', false, 'third@g.us', groups), + ).toBe(false); + }); + + it('non-main group cannot react in unregistered JID', () => { + expect( + isReactionAuthorized('other-group', false, 'unknown@g.us', groups), + ).toBe(false); + }); +}); + +// --- sendReaction mock is exercised --- +// The sendReaction dep is wired in but was never called in tests. +// These tests verify startIpcWatcher would call it by testing the pattern inline. + +describe('IPC reaction sendReaction integration', () => { + it('sendReaction mock is callable', async () => { + const calls: Array<{ jid: string; emoji: string; messageId?: string }> = []; + deps.sendReaction = async (jid, emoji, messageId) => { + calls.push({ jid, emoji, messageId }); + }; + + // Simulate what processIpcFiles does for a reaction + const data = { + type: 'reaction' as const, + chatJid: 'other@g.us', + emoji: '👍', + messageId: 'msg-123', + }; + const sourceGroup = 'main'; + const isMain = true; + const registeredGroups = deps.registeredGroups(); + const targetGroup = registeredGroups[data.chatJid]; + + if (isMain || (targetGroup && targetGroup.folder === sourceGroup)) { + await deps.sendReaction(data.chatJid, data.emoji, data.messageId); + } + + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ + jid: 'other@g.us', + emoji: '👍', + messageId: 'msg-123', + }); + }); + + it('sendReaction is blocked for unauthorized group', async () => { + const calls: Array<{ jid: string; emoji: string; messageId?: string }> = []; + deps.sendReaction = async (jid, emoji, messageId) => { + calls.push({ jid, emoji, messageId }); + }; + + const data = { + type: 'reaction' as const, + chatJid: 'main@g.us', + emoji: '❤️', + }; + const sourceGroup = 'other-group'; + const isMain = false; + const registeredGroups = deps.registeredGroups(); + const targetGroup = registeredGroups[data.chatJid]; + + if (isMain || (targetGroup && targetGroup.folder === sourceGroup)) { + await deps.sendReaction(data.chatJid, data.emoji); + } + + expect(calls).toHaveLength(0); + }); + + it('sendReaction works without messageId (react to latest)', async () => { + const calls: Array<{ jid: string; emoji: string; messageId?: string }> = []; + deps.sendReaction = async (jid, emoji, messageId) => { + calls.push({ jid, emoji, messageId }); + }; + + const data = { + type: 'reaction' as const, + chatJid: 'other@g.us', + emoji: '🔥', + }; + const sourceGroup = 'other-group'; + const isMain = false; + const registeredGroups = deps.registeredGroups(); + const targetGroup = registeredGroups[data.chatJid]; + + if (isMain || (targetGroup && targetGroup.folder === sourceGroup)) { + await deps.sendReaction(data.chatJid, data.emoji, undefined); + } + + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ + jid: 'other@g.us', + emoji: '🔥', + messageId: undefined, + }); + }); +}); + +// --- schedule_task with cron and interval types --- + +describe('schedule_task schedule types', () => { + it('creates task with cron schedule and computes next_run', async () => { + await processTaskIpc( + { + type: 'schedule_task', + prompt: 'cron task', + schedule_type: 'cron', + schedule_value: '0 9 * * *', // every day at 9am + targetJid: 'other@g.us', + }, + 'main', + true, + deps, + ); + + const tasks = getAllTasks(); + expect(tasks).toHaveLength(1); + expect(tasks[0].schedule_type).toBe('cron'); + expect(tasks[0].next_run).toBeTruthy(); + // next_run should be a valid ISO date in the future + expect(new Date(tasks[0].next_run!).getTime()).toBeGreaterThan( + Date.now() - 60000, + ); + }); + + it('rejects invalid cron expression', async () => { + await processTaskIpc( + { + type: 'schedule_task', + prompt: 'bad cron', + schedule_type: 'cron', + schedule_value: 'not a cron', + targetJid: 'other@g.us', + }, + 'main', + true, + deps, + ); + + expect(getAllTasks()).toHaveLength(0); + }); + + it('creates task with interval schedule', async () => { + const before = Date.now(); + + await processTaskIpc( + { + type: 'schedule_task', + prompt: 'interval task', + schedule_type: 'interval', + schedule_value: '3600000', // 1 hour + targetJid: 'other@g.us', + }, + 'main', + true, + deps, + ); + + const tasks = getAllTasks(); + expect(tasks).toHaveLength(1); + expect(tasks[0].schedule_type).toBe('interval'); + // next_run should be ~1 hour from now + const nextRun = new Date(tasks[0].next_run!).getTime(); + expect(nextRun).toBeGreaterThanOrEqual(before + 3600000 - 1000); + expect(nextRun).toBeLessThanOrEqual(Date.now() + 3600000 + 1000); + }); + + it('rejects invalid interval (non-numeric)', async () => { + await processTaskIpc( + { + type: 'schedule_task', + prompt: 'bad interval', + schedule_type: 'interval', + schedule_value: 'abc', + targetJid: 'other@g.us', + }, + 'main', + true, + deps, + ); + + expect(getAllTasks()).toHaveLength(0); + }); + + it('rejects invalid interval (zero)', async () => { + await processTaskIpc( + { + type: 'schedule_task', + prompt: 'zero interval', + schedule_type: 'interval', + schedule_value: '0', + targetJid: 'other@g.us', + }, + 'main', + true, + deps, + ); + + expect(getAllTasks()).toHaveLength(0); + }); + + it('rejects invalid once timestamp', async () => { + await processTaskIpc( + { + type: 'schedule_task', + prompt: 'bad once', + schedule_type: 'once', + schedule_value: 'not-a-date', + targetJid: 'other@g.us', + }, + 'main', + true, + deps, + ); + + expect(getAllTasks()).toHaveLength(0); + }); +}); + +// --- context_mode defaulting --- + +describe('schedule_task context_mode', () => { + it('accepts context_mode=group', async () => { + await processTaskIpc( + { + type: 'schedule_task', + prompt: 'group context', + schedule_type: 'once', + schedule_value: '2025-06-01T00:00:00.000Z', + context_mode: 'group', + targetJid: 'other@g.us', + }, + 'main', + true, + deps, + ); + + const tasks = getAllTasks(); + expect(tasks[0].context_mode).toBe('group'); + }); + + it('accepts context_mode=isolated', async () => { + await processTaskIpc( + { + type: 'schedule_task', + prompt: 'isolated context', + schedule_type: 'once', + schedule_value: '2025-06-01T00:00:00.000Z', + context_mode: 'isolated', + targetJid: 'other@g.us', + }, + 'main', + true, + deps, + ); + + const tasks = getAllTasks(); + expect(tasks[0].context_mode).toBe('isolated'); + }); + + it('defaults invalid context_mode to isolated', async () => { + await processTaskIpc( + { + type: 'schedule_task', + prompt: 'bad context', + schedule_type: 'once', + schedule_value: '2025-06-01T00:00:00.000Z', + context_mode: 'bogus' as any, + targetJid: 'other@g.us', + }, + 'main', + true, + deps, + ); + + const tasks = getAllTasks(); + expect(tasks[0].context_mode).toBe('isolated'); + }); + + it('defaults missing context_mode to isolated', async () => { + await processTaskIpc( + { + type: 'schedule_task', + prompt: 'no context mode', + schedule_type: 'once', + schedule_value: '2025-06-01T00:00:00.000Z', + targetJid: 'other@g.us', + }, + 'main', + true, + deps, + ); + + const tasks = getAllTasks(); + expect(tasks[0].context_mode).toBe('isolated'); + }); +}); + +// --- register_group success path --- + +describe('register_group success', () => { + it('main group can register a new group', async () => { + await processTaskIpc( + { + type: 'register_group', + jid: 'new@g.us', + name: 'New Group', + folder: 'new-group', + trigger: '@Andy', + }, + 'main', + true, + deps, + ); + + // Verify group was registered in DB + const group = getRegisteredGroup('new@g.us'); + expect(group).toBeDefined(); + expect(group!.name).toBe('New Group'); + expect(group!.folder).toBe('new-group'); + expect(group!.trigger).toBe('@Andy'); + }); + + it('register_group rejects request with missing fields', async () => { + await processTaskIpc( + { + type: 'register_group', + jid: 'partial@g.us', + name: 'Partial', + // missing folder and trigger + }, + 'main', + true, + deps, + ); + + expect(getRegisteredGroup('partial@g.us')).toBeUndefined(); + }); +}); diff --git a/.claude/skills/add-reactions/modify/src/ipc.ts b/.claude/skills/add-reactions/modify/src/ipc.ts new file mode 100644 index 0000000..4681092 --- /dev/null +++ b/.claude/skills/add-reactions/modify/src/ipc.ts @@ -0,0 +1,446 @@ +import fs from 'fs'; +import path from 'path'; + +import { CronExpressionParser } from 'cron-parser'; + +import { DATA_DIR, IPC_POLL_INTERVAL, TIMEZONE } from './config.js'; +import { AvailableGroup } from './container-runner.js'; +import { createTask, deleteTask, getTaskById, updateTask } from './db.js'; +import { isValidGroupFolder } from './group-folder.js'; +import { logger } from './logger.js'; +import { RegisteredGroup } from './types.js'; + +export interface IpcDeps { + sendMessage: (jid: string, text: string) => Promise; + sendReaction?: ( + jid: string, + emoji: string, + messageId?: string, + ) => Promise; + registeredGroups: () => Record; + registerGroup: (jid: string, group: RegisteredGroup) => void; + syncGroups: (force: boolean) => Promise; + getAvailableGroups: () => AvailableGroup[]; + writeGroupsSnapshot: ( + groupFolder: string, + isMain: boolean, + availableGroups: AvailableGroup[], + registeredJids: Set, + ) => void; + statusHeartbeat?: () => void; + recoverPendingMessages?: () => void; +} + +let ipcWatcherRunning = false; +const RECOVERY_INTERVAL_MS = 60_000; + +export function startIpcWatcher(deps: IpcDeps): 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 }); + let lastRecoveryTime = Date.now(); + + 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; + } + + const registeredGroups = deps.registeredGroups(); + + // Build folder→isMain lookup from registered groups + const folderIsMain = new Map(); + for (const group of Object.values(registeredGroups)) { + if (group.isMain) folderIsMain.set(group.folder, true); + } + + for (const sourceGroup of groupFolders) { + const isMain = folderIsMain.get(sourceGroup) === true; + 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 deps.sendMessage(data.chatJid, data.text); + logger.info( + { chatJid: data.chatJid, sourceGroup }, + 'IPC message sent', + ); + } else { + logger.warn( + { chatJid: data.chatJid, sourceGroup }, + 'Unauthorized IPC message attempt blocked', + ); + } + } else if ( + data.type === 'reaction' && + data.chatJid && + data.emoji && + deps.sendReaction + ) { + const targetGroup = registeredGroups[data.chatJid]; + if ( + isMain || + (targetGroup && targetGroup.folder === sourceGroup) + ) { + try { + await deps.sendReaction( + data.chatJid, + data.emoji, + data.messageId, + ); + logger.info( + { chatJid: data.chatJid, emoji: data.emoji, sourceGroup }, + 'IPC reaction sent', + ); + } catch (err) { + logger.error( + { + chatJid: data.chatJid, + emoji: data.emoji, + sourceGroup, + err, + }, + 'IPC reaction failed', + ); + } + } else { + logger.warn( + { chatJid: data.chatJid, sourceGroup }, + 'Unauthorized IPC reaction 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, deps); + 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'); + } + } + + // Status emoji heartbeat — detect dead containers with stale emoji state + deps.statusHeartbeat?.(); + + // Periodic message recovery — catch stuck messages after retry exhaustion or pipeline stalls + const now = Date.now(); + if (now - lastRecoveryTime >= RECOVERY_INTERVAL_MS) { + lastRecoveryTime = now; + deps.recoverPendingMessages?.(); + } + + setTimeout(processIpcFiles, IPC_POLL_INTERVAL); + }; + + processIpcFiles(); + logger.info('IPC watcher started (per-group namespaces)'); +} + +export 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; + requiresTrigger?: boolean; + containerConfig?: RegisteredGroup['containerConfig']; + }, + sourceGroup: string, // Verified identity from IPC directory + isMain: boolean, // Verified from directory path + deps: IpcDeps, +): Promise { + const registeredGroups = deps.registeredGroups(); + + 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 deps.syncGroups(true); + // Write updated snapshot immediately + const availableGroups = deps.getAvailableGroups(); + deps.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) { + if (!isValidGroupFolder(data.folder)) { + logger.warn( + { sourceGroup, folder: data.folder }, + 'Invalid register_group request - unsafe folder name', + ); + break; + } + // Defense in depth: agent cannot set isMain via IPC + deps.registerGroup(data.jid, { + name: data.name, + folder: data.folder, + trigger: data.trigger, + added_at: new Date().toISOString(), + containerConfig: data.containerConfig, + requiresTrigger: data.requiresTrigger, + }); + } else { + logger.warn( + { data }, + 'Invalid register_group request - missing required fields', + ); + } + break; + + default: + logger.warn({ type: data.type }, 'Unknown IPC task type'); + } +} diff --git a/.claude/skills/add-reactions/modify/src/types.ts b/.claude/skills/add-reactions/modify/src/types.ts new file mode 100644 index 0000000..1542408 --- /dev/null +++ b/.claude/skills/add-reactions/modify/src/types.ts @@ -0,0 +1,111 @@ +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 +} + +/** + * Mount Allowlist - Security configuration for additional mounts + * This file should be stored at ~/.config/nanoclaw/mount-allowlist.json + * and is NOT mounted into any container, making it tamper-proof from agents. + */ +export interface MountAllowlist { + // Directories that can be mounted into containers + allowedRoots: AllowedRoot[]; + // Glob patterns for paths that should never be mounted (e.g., ".ssh", ".gnupg") + blockedPatterns: string[]; + // If true, non-main groups can only mount read-only regardless of config + nonMainReadOnly: boolean; +} + +export interface AllowedRoot { + // Absolute path or ~ for home (e.g., "~/projects", "/var/repos") + path: string; + // Whether read-write mounts are allowed under this root + allowReadWrite: boolean; + // Optional description for documentation + description?: string; +} + +export interface ContainerConfig { + additionalMounts?: AdditionalMount[]; + timeout?: number; // Default: 300000 (5 minutes) +} + +export interface RegisteredGroup { + name: string; + folder: string; + trigger: string; + added_at: string; + containerConfig?: ContainerConfig; + requiresTrigger?: boolean; // Default: true for groups, false for solo chats +} + +export interface NewMessage { + id: string; + chat_jid: string; + sender: string; + sender_name: string; + content: string; + timestamp: string; + is_from_me?: boolean; + is_bot_message?: boolean; +} + +export interface ScheduledTask { + id: string; + group_folder: string; + chat_jid: string; + prompt: string; + schedule_type: 'cron' | 'interval' | 'once'; + schedule_value: string; + context_mode: 'group' | 'isolated'; + next_run: string | null; + last_run: string | null; + last_result: string | null; + status: 'active' | 'paused' | 'completed'; + created_at: string; +} + +export interface TaskRunLog { + task_id: string; + run_at: string; + duration_ms: number; + status: 'success' | 'error'; + result: string | null; + error: string | null; +} + +// --- Channel abstraction --- + +export interface Channel { + name: string; + connect(): Promise; + sendMessage(jid: string, text: string): Promise; + isConnected(): boolean; + ownsJid(jid: string): boolean; + disconnect(): Promise; + // Optional: typing indicator. Channels that support it implement it. + setTyping?(jid: string, isTyping: boolean): Promise; + // Optional: reaction support + sendReaction?( + chatJid: string, + messageKey: { id: string; remoteJid: string; fromMe?: boolean; participant?: string }, + emoji: string + ): Promise; + reactToLatestMessage?(chatJid: string, emoji: string): Promise; +} + +// Callback type that channels use to deliver inbound messages +export type OnInboundMessage = (chatJid: string, message: NewMessage) => void; + +// Callback for chat metadata discovery. +// name is optional — channels that deliver names inline (Telegram) pass it here; +// channels that sync names separately (WhatsApp syncGroupMetadata) omit it. +export type OnChatMetadata = ( + chatJid: string, + timestamp: string, + name?: string, + channel?: string, + isGroup?: boolean, +) => void; From a689a18dfaec38cba3dd8ba00476be37d578de30 Mon Sep 17 00:00:00 2001 From: glifocat Date: Sun, 8 Mar 2026 20:43:21 +0100 Subject: [PATCH 3/4] fix: close task container promptly when agent uses IPC-only messaging (#840) Scheduled tasks that send messages via send_message (IPC) instead of returning text as result left the container idle for ~30 minutes until the hard timeout killed it (exit 137). This blocked new messages for the group during that window. Root cause: scheduleClose() was only called inside the `if (streamedOutput.result)` branch. Tasks that communicate solely through IPC (e.g. heartbeat check-ins) complete with result=null, so the 10s close timer was never set. Fix: also call scheduleClose() on status==='success', covering both result-based and IPC-only task completions. Co-authored-by: Claude Opus 4.6 --- src/task-scheduler.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index f216e12..d0abd2e 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -191,6 +191,7 @@ async function runTask( } if (streamedOutput.status === 'success') { deps.queue.notifyIdle(task.chat_jid); + scheduleClose(); // Close promptly even when result is null (e.g. IPC-only tasks) } if (streamedOutput.status === 'error') { error = streamedOutput.error || 'Unknown error'; From 4ccc5c57f2d543edc5164e9cac928e4d57590c48 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 8 Mar 2026 19:43:31 +0000 Subject: [PATCH 4/4] chore: bump version to 1.2.11 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4e6b681..473a708 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.10", + "version": "1.2.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.10", + "version": "1.2.11", "dependencies": { "better-sqlite3": "^11.8.1", "cron-parser": "^5.5.0", diff --git a/package.json b/package.json index f4863e4..d885c40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.10", + "version": "1.2.11", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js",