From 5630daa15048b601179a1c6ee8dc9a0c61b10f26 Mon Sep 17 00:00:00 2001 From: winterborn Date: Wed, 4 Feb 2026 23:02:04 +0000 Subject: [PATCH 1/2] feat(whatsapp): allow processing messages from self and groups - Add processFromSelf and processGroups options to WhatsApp connection - Default remains 1:1 from others only; opt-in via Message Filtering UI - For groups, use participant JID for sender identity and reply in group - Schema migration 0013; API filter endpoint accepts optional booleans - UI: Message sources section with checkboxes on WhatsApp Connection page --- .../0013_add_process_from_self_and_groups.sql | 3 + .../api/controllers/settings.controller.ts | 2 + .../api/controllers/whatsapp.controller.ts | 24 +++++- .../src/api/validators/whatsapp.validators.ts | 2 + backend/src/db/schema.ts | 3 + .../src/models/whatsapp-connection.model.ts | 4 + .../whatsapp-connection.repository.ts | 25 ++++-- .../whatsapp/message-handler.service.ts | 77 +++++++------------ .../whatsapp/whatsapp-client.service.ts | 20 +++-- .../controllers/whatsapp.controller.test.ts | 9 ++- .../whatsapp-connection.repository.test.ts | 54 +++++++++++++ .../whatsapp/message-filter-form.tsx | 19 +++-- .../whatsapp/message-sources-card.tsx | 73 ++++++++++++++++++ frontend/src/hooks/use-whatsapp.ts | 4 + frontend/src/pages/whatsapp-connection.tsx | 62 +++++++++++++-- frontend/src/types/whatsapp.types.ts | 6 +- 16 files changed, 302 insertions(+), 85 deletions(-) create mode 100644 backend/drizzle/migrations/0013_add_process_from_self_and_groups.sql create mode 100644 frontend/src/components/whatsapp/message-sources-card.tsx diff --git a/backend/drizzle/migrations/0013_add_process_from_self_and_groups.sql b/backend/drizzle/migrations/0013_add_process_from_self_and_groups.sql new file mode 100644 index 0000000..63e18ef --- /dev/null +++ b/backend/drizzle/migrations/0013_add_process_from_self_and_groups.sql @@ -0,0 +1,3 @@ +-- Allow processing messages from self and from groups (opt-in, default off) +ALTER TABLE whatsapp_connections ADD COLUMN process_from_self INTEGER DEFAULT 0 NOT NULL; +ALTER TABLE whatsapp_connections ADD COLUMN process_groups INTEGER DEFAULT 0 NOT NULL; diff --git a/backend/src/api/controllers/settings.controller.ts b/backend/src/api/controllers/settings.controller.ts index e74c310..a9c731c 100644 --- a/backend/src/api/controllers/settings.controller.ts +++ b/backend/src/api/controllers/settings.controller.ts @@ -222,6 +222,8 @@ export async function exportData(req: AuthenticatedRequest, res: Response, next: lastConnectedAt: whatsappConnection.lastConnectedAt, filterType: whatsappConnection.filterType, filterValue: whatsappConnection.filterValue, + processFromSelf: whatsappConnection.processFromSelf, + processGroups: whatsappConnection.processGroups, autoApprovalMode: whatsappConnection.autoApprovalMode, exceptionsEnabled: whatsappConnection.exceptionsEnabled, exceptionContacts: whatsappConnection.exceptionContacts, diff --git a/backend/src/api/controllers/whatsapp.controller.ts b/backend/src/api/controllers/whatsapp.controller.ts index 200b7f8..f959111 100644 --- a/backend/src/api/controllers/whatsapp.controller.ts +++ b/backend/src/api/controllers/whatsapp.controller.ts @@ -40,6 +40,8 @@ export const getStatus = async ( lastConnectedAt: activeConnection.lastConnectedAt, filterType: activeConnection.filterType, filterValue: activeConnection.filterValue, + processFromSelf: activeConnection.processFromSelf, + processGroups: activeConnection.processGroups, autoApprovalMode: activeConnection.autoApprovalMode, exceptionsEnabled: activeConnection.exceptionsEnabled, exceptionContacts: activeConnection.exceptionContacts, @@ -79,6 +81,8 @@ export const getStatus = async ( lastConnectedAt: connection.lastConnectedAt, filterType: connection.filterType, filterValue: connection.filterValue, + processFromSelf: connection.processFromSelf, + processGroups: connection.processGroups, autoApprovalMode: connection.autoApprovalMode, exceptionsEnabled: connection.exceptionsEnabled, exceptionContacts: connection.exceptionContacts, @@ -194,10 +198,17 @@ export const updateMessageFilter = async ( return; } - const { filterType, filterValue } = result.data; + const { filterType, filterValue, processFromSelf, processGroups } = result.data; - // Update filter configuration - const updated = await whatsappConnectionRepository.updateMessageFilter(filterType, filterValue); + // Update filter configuration and message source options + const updated = await whatsappConnectionRepository.updateMessageFilter( + filterType, + filterValue, + { + ...(processFromSelf !== undefined && { processFromSelf }), + ...(processGroups !== undefined && { processGroups }), + } + ); if (!updated) { res.status(404).json({ @@ -207,13 +218,18 @@ export const updateMessageFilter = async ( return; } - logger.info({ filterType, filterValue }, 'Message filter updated'); + logger.info( + { filterType, filterValue, processFromSelf, processGroups }, + 'Message filter updated' + ); res.json({ success: true, message: 'Message filter updated successfully', filterType: updated.filterType, filterValue: updated.filterValue, + processFromSelf: updated.processFromSelf, + processGroups: updated.processGroups, }); } catch (error) { logger.error({ error }, 'Failed to update message filter'); diff --git a/backend/src/api/validators/whatsapp.validators.ts b/backend/src/api/validators/whatsapp.validators.ts index f693e59..21d3f4a 100644 --- a/backend/src/api/validators/whatsapp.validators.ts +++ b/backend/src/api/validators/whatsapp.validators.ts @@ -12,6 +12,8 @@ export const messageFilterSchema = z .min(1, 'Filter value must be at least 1 character') .max(10, 'Filter value must be at most 10 characters') .nullable(), + processFromSelf: z.boolean().optional(), + processGroups: z.boolean().optional(), }) .refine( (data) => diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 9033770..2c970f7 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -29,6 +29,9 @@ export const whatsappConnections = sqliteTable( // Message filtering configuration filterType: text('filter_type', { enum: ['prefix', 'keyword'] }), filterValue: text('filter_value'), + // Process messages from self and/or groups (default: only 1:1 from others) + processFromSelf: integer('process_from_self', { mode: 'boolean' }).notNull().default(false), + processGroups: integer('process_groups', { mode: 'boolean' }).notNull().default(false), // Auto-approval mode autoApprovalMode: text('auto_approval_mode', { enum: ['auto_approve', 'auto_deny', 'manual'] }) .notNull() diff --git a/backend/src/models/whatsapp-connection.model.ts b/backend/src/models/whatsapp-connection.model.ts index 910b111..189aea6 100644 --- a/backend/src/models/whatsapp-connection.model.ts +++ b/backend/src/models/whatsapp-connection.model.ts @@ -15,6 +15,8 @@ export interface WhatsAppConnection { qrCodeGeneratedAt: Date | null; filterType: MessageFilterType; filterValue: string | null; + processFromSelf: boolean; + processGroups: boolean; autoApprovalMode: AutoApprovalMode; exceptionsEnabled: boolean; exceptionContacts: string[]; @@ -57,6 +59,8 @@ export interface WhatsAppConnectionResponse { lastConnectedAt: Date | null; filterType: MessageFilterType; filterValue: string | null; + processFromSelf: boolean; + processGroups: boolean; autoApprovalMode: AutoApprovalMode; exceptionsEnabled: boolean; exceptionContacts: string[]; diff --git a/backend/src/repositories/whatsapp-connection.repository.ts b/backend/src/repositories/whatsapp-connection.repository.ts index d9d21be..282bf32 100644 --- a/backend/src/repositories/whatsapp-connection.repository.ts +++ b/backend/src/repositories/whatsapp-connection.repository.ts @@ -127,11 +127,12 @@ export class WhatsAppConnectionRepository { } /** - * Update message filter configuration + * Update message filter and message source options */ async updateMessageFilter( filterType: 'prefix' | 'keyword' | null, - filterValue: string | null + filterValue: string | null, + options?: { processFromSelf?: boolean; processGroups?: boolean } ): Promise { const connections = await this.findAll(); @@ -139,13 +140,21 @@ export class WhatsAppConnectionRepository { return undefined; } + const setValues: Record = { + filterType, + filterValue, + updatedAt: new Date().toISOString(), + }; + if (options?.processFromSelf !== undefined) { + setValues.processFromSelf = options.processFromSelf ? 1 : 0; + } + if (options?.processGroups !== undefined) { + setValues.processGroups = options.processGroups ? 1 : 0; + } + const result = await db .update(whatsappConnections) - .set({ - filterType, - filterValue, - updatedAt: new Date().toISOString(), - }) + .set(setValues as Record) .where(eq(whatsappConnections.id, connections[0].id)) .returning(); @@ -175,6 +184,8 @@ export class WhatsAppConnectionRepository { qrCodeGeneratedAt: row.qrCodeGeneratedAt ? new Date(row.qrCodeGeneratedAt) : null, filterType: row.filterType as 'prefix' | 'keyword' | null, filterValue: row.filterValue, + processFromSelf: Boolean(row.processFromSelf), + processGroups: Boolean(row.processGroups), autoApprovalMode: (row.autoApprovalMode as 'auto_approve' | 'auto_deny' | 'manual') || 'auto_approve', exceptionsEnabled: Boolean(row.exceptionsEnabled), diff --git a/backend/src/services/whatsapp/message-handler.service.ts b/backend/src/services/whatsapp/message-handler.service.ts index efecd23..bca7e7d 100644 --- a/backend/src/services/whatsapp/message-handler.service.ts +++ b/backend/src/services/whatsapp/message-handler.service.ts @@ -153,16 +153,16 @@ class MessageHandlerService { '📱 DEBUG: Raw incoming message key' ); - // Extract full JID for sending responses (preserves @lid or @s.whatsapp.net) + // For groups, reply to the group JID; sender identity comes from participant + const isGroup = message.key.remoteJid?.endsWith('@g.us'); + const senderJid = isGroup ? (message.key.participant ?? message.key.remoteJid) : null; + + // Extract full JID for sending responses (group = reply in group, 1:1 = reply to chat) const fullJid = this.extractFullJid(message); - // Extract phone number - prefer remoteJidAlt (PN) if message is from LID - // This is important for: - // 1. Consistent hashing (phone numbers produce consistent hashes) - // 2. Contact storage (store actual phone numbers) - // 3. Exception matching (matches against phone number hashes) - const phoneNumber = this.extractPhoneNumber(message); - const userIdentifier = phoneNumber || this.extractUserIdentifier(message); + // Extract phone number - use participant JID for groups so we identify the sender + const phoneNumber = this.extractPhoneNumber(message, senderJid); + const userIdentifier = phoneNumber || this.extractUserIdentifier(message, senderJid); logger.info( { @@ -320,36 +320,33 @@ class MessageHandlerService { /** * Extract actual phone number from message - * In Baileys v7+, if message comes from @lid, the actual phone number is in remoteJidAlt - * Returns phone number in E.164 format (e.g., +1234567890) or null if not available + * In Baileys v7+, if message comes from @lid, the actual phone number is in remoteJidAlt (or participantAlt for groups) + * When senderJid is set (e.g. group participant), use it instead of remoteJid for sender identity */ - private extractPhoneNumber(message: BaileysMessage): string | null { + private extractPhoneNumber( + message: BaileysMessage, + senderJidOverride?: string | null + ): string | null { try { - const remoteJid = message.key.remoteJid; - const remoteJidAlt = message.key.remoteJidAlt; + const remoteJid = senderJidOverride ?? message.key.remoteJid; + const remoteJidAlt = senderJidOverride + ? message.key.participantAlt + : message.key.remoteJidAlt; - // If message is from LID (@lid), try to get PN from remoteJidAlt if (remoteJid?.endsWith('@lid') && remoteJidAlt?.endsWith('@s.whatsapp.net')) { const decoded = jidDecode(remoteJidAlt); if (decoded?.user) { logger.debug( - { - lidJid: remoteJid, - pnJid: remoteJidAlt, - phoneNumber: decoded.user, - }, - 'Extracted phone number from remoteJidAlt' + { lidJid: remoteJid, pnJid: remoteJidAlt, phoneNumber: decoded.user }, + 'Extracted phone number from alt JID' ); return `+${decoded.user}`; } } - // If message is from PN (@s.whatsapp.net), extract directly if (remoteJid?.endsWith('@s.whatsapp.net')) { const decoded = jidDecode(remoteJid); - if (decoded?.user) { - return `+${decoded.user}`; - } + if (decoded?.user) return `+${decoded.user}`; } return null; @@ -361,37 +358,21 @@ class MessageHandlerService { /** * Extract user identifier from WhatsApp message for hashing/session management - * Returns just the user portion (without domain) - could be phone number or LID + * When senderJidOverride is set (e.g. group participant), use it for sender identity */ - private extractUserIdentifier(message: BaileysMessage): string | null { + private extractUserIdentifier( + message: BaileysMessage, + senderJidOverride?: string | null + ): string | null { try { - const remoteJid = message.key.remoteJid; - if (!remoteJid) { - return null; - } + const remoteJid = senderJidOverride ?? message.key.remoteJid; + if (!remoteJid) return null; - // Use jidDecode to extract the user portion const decoded = jidDecode(remoteJid); - - logger.debug( - { - remoteJid, - decoded, - decodedUser: decoded?.user, - }, - 'DEBUG: jidDecode result' - ); - if (!decoded?.user) { - // Fallback: extract directly from JID string const match = remoteJid.match(/^([^@]+)@/); - if (match) { - logger.debug({ extracted: match[1] }, 'Extracted user from JID directly'); - return match[1]; - } - return null; + return match ? match[1] : null; } - return decoded.user; } catch (error) { logger.error('Error extracting user identifier', { error, from: message.key.remoteJid }); diff --git a/backend/src/services/whatsapp/whatsapp-client.service.ts b/backend/src/services/whatsapp/whatsapp-client.service.ts index c4964eb..cda50e4 100644 --- a/backend/src/services/whatsapp/whatsapp-client.service.ts +++ b/backend/src/services/whatsapp/whatsapp-client.service.ts @@ -299,19 +299,23 @@ class WhatsAppClientService { // Message received event this.sock.ev.on('messages.upsert', async (event) => { try { - for (const message of event.messages) { - // Skip messages from self - if (message.key.fromMe) continue; + const connections = await whatsappConnectionRepository.findAll(); + const conn = connections[0]; + const processFromSelf = conn?.processFromSelf ?? false; + const processGroups = conn?.processGroups ?? false; - // Skip group messages - only process individual chats + for (const message of event.messages) { const remoteJid = message.key.remoteJid; - if (!remoteJid || remoteJid.endsWith('@g.us') || remoteJid === 'status@broadcast') { - continue; - } + const fromMe = message.key.fromMe; + const isGroup = remoteJid?.endsWith('@g.us') ?? false; + const isBroadcast = remoteJid === 'status@broadcast'; + + if (fromMe && !processFromSelf) continue; + if ((isGroup || isBroadcast) && !processGroups) continue; + if (!remoteJid) continue; logger.debug({ from: remoteJid }, 'Message received'); - // Forward to message callback if (this.messageCallback) { this.messageCallback(message as BaileysMessage); } diff --git a/backend/tests/unit/controllers/whatsapp.controller.test.ts b/backend/tests/unit/controllers/whatsapp.controller.test.ts index 72e8967..ed4bab0 100644 --- a/backend/tests/unit/controllers/whatsapp.controller.test.ts +++ b/backend/tests/unit/controllers/whatsapp.controller.test.ts @@ -286,6 +286,8 @@ describe('WhatsApp Controller', () => { id: 1, filterType: 'contains', filterValue: 'movie', + processFromSelf: false, + processGroups: false, }; (messageFilterSchema.safeParse as Mock).mockReturnValue({ @@ -301,10 +303,11 @@ describe('WhatsApp Controller', () => { expect(messageFilterSchema.safeParse).toHaveBeenCalledWith(filterData); expect(whatsappConnectionRepository.updateMessageFilter).toHaveBeenCalledWith( 'contains', - 'movie' + 'movie', + {} ); expect(logger.info).toHaveBeenCalledWith( - { filterType: 'contains', filterValue: 'movie' }, + { filterType: 'contains', filterValue: 'movie', processFromSelf: undefined, processGroups: undefined }, 'Message filter updated' ); expect(mockResponse.json).toHaveBeenCalledWith({ @@ -312,6 +315,8 @@ describe('WhatsApp Controller', () => { message: 'Message filter updated successfully', filterType: 'contains', filterValue: 'movie', + processFromSelf: false, + processGroups: false, }); }); diff --git a/backend/tests/unit/repositories/whatsapp-connection.repository.test.ts b/backend/tests/unit/repositories/whatsapp-connection.repository.test.ts index c0d2935..923cbb4 100644 --- a/backend/tests/unit/repositories/whatsapp-connection.repository.test.ts +++ b/backend/tests/unit/repositories/whatsapp-connection.repository.test.ts @@ -61,7 +61,11 @@ describe('WhatsAppConnectionRepository', () => { qrCodeGeneratedAt: null, filterType: null, filterValue: null, + processFromSelf: 0, + processGroups: 0, autoApprovalMode: 'auto_approve', + exceptionsEnabled: 0, + exceptionContacts: [], createdAt: '2023-01-01T00:00:00.000Z', updatedAt: '2023-01-01T00:00:00.000Z', }; @@ -84,6 +88,8 @@ describe('WhatsAppConnectionRepository', () => { qrCodeGeneratedAt: null, filterType: null, filterValue: null, + processFromSelf: false, + processGroups: false, autoApprovalMode: 'auto_approve', exceptionsEnabled: false, exceptionContacts: [], @@ -117,7 +123,11 @@ describe('WhatsAppConnectionRepository', () => { qrCodeGeneratedAt: null, filterType: null, filterValue: null, + processFromSelf: 0, + processGroups: 0, autoApprovalMode: 'auto_approve', + exceptionsEnabled: 0, + exceptionContacts: [], createdAt: '2023-01-01T00:00:00.000Z', updatedAt: '2023-01-01T00:00:00.000Z', }; @@ -140,6 +150,8 @@ describe('WhatsAppConnectionRepository', () => { qrCodeGeneratedAt: null, filterType: null, filterValue: null, + processFromSelf: false, + processGroups: false, autoApprovalMode: 'auto_approve', exceptionsEnabled: false, exceptionContacts: [], @@ -174,7 +186,11 @@ describe('WhatsAppConnectionRepository', () => { qrCodeGeneratedAt: null, filterType: null, filterValue: null, + processFromSelf: 0, + processGroups: 0, autoApprovalMode: 'auto_approve', + exceptionsEnabled: 0, + exceptionContacts: [], createdAt: '2023-01-01T00:00:00.000Z', updatedAt: '2023-01-01T00:00:00.000Z', }, @@ -188,7 +204,11 @@ describe('WhatsAppConnectionRepository', () => { qrCodeGeneratedAt: null, filterType: null, filterValue: null, + processFromSelf: 0, + processGroups: 0, autoApprovalMode: 'auto_approve', + exceptionsEnabled: 0, + exceptionContacts: [], createdAt: '2023-01-01T00:00:00.000Z', updatedAt: '2023-01-02T00:00:00.000Z', }; @@ -218,6 +238,8 @@ describe('WhatsAppConnectionRepository', () => { qrCodeGeneratedAt: null, filterType: null, filterValue: null, + processFromSelf: false, + processGroups: false, autoApprovalMode: 'auto_approve', exceptionsEnabled: false, exceptionContacts: [], @@ -235,6 +257,8 @@ describe('WhatsAppConnectionRepository', () => { qrCodeGeneratedAt: null, filterType: null, filterValue: null, + processFromSelf: 0, + processGroups: 0, autoApprovalMode: 'auto_approve', createdAt: '2023-01-02T00:00:00.000Z', updatedAt: '2023-01-02T00:00:00.000Z', @@ -263,6 +287,8 @@ describe('WhatsAppConnectionRepository', () => { qrCodeGeneratedAt: null, filterType: null, filterValue: null, + processFromSelf: false, + processGroups: false, autoApprovalMode: 'auto_approve', exceptionsEnabled: false, exceptionContacts: [], @@ -282,7 +308,11 @@ describe('WhatsAppConnectionRepository', () => { qrCodeGeneratedAt: null, filterType: null, filterValue: null, + processFromSelf: 0, + processGroups: 0, autoApprovalMode: 'auto_approve', + exceptionsEnabled: 0, + exceptionContacts: [], createdAt: '2023-01-01T00:00:00.000Z', updatedAt: '2023-01-02T00:00:00.000Z', }; @@ -308,6 +338,8 @@ describe('WhatsAppConnectionRepository', () => { qrCodeGeneratedAt: null, filterType: null, filterValue: null, + processFromSelf: false, + processGroups: false, autoApprovalMode: 'auto_approve', exceptionsEnabled: false, exceptionContacts: [], @@ -342,7 +374,11 @@ describe('WhatsAppConnectionRepository', () => { qrCodeGeneratedAt: null, filterType: null, filterValue: null, + processFromSelf: 0, + processGroups: 0, autoApprovalMode: 'auto_approve', + exceptionsEnabled: 0, + exceptionContacts: [], createdAt: '2023-01-01T00:00:00.000Z', updatedAt: '2023-01-01T00:00:00.000Z', }, @@ -356,7 +392,11 @@ describe('WhatsAppConnectionRepository', () => { qrCodeGeneratedAt: null, filterType: 'prefix', filterValue: 'test', + processFromSelf: 0, + processGroups: 0, autoApprovalMode: 'auto_approve', + exceptionsEnabled: 0, + exceptionContacts: [], createdAt: '2023-01-01T00:00:00.000Z', updatedAt: '2023-01-02T00:00:00.000Z', }; @@ -381,6 +421,8 @@ describe('WhatsAppConnectionRepository', () => { qrCodeGeneratedAt: null, filterType: 'prefix', filterValue: 'test', + processFromSelf: false, + processGroups: false, autoApprovalMode: 'auto_approve', exceptionsEnabled: false, exceptionContacts: [], @@ -409,7 +451,11 @@ describe('WhatsAppConnectionRepository', () => { qrCodeGeneratedAt: null, filterType: null, filterValue: null, + processFromSelf: 0, + processGroups: 0, autoApprovalMode: 'auto_approve', + exceptionsEnabled: 0, + exceptionContacts: [], createdAt: '2023-01-01T00:00:00.000Z', updatedAt: '2023-01-01T00:00:00.000Z', }, @@ -421,7 +467,11 @@ describe('WhatsAppConnectionRepository', () => { qrCodeGeneratedAt: '2023-01-02T00:00:00.000Z', filterType: 'keyword', filterValue: 'approve', + processFromSelf: 0, + processGroups: 0, autoApprovalMode: 'manual', + exceptionsEnabled: 0, + exceptionContacts: [], createdAt: '2023-01-02T00:00:00.000Z', updatedAt: '2023-01-02T00:00:00.000Z', }, @@ -442,6 +492,8 @@ describe('WhatsAppConnectionRepository', () => { qrCodeGeneratedAt: null, filterType: null, filterValue: null, + processFromSelf: false, + processGroups: false, autoApprovalMode: 'auto_approve', exceptionsEnabled: false, exceptionContacts: [], @@ -456,6 +508,8 @@ describe('WhatsAppConnectionRepository', () => { qrCodeGeneratedAt: new Date('2023-01-02T00:00:00.000Z'), filterType: 'keyword', filterValue: 'approve', + processFromSelf: false, + processGroups: false, autoApprovalMode: 'manual', exceptionsEnabled: false, exceptionContacts: [], diff --git a/frontend/src/components/whatsapp/message-filter-form.tsx b/frontend/src/components/whatsapp/message-filter-form.tsx index 55a98e6..40f7c55 100644 --- a/frontend/src/components/whatsapp/message-filter-form.tsx +++ b/frontend/src/components/whatsapp/message-filter-form.tsx @@ -6,10 +6,15 @@ import { Input } from '../ui/input'; import { AlertCircle, Filter, Trash2 } from 'lucide-react'; import type { MessageFilterType } from '../../types/whatsapp.types'; +export interface MessageFilterFormSavePayload { + filterType: MessageFilterType; + filterValue: string | null; +} + interface MessageFilterFormProps { currentFilterType: MessageFilterType; currentFilterValue: string | null; - onSave: (filterType: MessageFilterType, filterValue: string | null) => void; + onSave: (payload: MessageFilterFormSavePayload) => void; isSaving?: boolean; } @@ -22,24 +27,22 @@ export function MessageFilterForm({ const [filterType, setFilterType] = useState(currentFilterType || 'none'); const [filterValue, setFilterValue] = useState(currentFilterValue || ''); - // Sync state when props change from database useEffect(() => { setFilterType(currentFilterType || 'none'); setFilterValue(currentFilterValue || ''); }, [currentFilterType, currentFilterValue]); const handleSave = () => { - if (filterType === 'none') { - onSave(null, null); - } else { - onSave(filterType as MessageFilterType, filterValue); - } + onSave({ + filterType: filterType === 'none' ? null : (filterType as MessageFilterType), + filterValue: filterType === 'none' ? null : filterValue, + }); }; const handleDelete = () => { setFilterType('none'); setFilterValue(''); - onSave(null, null); + onSave({ filterType: null, filterValue: null }); }; const isValid = filterType === 'none' || (filterValue.length >= 1 && filterValue.length <= 10); diff --git a/frontend/src/components/whatsapp/message-sources-card.tsx b/frontend/src/components/whatsapp/message-sources-card.tsx new file mode 100644 index 0000000..ebdbf7d --- /dev/null +++ b/frontend/src/components/whatsapp/message-sources-card.tsx @@ -0,0 +1,73 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'; +import { Label } from '../ui/label'; +import { Switch } from '../ui/switch'; +import { MessageCircle } from 'lucide-react'; + +interface MessageSourcesCardProps { + processFromSelf: boolean; + processGroups: boolean; + onSave: (processFromSelf: boolean, processGroups: boolean) => void; + isSaving?: boolean; +} + +export function MessageSourcesCard({ + processFromSelf, + processGroups, + onSave, + isSaving = false, +}: MessageSourcesCardProps) { + const handleFromSelfChange = (checked: boolean) => { + onSave(checked, processGroups); + }; + + const handleGroupsChange = (checked: boolean) => { + onSave(processFromSelf, checked); + }; + + return ( + + + + + Message sources + + + By default only 1:1 chats from others are processed. Enable these to also process messages + from yourself or from groups. + + + +
+
+ +

+ When on, messages you send to the linked number (e.g. from another device) are + processed. +

+
+ +
+
+
+ +

+ When on, messages in groups where the bot is added are processed; replies go to the + group. +

+
+ +
+
+
+ ); +} diff --git a/frontend/src/hooks/use-whatsapp.ts b/frontend/src/hooks/use-whatsapp.ts index 26b3915..cdd8d95 100644 --- a/frontend/src/hooks/use-whatsapp.ts +++ b/frontend/src/hooks/use-whatsapp.ts @@ -152,6 +152,8 @@ export function useWhatsApp() { lastConnectedAt: null, filterType: null, filterValue: null, + processFromSelf: false, + processGroups: false, autoApprovalMode: 'auto_approve', exceptionsEnabled: false, exceptionContacts: [], @@ -199,6 +201,8 @@ export function useWhatsApp() { lastConnectedAt: null, filterType: null, filterValue: null, + processFromSelf: false, + processGroups: false, autoApprovalMode: 'auto_approve', exceptionsEnabled: false, exceptionContacts: [], diff --git a/frontend/src/pages/whatsapp-connection.tsx b/frontend/src/pages/whatsapp-connection.tsx index 802acb9..c10c0bb 100644 --- a/frontend/src/pages/whatsapp-connection.tsx +++ b/frontend/src/pages/whatsapp-connection.tsx @@ -6,6 +6,7 @@ import { Button } from '../components/ui/button'; import { QRCodeDisplay } from '../components/whatsapp/qr-code-display'; import { ConnectionStatus } from '../components/whatsapp/connection-status'; import { MessageFilterForm } from '../components/whatsapp/message-filter-form'; +import { MessageSourcesCard } from '../components/whatsapp/message-sources-card'; import { Smartphone, Loader2, RefreshCw, Trash2, AlertTriangle } from 'lucide-react'; import type { MessageFilterType } from '../types/whatsapp.types'; import { useQueryClient } from '@tanstack/react-query'; @@ -144,21 +145,28 @@ export default function WhatsAppConnection() { connect(); }; - const handleFilterSave = (filterType: MessageFilterType, filterValue: string | null) => { + const handleFilterSave = (payload: { + filterType: MessageFilterType; + filterValue: string | null; + }) => { updateFilter( - { filterType, filterValue }, + { + ...payload, + processFromSelf: status?.processFromSelf ?? false, + processGroups: status?.processGroups ?? false, + }, { onSuccess: () => { toast({ - title: 'Filter Updated', - description: filterType - ? `Message filter set to ${filterType}: "${filterValue}"` + title: 'Filter saved', + description: payload.filterType + ? `Message filter set to ${payload.filterType}: "${payload.filterValue}"` : 'Message filter removed. All messages will be processed.', }); }, onError: (error) => { toast({ - title: 'Filter Update Failed', + title: 'Update failed', description: error instanceof Error ? error.message : 'Failed to update message filter', variant: 'destructive', }); @@ -167,6 +175,36 @@ export default function WhatsAppConnection() { ); }; + const handleMessageSourcesSave = (processFromSelf: boolean, processGroups: boolean) => { + updateFilter( + { + filterType: status?.filterType ?? null, + filterValue: status?.filterValue ?? null, + processFromSelf, + processGroups, + }, + { + onSuccess: () => { + const parts = ['1:1 from others']; + if (processFromSelf) parts.push('from self'); + if (processGroups) parts.push('from groups'); + toast({ + title: 'Message sources updated', + description: parts.join(', ') + '.', + }); + }, + onError: (error) => { + toast({ + title: 'Update failed', + description: + error instanceof Error ? error.message : 'Failed to update message sources', + variant: 'destructive', + }); + }, + } + ); + }; + const handleResetSession = () => { resetSession(undefined, { onSuccess: () => { @@ -246,7 +284,7 @@ export default function WhatsAppConnection() { {/* QR Code Display (only show when connecting) */} {shouldShowQR && } - {/* Message Filter Configuration (only show when connected) */} + {/* Message Filter (only show when connected) */} {isConnected && ( )} + {/* Message sources – own section (only show when connected) */} + {isConnected && ( + + )} + {/* Instructions */} {isConnected && (
diff --git a/frontend/src/types/whatsapp.types.ts b/frontend/src/types/whatsapp.types.ts index 28a708b..3f48078 100644 --- a/frontend/src/types/whatsapp.types.ts +++ b/frontend/src/types/whatsapp.types.ts @@ -23,17 +23,21 @@ export interface WhatsAppConnection { lastConnectedAt: string | null; filterType: MessageFilterType; filterValue: string | null; + processFromSelf: boolean; + processGroups: boolean; autoApprovalMode: AutoApprovalMode; exceptionsEnabled: boolean; exceptionContacts: string[]; } /** - * Message filter configuration + * Message filter and source options */ export interface MessageFilterConfig { filterType: MessageFilterType; filterValue: string | null; + processFromSelf?: boolean; + processGroups?: boolean; } /** From 94b742f5b2aa7a2383056c37f8e2399f201ba0b3 Mon Sep 17 00:00:00 2001 From: winterborn Date: Thu, 5 Feb 2026 23:01:17 +0000 Subject: [PATCH 2/2] feat(whatsapp): make markOnlineOnConnect configurable - Add mark_online_on_connect to whatsapp_connections (default false) - Expose toggle in Phone notifications card on WhatsApp Connection page - Client reads option at init and passes to makeWASocket - When off, phone keeps notifications and unread badges; WAMR still receives messages Co-authored-by: Cursor --- .../0014_add_mark_online_on_connect.sql | 2 + .../api/controllers/whatsapp.controller.ts | 11 +++- .../src/api/validators/whatsapp.validators.ts | 1 + backend/src/db/schema.ts | 4 ++ .../src/models/whatsapp-connection.model.ts | 2 + .../whatsapp-connection.repository.ts | 10 +++- .../whatsapp/whatsapp-client.service.ts | 7 ++- .../whatsapp/phone-notifications-card.tsx | 57 +++++++++++++++++++ frontend/src/hooks/use-whatsapp.ts | 2 + frontend/src/pages/whatsapp-connection.tsx | 41 +++++++++++++ frontend/src/types/whatsapp.types.ts | 2 + 11 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 backend/drizzle/migrations/0014_add_mark_online_on_connect.sql create mode 100644 frontend/src/components/whatsapp/phone-notifications-card.tsx diff --git a/backend/drizzle/migrations/0014_add_mark_online_on_connect.sql b/backend/drizzle/migrations/0014_add_mark_online_on_connect.sql new file mode 100644 index 0000000..212129a --- /dev/null +++ b/backend/drizzle/migrations/0014_add_mark_online_on_connect.sql @@ -0,0 +1,2 @@ +-- Configurable: mark linked session as online on connect (default off so phone keeps notifications) +ALTER TABLE whatsapp_connections ADD COLUMN mark_online_on_connect INTEGER DEFAULT 0 NOT NULL; diff --git a/backend/src/api/controllers/whatsapp.controller.ts b/backend/src/api/controllers/whatsapp.controller.ts index f959111..e796d73 100644 --- a/backend/src/api/controllers/whatsapp.controller.ts +++ b/backend/src/api/controllers/whatsapp.controller.ts @@ -42,6 +42,7 @@ export const getStatus = async ( filterValue: activeConnection.filterValue, processFromSelf: activeConnection.processFromSelf, processGroups: activeConnection.processGroups, + markOnlineOnConnect: activeConnection.markOnlineOnConnect, autoApprovalMode: activeConnection.autoApprovalMode, exceptionsEnabled: activeConnection.exceptionsEnabled, exceptionContacts: activeConnection.exceptionContacts, @@ -83,6 +84,7 @@ export const getStatus = async ( filterValue: connection.filterValue, processFromSelf: connection.processFromSelf, processGroups: connection.processGroups, + markOnlineOnConnect: connection.markOnlineOnConnect, autoApprovalMode: connection.autoApprovalMode, exceptionsEnabled: connection.exceptionsEnabled, exceptionContacts: connection.exceptionContacts, @@ -198,15 +200,17 @@ export const updateMessageFilter = async ( return; } - const { filterType, filterValue, processFromSelf, processGroups } = result.data; + const { filterType, filterValue, processFromSelf, processGroups, markOnlineOnConnect } = + result.data; - // Update filter configuration and message source options + // Update filter configuration and message source / presence options const updated = await whatsappConnectionRepository.updateMessageFilter( filterType, filterValue, { ...(processFromSelf !== undefined && { processFromSelf }), ...(processGroups !== undefined && { processGroups }), + ...(markOnlineOnConnect !== undefined && { markOnlineOnConnect }), } ); @@ -219,7 +223,7 @@ export const updateMessageFilter = async ( } logger.info( - { filterType, filterValue, processFromSelf, processGroups }, + { filterType, filterValue, processFromSelf, processGroups, markOnlineOnConnect }, 'Message filter updated' ); @@ -230,6 +234,7 @@ export const updateMessageFilter = async ( filterValue: updated.filterValue, processFromSelf: updated.processFromSelf, processGroups: updated.processGroups, + markOnlineOnConnect: updated.markOnlineOnConnect, }); } catch (error) { logger.error({ error }, 'Failed to update message filter'); diff --git a/backend/src/api/validators/whatsapp.validators.ts b/backend/src/api/validators/whatsapp.validators.ts index 21d3f4a..f9193d2 100644 --- a/backend/src/api/validators/whatsapp.validators.ts +++ b/backend/src/api/validators/whatsapp.validators.ts @@ -14,6 +14,7 @@ export const messageFilterSchema = z .nullable(), processFromSelf: z.boolean().optional(), processGroups: z.boolean().optional(), + markOnlineOnConnect: z.boolean().optional(), }) .refine( (data) => diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 2c970f7..f419c5d 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -32,6 +32,10 @@ export const whatsappConnections = sqliteTable( // Process messages from self and/or groups (default: only 1:1 from others) processFromSelf: integer('process_from_self', { mode: 'boolean' }).notNull().default(false), processGroups: integer('process_groups', { mode: 'boolean' }).notNull().default(false), + // When true, linked session marks as online on connect (may stop phone notifications) + markOnlineOnConnect: integer('mark_online_on_connect', { mode: 'boolean' }) + .notNull() + .default(false), // Auto-approval mode autoApprovalMode: text('auto_approval_mode', { enum: ['auto_approve', 'auto_deny', 'manual'] }) .notNull() diff --git a/backend/src/models/whatsapp-connection.model.ts b/backend/src/models/whatsapp-connection.model.ts index 189aea6..d272726 100644 --- a/backend/src/models/whatsapp-connection.model.ts +++ b/backend/src/models/whatsapp-connection.model.ts @@ -17,6 +17,7 @@ export interface WhatsAppConnection { filterValue: string | null; processFromSelf: boolean; processGroups: boolean; + markOnlineOnConnect: boolean; autoApprovalMode: AutoApprovalMode; exceptionsEnabled: boolean; exceptionContacts: string[]; @@ -61,6 +62,7 @@ export interface WhatsAppConnectionResponse { filterValue: string | null; processFromSelf: boolean; processGroups: boolean; + markOnlineOnConnect: boolean; autoApprovalMode: AutoApprovalMode; exceptionsEnabled: boolean; exceptionContacts: string[]; diff --git a/backend/src/repositories/whatsapp-connection.repository.ts b/backend/src/repositories/whatsapp-connection.repository.ts index 282bf32..d71f54e 100644 --- a/backend/src/repositories/whatsapp-connection.repository.ts +++ b/backend/src/repositories/whatsapp-connection.repository.ts @@ -132,7 +132,11 @@ export class WhatsAppConnectionRepository { async updateMessageFilter( filterType: 'prefix' | 'keyword' | null, filterValue: string | null, - options?: { processFromSelf?: boolean; processGroups?: boolean } + options?: { + processFromSelf?: boolean; + processGroups?: boolean; + markOnlineOnConnect?: boolean; + } ): Promise { const connections = await this.findAll(); @@ -151,6 +155,9 @@ export class WhatsAppConnectionRepository { if (options?.processGroups !== undefined) { setValues.processGroups = options.processGroups ? 1 : 0; } + if (options?.markOnlineOnConnect !== undefined) { + setValues.markOnlineOnConnect = options.markOnlineOnConnect ? 1 : 0; + } const result = await db .update(whatsappConnections) @@ -186,6 +193,7 @@ export class WhatsAppConnectionRepository { filterValue: row.filterValue, processFromSelf: Boolean(row.processFromSelf), processGroups: Boolean(row.processGroups), + markOnlineOnConnect: Boolean(row.markOnlineOnConnect), autoApprovalMode: (row.autoApprovalMode as 'auto_approve' | 'auto_deny' | 'manual') || 'auto_approve', exceptionsEnabled: Boolean(row.exceptionsEnabled), diff --git a/backend/src/services/whatsapp/whatsapp-client.service.ts b/backend/src/services/whatsapp/whatsapp-client.service.ts index cda50e4..d689129 100644 --- a/backend/src/services/whatsapp/whatsapp-client.service.ts +++ b/backend/src/services/whatsapp/whatsapp-client.service.ts @@ -97,13 +97,18 @@ class WhatsAppClientService { const { state, saveCreds } = await useMultiFileAuthState(env.WHATSAPP_SESSION_PATH); this.saveCreds = saveCreds; + // Read connection options (markOnlineOnConnect etc.) from DB; default false so phone keeps notifications + const connections = await whatsappConnectionRepository.findAll(); + const markOnlineOnConnect = connections[0]?.markOnlineOnConnect ?? false; + // Create Baileys socket this.sock = makeWASocket({ auth: state, printQRInTerminal: false, // We'll handle QR code ourselves via WebSocket browser: ['WAMR', 'Chrome', '1.0.0'], syncFullHistory: false, - markOnlineOnConnect: true, + // When false, linked session does not mark as online; phone keeps notifications and unread badges + markOnlineOnConnect, // Disable auto-retry to handle reconnection manually retryRequestDelayMs: 2000, }); diff --git a/frontend/src/components/whatsapp/phone-notifications-card.tsx b/frontend/src/components/whatsapp/phone-notifications-card.tsx new file mode 100644 index 0000000..9430862 --- /dev/null +++ b/frontend/src/components/whatsapp/phone-notifications-card.tsx @@ -0,0 +1,57 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'; +import { Label } from '../ui/label'; +import { Switch } from '../ui/switch'; +import { Bell } from 'lucide-react'; + +interface PhoneNotificationsCardProps { + markOnlineOnConnect: boolean; + onSave: (markOnlineOnConnect: boolean) => void; + isSaving?: boolean; +} + +export function PhoneNotificationsCard({ + markOnlineOnConnect, + onSave, + isSaving = false, +}: PhoneNotificationsCardProps) { + const handleChange = (checked: boolean) => { + onSave(checked); + }; + + return ( + + + + + Phone notifications + + + Control whether your linked session appears "online" when WAMR is connected. + When off (recommended), your phone keeps getting notifications and unread badges while + WAMR still receives and processes messages. + + + +
+
+ +

+ When on, the linked session appears online and WhatsApp may stop sending notifications + to your phone and clear unread badges. Turn off to keep getting notifications on your + phone while WAMR still receives and processes messages. +

+
+ +
+

+ Changes apply on next connect or reconnect. +

+
+
+ ); +} diff --git a/frontend/src/hooks/use-whatsapp.ts b/frontend/src/hooks/use-whatsapp.ts index cdd8d95..9d5e139 100644 --- a/frontend/src/hooks/use-whatsapp.ts +++ b/frontend/src/hooks/use-whatsapp.ts @@ -154,6 +154,7 @@ export function useWhatsApp() { filterValue: null, processFromSelf: false, processGroups: false, + markOnlineOnConnect: false, autoApprovalMode: 'auto_approve', exceptionsEnabled: false, exceptionContacts: [], @@ -203,6 +204,7 @@ export function useWhatsApp() { filterValue: null, processFromSelf: false, processGroups: false, + markOnlineOnConnect: false, autoApprovalMode: 'auto_approve', exceptionsEnabled: false, exceptionContacts: [], diff --git a/frontend/src/pages/whatsapp-connection.tsx b/frontend/src/pages/whatsapp-connection.tsx index c10c0bb..308902a 100644 --- a/frontend/src/pages/whatsapp-connection.tsx +++ b/frontend/src/pages/whatsapp-connection.tsx @@ -7,6 +7,7 @@ import { QRCodeDisplay } from '../components/whatsapp/qr-code-display'; import { ConnectionStatus } from '../components/whatsapp/connection-status'; import { MessageFilterForm } from '../components/whatsapp/message-filter-form'; import { MessageSourcesCard } from '../components/whatsapp/message-sources-card'; +import { PhoneNotificationsCard } from '../components/whatsapp/phone-notifications-card'; import { Smartphone, Loader2, RefreshCw, Trash2, AlertTriangle } from 'lucide-react'; import type { MessageFilterType } from '../types/whatsapp.types'; import { useQueryClient } from '@tanstack/react-query'; @@ -154,6 +155,7 @@ export default function WhatsAppConnection() { ...payload, processFromSelf: status?.processFromSelf ?? false, processGroups: status?.processGroups ?? false, + markOnlineOnConnect: status?.markOnlineOnConnect ?? false, }, { onSuccess: () => { @@ -205,6 +207,36 @@ export default function WhatsAppConnection() { ); }; + const handleMarkOnlineSave = (markOnlineOnConnect: boolean) => { + updateFilter( + { + filterType: status?.filterType ?? null, + filterValue: status?.filterValue ?? null, + processFromSelf: status?.processFromSelf ?? false, + processGroups: status?.processGroups ?? false, + markOnlineOnConnect, + }, + { + onSuccess: () => { + toast({ + title: 'Phone notifications updated', + description: markOnlineOnConnect + ? 'Session will appear online when connected; phone may get fewer notifications.' + : 'Session will not appear online; your phone will keep getting notifications.', + }); + }, + onError: (error) => { + toast({ + title: 'Update failed', + description: + error instanceof Error ? error.message : 'Failed to update phone notifications', + variant: 'destructive', + }); + }, + } + ); + }; + const handleResetSession = () => { resetSession(undefined, { onSuccess: () => { @@ -304,6 +336,15 @@ export default function WhatsAppConnection() { /> )} + {/* Phone notifications (only show when connected) */} + {isConnected && ( + + )} + {/* Instructions */} {isConnected && (
diff --git a/frontend/src/types/whatsapp.types.ts b/frontend/src/types/whatsapp.types.ts index 3f48078..76bb26c 100644 --- a/frontend/src/types/whatsapp.types.ts +++ b/frontend/src/types/whatsapp.types.ts @@ -25,6 +25,7 @@ export interface WhatsAppConnection { filterValue: string | null; processFromSelf: boolean; processGroups: boolean; + markOnlineOnConnect: boolean; autoApprovalMode: AutoApprovalMode; exceptionsEnabled: boolean; exceptionContacts: string[]; @@ -38,6 +39,7 @@ export interface MessageFilterConfig { filterValue: string | null; processFromSelf?: boolean; processGroups?: boolean; + markOnlineOnConnect?: boolean; } /**