feat(skill): add WhatsApp reactions skill (emoji reactions + status tracker) (#509)
* feat(skill): add reactions skill (emoji reactions + status tracker) * refactor(reactions): minimize overlays per upstream review Address gavrielc's review on qwibitai/nanoclaw#509: - SKILL.md: remove all inline code, follow add-telegram/add-whatsapp pattern (465→79 lines) - Rebuild overlays as minimal deltas against upstream/main base - ipc-mcp-stdio.ts: upstream base + only react_to_message tool (8% delta) - ipc.ts: upstream base + only reactions delta (14% delta) - group-queue.test.ts: upstream base + isActive tests only (5% delta) - Remove group-queue.ts overlay (isActive provided by container-hardening) - Remove group-queue.ts from manifest modifies list Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
5b2bafd7bb
commit
ab9abbb21a
103
.claude/skills/add-reactions/SKILL.md
Normal file
103
.claude/skills/add-reactions/SKILL.md
Normal file
@@ -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
|
||||
@@ -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 = '<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 |
|
||||
@@ -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();
|
||||
}
|
||||
450
.claude/skills/add-reactions/add/src/status-tracker.test.ts
Normal file
450
.claude/skills/add-reactions/add/src/status-tracker.test.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('fs', async () => {
|
||||
const actual = await vi.importActual<typeof import('fs')>('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<StatusTrackerDeps['sendReaction']>(async () => {}),
|
||||
sendMessage: vi.fn<StatusTrackerDeps['sendMessage']>(async () => {}),
|
||||
isMainGroup: vi.fn<StatusTrackerDeps['isMainGroup']>((jid) => jid === 'main@s.whatsapp.net'),
|
||||
isContainerAlive: vi.fn<StatusTrackerDeps['isContainerAlive']>(() => true),
|
||||
};
|
||||
}
|
||||
|
||||
describe('StatusTracker', () => {
|
||||
let tracker: StatusTracker;
|
||||
let deps: ReturnType<typeof makeDeps>;
|
||||
|
||||
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<typeof vi.fn>).mockReturnValue(true);
|
||||
(fs.default.readFileSync as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(true);
|
||||
(fs.default.readFileSync as ReturnType<typeof vi.fn>).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);
|
||||
});
|
||||
});
|
||||
});
|
||||
324
.claude/skills/add-reactions/add/src/status-tracker.ts
Normal file
324
.claude/skills/add-reactions/add/src/status-tracker.ts
Normal file
@@ -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<void>;
|
||||
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<void>;
|
||||
sendMessage: (chatJid: string, text: string) => Promise<void>;
|
||||
isMainGroup: (chatJid: string) => boolean;
|
||||
isContainerAlive: (chatJid: string) => boolean;
|
||||
}
|
||||
|
||||
export class StatusTracker {
|
||||
private tracked = new Map<string, TrackedMessage>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, number>();
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
23
.claude/skills/add-reactions/manifest.yaml
Normal file
23
.claude/skills/add-reactions/manifest.yaml
Normal file
@@ -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"
|
||||
@@ -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<string, string | undefined> = {
|
||||
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<string, string | undefined> = {
|
||||
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 <internal> 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);
|
||||
@@ -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<typeof import('fs')>('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<typeof createFakeSocket>;
|
||||
|
||||
// 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>,
|
||||
): 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<string, unknown>) {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
457
.claude/skills/add-reactions/modify/src/channels/whatsapp.ts
Normal file
457
.claude/skills/add-reactions/modify/src/channels/whatsapp.ts
Normal file
@@ -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<string, RegisteredGroup>;
|
||||
}
|
||||
|
||||
export class WhatsAppChannel implements Channel {
|
||||
name = 'whatsapp';
|
||||
|
||||
private sock!: WASocket;
|
||||
private connected = false;
|
||||
private lidToPhoneMap: Record<string, string> = {};
|
||||
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<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.connectInternal(resolve).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
private async connectInternal(onFirstOpen?: () => void): Promise<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
this.connected = false;
|
||||
this.sock?.end(undefined);
|
||||
}
|
||||
|
||||
async setTyping(jid: string, isTyping: boolean): Promise<void> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
715
.claude/skills/add-reactions/modify/src/db.test.ts
Normal file
715
.claude/skills/add-reactions/modify/src/db.test.ts
Normal file
@@ -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([]);
|
||||
});
|
||||
});
|
||||
801
.claude/skills/add-reactions/modify/src/db.ts
Normal file
801
.claude/skills/add-reactions/modify/src/db.ts
Normal file
@@ -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<Reaction & { content: string; sender_name: string; message_timestamp: string }> {
|
||||
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<ScheduledTask, 'last_run' | 'last_result'>,
|
||||
): 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<string, string> {
|
||||
const rows = db
|
||||
.prepare('SELECT group_folder, session_id FROM sessions')
|
||||
.all() as Array<{ group_folder: string; session_id: string }>;
|
||||
const result: Record<string, string> = {};
|
||||
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<string, RegisteredGroup> {
|
||||
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<string, RegisteredGroup> = {};
|
||||
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<string, string>;
|
||||
} | 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
510
.claude/skills/add-reactions/modify/src/group-queue.test.ts
Normal file
510
.claude/skills/add-reactions/modify/src/group-queue.test.ts
Normal file
@@ -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<typeof import('fs')>('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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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);
|
||||
});
|
||||
});
|
||||
});
|
||||
726
.claude/skills/add-reactions/modify/src/index.ts
Normal file
726
.claude/skills/add-reactions/modify/src/index.ts
Normal file
@@ -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<string, string> = {};
|
||||
let registeredGroups: Record<string, RegisteredGroup> = {};
|
||||
let lastAgentTimestamp: Record<string, string> = {};
|
||||
// Tracks cursor value before messages were piped to an active container.
|
||||
// Used to roll back if the container dies after piping.
|
||||
let cursorBeforePipe: Record<string, string> = {};
|
||||
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<string, RegisteredGroup>,
|
||||
): 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<boolean> {
|
||||
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<typeof setTimeout> | 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 <internal>...</internal> blocks — agent uses these for internal reasoning
|
||||
const text = raw.replace(/<internal>[\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<void>,
|
||||
): 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<void> {
|
||||
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<string, NewMessage[]>();
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
807
.claude/skills/add-reactions/modify/src/ipc-auth.test.ts
Normal file
807
.claude/skills/add-reactions/modify/src/ipc-auth.test.ts
Normal file
@@ -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<string, RegisteredGroup>;
|
||||
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<string, RegisteredGroup>,
|
||||
): 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<string, RegisteredGroup>,
|
||||
): 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();
|
||||
});
|
||||
});
|
||||
446
.claude/skills/add-reactions/modify/src/ipc.ts
Normal file
446
.claude/skills/add-reactions/modify/src/ipc.ts
Normal file
@@ -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<void>;
|
||||
sendReaction?: (
|
||||
jid: string,
|
||||
emoji: string,
|
||||
messageId?: string,
|
||||
) => Promise<void>;
|
||||
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||
registerGroup: (jid: string, group: RegisteredGroup) => void;
|
||||
syncGroups: (force: boolean) => Promise<void>;
|
||||
getAvailableGroups: () => AvailableGroup[];
|
||||
writeGroupsSnapshot: (
|
||||
groupFolder: string,
|
||||
isMain: boolean,
|
||||
availableGroups: AvailableGroup[],
|
||||
registeredJids: Set<string>,
|
||||
) => 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<string, boolean>();
|
||||
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<void> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
111
.claude/skills/add-reactions/modify/src/types.ts
Normal file
111
.claude/skills/add-reactions/modify/src/types.ts
Normal file
@@ -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<void>;
|
||||
sendMessage(jid: string, text: string): Promise<void>;
|
||||
isConnected(): boolean;
|
||||
ownsJid(jid: string): boolean;
|
||||
disconnect(): Promise<void>;
|
||||
// Optional: typing indicator. Channels that support it implement it.
|
||||
setTyping?(jid: string, isTyping: boolean): Promise<void>;
|
||||
// Optional: reaction support
|
||||
sendReaction?(
|
||||
chatJid: string,
|
||||
messageKey: { id: string; remoteJid: string; fromMe?: boolean; participant?: string },
|
||||
emoji: string
|
||||
): Promise<void>;
|
||||
reactToLatestMessage?(chatJid: string, emoji: string): Promise<void>;
|
||||
}
|
||||
|
||||
// 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;
|
||||
Reference in New Issue
Block a user