diff --git a/src/channels/group-mode.test.ts b/src/channels/group-mode.test.ts index 5a72046..15d2ba6 100644 --- a/src/channels/group-mode.test.ts +++ b/src/channels/group-mode.test.ts @@ -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)', () => { diff --git a/src/channels/group-mode.ts b/src/channels/group-mode.ts index 9895c72..fa5f895 100644 --- a/src/channels/group-mode.ts +++ b/src/channels/group-mode.ts @@ -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)); diff --git a/src/channels/signal/group-gating.test.ts b/src/channels/signal/group-gating.test.ts index 20e82ec..dd8adef 100644 --- a/src/channels/signal/group-gating.test.ts +++ b/src/channels/signal/group-gating.test.ts @@ -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); diff --git a/src/channels/telegram-group-gating.test.ts b/src/channels/telegram-group-gating.test.ts index b15d740..d501996 100644 --- a/src/channels/telegram-group-gating.test.ts +++ b/src/channels/telegram-group-gating.test.ts @@ -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); }); }); }); diff --git a/src/channels/whatsapp/inbound/group-gating.test.ts b/src/channels/whatsapp/inbound/group-gating.test.ts index 9185f57..bee6586 100644 --- a/src/channels/whatsapp/inbound/group-gating.test.ts +++ b/src/channels/whatsapp/inbound/group-gating.test.ts @@ -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'); }); }); diff --git a/src/channels/whatsapp/inbound/group-gating.ts b/src/channels/whatsapp/inbound/group-gating.ts index 7abc845..7de953b 100644 --- a/src/channels/whatsapp/inbound/group-gating.ts +++ b/src/channels/whatsapp/inbound/group-gating.ts @@ -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', }; } diff --git a/src/channels/whatsapp/index.ts b/src/channels/whatsapp/index.ts index c547bb9..1f70566 100644 --- a/src/channels/whatsapp/index.ts +++ b/src/channels/whatsapp/index.ts @@ -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 = 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; } diff --git a/src/channels/whatsapp/session.ts b/src/channels/whatsapp/session.ts index b5d6a33..1c033b3 100644 --- a/src/channels/whatsapp/session.ts +++ b/src/channels/whatsapp/session.ts @@ -203,10 +203,12 @@ export async function createWaSocket(options: SocketOptions): Promise { 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