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