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; 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): diff --git a/package-lock.json b/package-lock.json index 6bc4160..8e4736a 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 687b875..5bf8a20 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", 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';