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:
Cameron
2026-02-23 15:22:38 -08:00
committed by GitHub
parent b1f72e0150
commit ad4c22ba54
8 changed files with 88 additions and 43 deletions

View File

@@ -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)', () => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',
};
}

View File

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

View File

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