fix(whatsapp): fix encryption retries, session lifecycle, and group defaults (#358)
- Fix getMessage callback to return proto.IMessage for delivery retries - Store incoming messages in messageStore for bidirectional retry - Stop calling sock.logout() on server stop (was destroying credentials) - Suppress noisy stack traces, surface actual disconnect reason - Remove auto-clearing of credentials on connection failures - Raise session failure threshold from 3 to 8 - Default group behavior to disabled when no groups config Written by Cameron and Letta Code
This commit is contained in:
@@ -3,8 +3,8 @@ import { isGroupAllowed, isGroupUserAllowed, resolveGroupAllowedUsers, resolveGr
|
||||
|
||||
describe('group-mode helpers', () => {
|
||||
describe('isGroupAllowed', () => {
|
||||
it('allows when groups config is missing', () => {
|
||||
expect(isGroupAllowed(undefined, ['group-1'])).toBe(true);
|
||||
it('rejects when groups config is missing (no config = no groups)', () => {
|
||||
expect(isGroupAllowed(undefined, ['group-1'])).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when groups config is empty (explicit empty allowlist)', () => {
|
||||
|
||||
@@ -36,7 +36,7 @@ function coerceMode(config?: GroupModeConfig): GroupMode | undefined {
|
||||
* If no groups config exists, this returns true (open allowlist).
|
||||
*/
|
||||
export function isGroupAllowed(groups: GroupsConfig | undefined, keys: string[]): boolean {
|
||||
if (!groups) return true;
|
||||
if (!groups) return false; // No groups config = don't participate in groups
|
||||
if (Object.keys(groups).length === 0) return false;
|
||||
if (Object.hasOwn(groups, '*')) return true;
|
||||
return keys.some((key) => Object.hasOwn(groups, key));
|
||||
|
||||
@@ -5,12 +5,27 @@ describe('applySignalGroupGating', () => {
|
||||
const selfPhoneNumber = '+15551234567';
|
||||
const selfUuid = 'abc-123-uuid';
|
||||
|
||||
describe('open mode (default)', () => {
|
||||
describe('no groups config', () => {
|
||||
it('rejects all group messages when no groupsConfig provided', () => {
|
||||
const result = applySignalGroupGating({
|
||||
text: 'Hello everyone!',
|
||||
groupId: 'test-group',
|
||||
selfPhoneNumber,
|
||||
});
|
||||
|
||||
expect(result.shouldProcess).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('open mode (explicit config)', () => {
|
||||
const openConfig = { '*': { mode: 'open' as const } };
|
||||
|
||||
it('allows messages without mention', () => {
|
||||
const result = applySignalGroupGating({
|
||||
text: 'Hello everyone!',
|
||||
groupId: 'test-group',
|
||||
selfPhoneNumber,
|
||||
groupsConfig: openConfig,
|
||||
});
|
||||
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
@@ -23,6 +38,7 @@ describe('applySignalGroupGating', () => {
|
||||
groupId: 'test-group',
|
||||
mentions: [{ number: '+15551234567', start: 4, length: 4 }],
|
||||
selfPhoneNumber,
|
||||
groupsConfig: openConfig,
|
||||
});
|
||||
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
@@ -37,6 +53,7 @@ describe('applySignalGroupGating', () => {
|
||||
mentions: [{ uuid: selfUuid, start: 4, length: 4 }],
|
||||
selfPhoneNumber,
|
||||
selfUuid,
|
||||
groupsConfig: openConfig,
|
||||
});
|
||||
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
@@ -49,6 +66,7 @@ describe('applySignalGroupGating', () => {
|
||||
groupId: 'test-group',
|
||||
mentions: [{ number: '+19998887777', start: 4, length: 6 }],
|
||||
selfPhoneNumber,
|
||||
groupsConfig: openConfig,
|
||||
});
|
||||
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
@@ -61,6 +79,7 @@ describe('applySignalGroupGating', () => {
|
||||
groupId: 'test-group',
|
||||
selfPhoneNumber,
|
||||
mentionPatterns: ['@lettabot', '@bot'],
|
||||
groupsConfig: openConfig,
|
||||
});
|
||||
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
@@ -73,6 +92,7 @@ describe('applySignalGroupGating', () => {
|
||||
groupId: 'test-group',
|
||||
quote: { author: '+15551234567', text: 'Previous message' },
|
||||
selfPhoneNumber,
|
||||
groupsConfig: openConfig,
|
||||
});
|
||||
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
@@ -84,6 +104,7 @@ describe('applySignalGroupGating', () => {
|
||||
text: 'Hey 15551234567 check this out',
|
||||
groupId: 'test-group',
|
||||
selfPhoneNumber,
|
||||
groupsConfig: openConfig,
|
||||
});
|
||||
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
|
||||
@@ -40,13 +40,13 @@ describe('applyTelegramGroupGating', () => {
|
||||
expect(result.reason).toBe('group-not-in-allowlist');
|
||||
});
|
||||
|
||||
it('allows all groups when no groupsConfig provided', () => {
|
||||
// No config = no allowlist filtering (open mode)
|
||||
it('rejects all groups when no groupsConfig provided', () => {
|
||||
// No config = no group participation
|
||||
const result = applyTelegramGroupGating(createParams({
|
||||
text: '@mybot hello',
|
||||
groupsConfig: undefined,
|
||||
}));
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
expect(result.shouldProcess).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -262,21 +262,19 @@ describe('applyTelegramGroupGating', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('no groupsConfig (open mode)', () => {
|
||||
it('processes messages with mention when no config', () => {
|
||||
describe('no groupsConfig (disabled)', () => {
|
||||
it('rejects messages with mention when no config', () => {
|
||||
const result = applyTelegramGroupGating(createParams({
|
||||
text: '@mybot hello',
|
||||
}));
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
expect(result.wasMentioned).toBe(true);
|
||||
expect(result.shouldProcess).toBe(false);
|
||||
});
|
||||
|
||||
it('processes messages without mention when no config', () => {
|
||||
it('rejects messages without mention when no config', () => {
|
||||
const result = applyTelegramGroupGating(createParams({
|
||||
text: 'hello everyone',
|
||||
}));
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
expect(result.mode).toBe('open');
|
||||
expect(result.shouldProcess).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('applyGroupGating', () => {
|
||||
expect(result.reason).toBe('group-not-in-allowlist');
|
||||
});
|
||||
|
||||
it('allows group when no allowlist configured', () => {
|
||||
it('rejects group when no groups config (no config = no groups)', () => {
|
||||
const result = applyGroupGating(createParams({
|
||||
groupsConfig: undefined,
|
||||
msg: createMessage({
|
||||
@@ -73,8 +73,8 @@ describe('applyGroupGating', () => {
|
||||
}),
|
||||
}));
|
||||
|
||||
// No allowlist = allowed (open mode)
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
expect(result.shouldProcess).toBe(false);
|
||||
expect(result.reason).toBe('no-groups-config');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -83,8 +83,8 @@ export function applyGroupGating(params: GroupGatingParams): GroupGatingResult {
|
||||
if (!isGroupAllowed(groupsConfig, [groupJid])) {
|
||||
return {
|
||||
shouldProcess: false,
|
||||
mode: 'open',
|
||||
reason: 'group-not-in-allowlist',
|
||||
mode: 'disabled',
|
||||
reason: groupsConfig ? 'group-not-in-allowlist' : 'no-groups-config',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ import { normalizePhoneForStorage } from "../../utils/phone.js";
|
||||
import { parseCommand, HELP_TEXT } from "../../core/commands.js";
|
||||
|
||||
// Node imports
|
||||
import { rmSync } from "node:fs";
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// DEBUG MODE
|
||||
@@ -94,7 +94,7 @@ const WATCHDOG_INTERVAL_MS = 60 * 1000;
|
||||
const WATCHDOG_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
/** Session corruption threshold - clear session after N failures without QR */
|
||||
const SESSION_CORRUPTION_THRESHOLD = 3;
|
||||
const SESSION_CORRUPTION_THRESHOLD = 8;
|
||||
|
||||
/** Message deduplication TTL (20 minutes) */
|
||||
const DEDUPE_TTL_MS = 20 * 60 * 1000;
|
||||
@@ -142,7 +142,7 @@ export class WhatsAppAdapter implements ChannelAdapter {
|
||||
// Group metadata cache
|
||||
private groupMetaCache: GroupMetaCache;
|
||||
|
||||
// Message store for getMessage callback (populated when we SEND, not receive)
|
||||
// Message store for getMessage callback (populated on both send and receive for retry capability)
|
||||
private messageStore: Map<string, any> = new Map();
|
||||
|
||||
// Attachment configuration
|
||||
@@ -177,6 +177,9 @@ export class WhatsAppAdapter implements ChannelAdapter {
|
||||
// Consecutive failures without QR (session corruption indicator)
|
||||
private consecutiveNoQrFailures = 0;
|
||||
|
||||
// One-time hint for missing groups config
|
||||
private loggedNoGroupsHint = false;
|
||||
|
||||
// Credential save queue
|
||||
private credsSaveQueue: CredsSaveQueue | null = null;
|
||||
|
||||
@@ -297,11 +300,17 @@ export class WhatsAppAdapter implements ChannelAdapter {
|
||||
|
||||
// Cleanup
|
||||
this.detachListeners();
|
||||
// Flush pending credential saves before closing
|
||||
if (this.credsSaveQueue) {
|
||||
await this.credsSaveQueue.flush();
|
||||
}
|
||||
if (this.sock) {
|
||||
try {
|
||||
await this.sock.logout();
|
||||
// Close WebSocket without logging out -- logout() invalidates the session
|
||||
// server-side, which destroys credentials and forces QR re-pair on restart
|
||||
this.sock.ws?.close();
|
||||
} catch (error) {
|
||||
console.warn("[WhatsApp] Logout error:", error);
|
||||
console.warn("[WhatsApp] Disconnect error:", error);
|
||||
}
|
||||
this.sock = null;
|
||||
}
|
||||
@@ -354,7 +363,13 @@ export class WhatsAppAdapter implements ChannelAdapter {
|
||||
this.reconnectState.attempts = 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[WhatsApp] Socket error:", error);
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
if (msg.includes("Connection closed during startup")) {
|
||||
// Log the reason (status code) without the full stack trace
|
||||
console.warn(`[WhatsApp] ${msg}`);
|
||||
} else {
|
||||
console.error("[WhatsApp] Socket error:", msg);
|
||||
}
|
||||
// Resolve the disconnect promise if it's still pending
|
||||
disconnectResolve!();
|
||||
}
|
||||
@@ -368,19 +383,13 @@ export class WhatsAppAdapter implements ChannelAdapter {
|
||||
// Check if logged out
|
||||
if (!this.running) break;
|
||||
|
||||
// Check for session corruption (repeated failures without QR)
|
||||
if (this.consecutiveNoQrFailures >= 3) {
|
||||
// Check for persistent session failures (only warn after many attempts --
|
||||
// 1-3 failures on startup is normal WhatsApp reconnection cooldown)
|
||||
if (this.consecutiveNoQrFailures >= SESSION_CORRUPTION_THRESHOLD) {
|
||||
console.warn(
|
||||
"[WhatsApp] Session appears corrupted (3 failures without QR), clearing session..."
|
||||
`[WhatsApp] ${SESSION_CORRUPTION_THRESHOLD} consecutive connection failures without QR. Session may need re-pairing -- use /reset whatsapp if this persists.`
|
||||
);
|
||||
try {
|
||||
rmSync(this.sessionPath, { recursive: true, force: true });
|
||||
console.log("[WhatsApp] Session cleared, will show QR on next attempt");
|
||||
} catch (err) {
|
||||
console.error("[WhatsApp] Failed to clear session:", err);
|
||||
}
|
||||
this.consecutiveNoQrFailures = 0;
|
||||
this.reconnectState.attempts = 0; // Reset attempts after clearing
|
||||
}
|
||||
|
||||
// Increment and check retry limit
|
||||
@@ -468,12 +477,9 @@ export class WhatsAppAdapter implements ChannelAdapter {
|
||||
qrWasShown = true;
|
||||
},
|
||||
onConnectionUpdate: (update) => {
|
||||
// Track connection close during initial connection
|
||||
// Track connection close during initial connection (silent -- logged at session clear)
|
||||
if (update.connection === "close" && !qrWasShown) {
|
||||
this.consecutiveNoQrFailures++;
|
||||
console.warn(
|
||||
`[WhatsApp] Connection closed without QR (failure ${this.consecutiveNoQrFailures}/3)`
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -605,6 +611,17 @@ export class WhatsAppAdapter implements ChannelAdapter {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Store received message for getMessage retry capability (enables "Waiting for this message" fix)
|
||||
// Must happen early so even messages we skip are available for protocol-level retries
|
||||
if (m.key?.id && m.message) {
|
||||
this.messageStore.set(m.key.id, m);
|
||||
// Auto-cleanup after 24 hours
|
||||
const storedId = m.key.id;
|
||||
setTimeout(() => {
|
||||
this.messageStore.delete(storedId);
|
||||
}, 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
// Build dedupe key (but don't check yet - wait until after extraction succeeds)
|
||||
const dedupeKey = `whatsapp:${remoteJid}:${messageId}`;
|
||||
|
||||
@@ -768,7 +785,12 @@ export class WhatsAppAdapter implements ChannelAdapter {
|
||||
});
|
||||
|
||||
if (!gatingResult.shouldProcess) {
|
||||
console.log(`[WhatsApp] Group message skipped: ${gatingResult.reason}`);
|
||||
if (gatingResult.reason === 'no-groups-config' && !this.loggedNoGroupsHint) {
|
||||
console.log(`[WhatsApp] Group messages ignored (no groups config). Add a "groups" section to your agent config to enable.`);
|
||||
this.loggedNoGroupsHint = true;
|
||||
} else if (gatingResult.reason !== 'no-groups-config') {
|
||||
console.log(`[WhatsApp] Group message skipped: ${gatingResult.reason}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -203,10 +203,12 @@ export async function createWaSocket(options: SocketOptions): Promise<SocketResu
|
||||
markOnlineOnConnect: false,
|
||||
logger: logger as any,
|
||||
printQRInTerminal: false,
|
||||
// getMessage for retry capability - store is populated when we SEND messages, not here
|
||||
// getMessage for retry capability - store is populated on both send and receive
|
||||
getMessage: async (key: { id?: string | null }) => {
|
||||
if (!key.id) return undefined;
|
||||
return messageStore.get(key.id);
|
||||
const msg = messageStore.get(key.id);
|
||||
// Return just the proto.IMessage content, not the full WAMessage wrapper
|
||||
return msg?.message ?? undefined;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -270,7 +272,9 @@ export async function createWaSocket(options: SocketOptions): Promise<SocketResu
|
||||
if (update.connection === "close") {
|
||||
clearTimeout(timeout);
|
||||
sock.ev.off("connection.update", handler);
|
||||
reject(new Error("Connection closed during startup"));
|
||||
const statusCode = (update.lastDisconnect?.error as any)?.output?.statusCode;
|
||||
const reason = update.lastDisconnect?.error?.message || "unknown";
|
||||
reject(new Error(`Connection closed during startup (status: ${statusCode ?? "none"}, reason: ${reason})`));
|
||||
}
|
||||
|
||||
// Notify callback
|
||||
|
||||
Reference in New Issue
Block a user