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:
Yonatan Azrielant
2026-03-08 14:02:20 -04:00
committed by GitHub
parent 5b2bafd7bb
commit ab9abbb21a
16 changed files with 6985 additions and 0 deletions

View File

@@ -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 |

View File

@@ -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();
}

View 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);
});
});
});

View 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
}
}
}