Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions backend/src/api/controllers/settings.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The markOnlineOnConnect field is missing from the export data. While processFromSelf and processGroups are included in lines 225-226, the markOnlineOnConnect field should also be exported for data consistency.

Suggested change
processGroups: whatsappConnection.processGroups,
processGroups: whatsappConnection.processGroups,
markOnlineOnConnect: whatsappConnection.markOnlineOnConnect,

Copilot uses AI. Check for mistakes.
autoApprovalMode: whatsappConnection.autoApprovalMode,
exceptionsEnabled: whatsappConnection.exceptionsEnabled,
exceptionContacts: whatsappConnection.exceptionContacts,
Expand Down
31 changes: 26 additions & 5 deletions backend/src/api/controllers/whatsapp.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export const getStatus = async (
lastConnectedAt: activeConnection.lastConnectedAt,
filterType: activeConnection.filterType,
filterValue: activeConnection.filterValue,
processFromSelf: activeConnection.processFromSelf,
processGroups: activeConnection.processGroups,
markOnlineOnConnect: activeConnection.markOnlineOnConnect,
autoApprovalMode: activeConnection.autoApprovalMode,
exceptionsEnabled: activeConnection.exceptionsEnabled,
exceptionContacts: activeConnection.exceptionContacts,
Expand Down Expand Up @@ -79,6 +82,9 @@ export const getStatus = async (
lastConnectedAt: connection.lastConnectedAt,
filterType: connection.filterType,
filterValue: connection.filterValue,
processFromSelf: connection.processFromSelf,
processGroups: connection.processGroups,
markOnlineOnConnect: connection.markOnlineOnConnect,
autoApprovalMode: connection.autoApprovalMode,
exceptionsEnabled: connection.exceptionsEnabled,
exceptionContacts: connection.exceptionContacts,
Expand Down Expand Up @@ -194,10 +200,19 @@ export const updateMessageFilter = async (
return;
}

const { filterType, filterValue } = result.data;

// Update filter configuration
const updated = await whatsappConnectionRepository.updateMessageFilter(filterType, filterValue);
const { filterType, filterValue, processFromSelf, processGroups, markOnlineOnConnect } =
result.data;

// Update filter configuration and message source / presence options
const updated = await whatsappConnectionRepository.updateMessageFilter(
filterType,
filterValue,
{
...(processFromSelf !== undefined && { processFromSelf }),
...(processGroups !== undefined && { processGroups }),
...(markOnlineOnConnect !== undefined && { markOnlineOnConnect }),
}
);

if (!updated) {
res.status(404).json({
Expand All @@ -207,13 +222,19 @@ export const updateMessageFilter = async (
return;
}

logger.info({ filterType, filterValue }, 'Message filter updated');
logger.info(
{ filterType, filterValue, processFromSelf, processGroups, markOnlineOnConnect },
'Message filter updated'
);

res.json({
success: true,
message: 'Message filter updated successfully',
filterType: updated.filterType,
filterValue: updated.filterValue,
processFromSelf: updated.processFromSelf,
processGroups: updated.processGroups,
markOnlineOnConnect: updated.markOnlineOnConnect,
});
} catch (error) {
logger.error({ error }, 'Failed to update message filter');
Expand Down
3 changes: 3 additions & 0 deletions backend/src/api/validators/whatsapp.validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ 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(),
markOnlineOnConnect: z.boolean().optional(),
})
.refine(
(data) =>
Expand Down
7 changes: 7 additions & 0 deletions backend/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ 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),
// 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()
Expand Down
6 changes: 6 additions & 0 deletions backend/src/models/whatsapp-connection.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export interface WhatsAppConnection {
qrCodeGeneratedAt: Date | null;
filterType: MessageFilterType;
filterValue: string | null;
processFromSelf: boolean;
processGroups: boolean;
markOnlineOnConnect: boolean;
autoApprovalMode: AutoApprovalMode;
exceptionsEnabled: boolean;
exceptionContacts: string[];
Expand Down Expand Up @@ -57,6 +60,9 @@ export interface WhatsAppConnectionResponse {
lastConnectedAt: Date | null;
filterType: MessageFilterType;
filterValue: string | null;
processFromSelf: boolean;
processGroups: boolean;
markOnlineOnConnect: boolean;
autoApprovalMode: AutoApprovalMode;
exceptionsEnabled: boolean;
exceptionContacts: string[];
Expand Down
33 changes: 26 additions & 7 deletions backend/src/repositories/whatsapp-connection.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,25 +127,41 @@ 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;
markOnlineOnConnect?: boolean;
}
): Promise<WhatsAppConnection | undefined> {
const connections = await this.findAll();

if (connections.length === 0) {
return undefined;
}

const setValues: Record<string, unknown> = {
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;
}
if (options?.markOnlineOnConnect !== undefined) {
setValues.markOnlineOnConnect = options.markOnlineOnConnect ? 1 : 0;
}

const result = await db
.update(whatsappConnections)
.set({
filterType,
filterValue,
updatedAt: new Date().toISOString(),
})
.set(setValues as Record<string, string | number>)
.where(eq(whatsappConnections.id, connections[0].id))
.returning();

Expand Down Expand Up @@ -175,6 +191,9 @@ 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),
markOnlineOnConnect: Boolean(row.markOnlineOnConnect),
autoApprovalMode:
(row.autoApprovalMode as 'auto_approve' | 'auto_deny' | 'manual') || 'auto_approve',
exceptionsEnabled: Boolean(row.exceptionsEnabled),
Expand Down
77 changes: 29 additions & 48 deletions backend/src/services/whatsapp/message-handler.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down Expand Up @@ -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;
Expand All @@ -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 });
Expand Down
27 changes: 18 additions & 9 deletions backend/src/services/whatsapp/whatsapp-client.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down Expand Up @@ -299,19 +304,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;
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition if ((isGroup || isBroadcast) && !processGroups) continue; treats broadcast messages the same as group messages. However, broadcasts (status updates) are different from group messages and should likely be skipped unconditionally. Consider separating these conditions: skip broadcasts always, and skip groups only when !processGroups.

Suggested change
if ((isGroup || isBroadcast) && !processGroups) continue;
if (isBroadcast) continue;
if (isGroup && !processGroups) continue;

Copilot uses AI. Check for mistakes.
if (!remoteJid) continue;

logger.debug({ from: remoteJid }, 'Message received');

// Forward to message callback
if (this.messageCallback) {
this.messageCallback(message as BaileysMessage);
}
Expand Down
9 changes: 7 additions & 2 deletions backend/tests/unit/controllers/whatsapp.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,8 @@ describe('WhatsApp Controller', () => {
id: 1,
filterType: 'contains',
filterValue: 'movie',
processFromSelf: false,
processGroups: false,
};

(messageFilterSchema.safeParse as Mock).mockReturnValue({
Expand All @@ -301,17 +303,20 @@ 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({
success: true,
message: 'Message filter updated successfully',
filterType: 'contains',
filterValue: 'movie',
processFromSelf: false,
processGroups: false,
});
Comment on lines +318 to 320
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test expectations need to be updated. The log assertion should include markOnlineOnConnect: undefined alongside the existing fields, and the response assertion should include markOnlineOnConnect: false to match the actual implementation in the controller.

Copilot uses AI. Check for mistakes.
});

Expand Down
Loading