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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user