diff --git a/.env.example b/.env.example index af6ff218..ed02fee2 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,9 @@ MAX_RETRIES=-1 # Maximun time to connect to whatsapp RECONNECT_INTERVAL=5000 +# How long to expose the last session issue (ms) after a rejected link +SESSION_ISSUE_TTL_MS=900000 + # Authentication AUTHENTICATION_GLOBAL_AUTH_TOKEN=A4gx18YGxKAvR01ClcHpcR7TjZUNtwvE diff --git a/controllers/chatsController.js b/controllers/chatsController.js index 8046010d..c77a2212 100644 --- a/controllers/chatsController.js +++ b/controllers/chatsController.js @@ -3,8 +3,7 @@ import { getChatList, isExists, sendMessage, - formatPhone, - formatGroup, + formatChatJid, readMessage, getMessageMedia, getStoreMessage, @@ -20,13 +19,13 @@ const send = async (req, res) => { const session = getSession(res.locals.sessionId) const { message } = req.body const isGroup = req.body.isGroup ?? false - const receiver = isGroup ? formatGroup(req.body.receiver) : formatPhone(req.body.receiver) + const receiver = formatChatJid(req.body.receiver, isGroup) const typesMessage = ['image', 'video', 'audio', 'document', 'sticker'] const filterTypeMessaje = compareAndFilter(Object.keys(message), typesMessage) try { - const exists = await isExists(session, receiver, isGroup) + const exists = receiver.endsWith('@lid') ? true : await isExists(session, receiver, isGroup) if (!exists) { return response(res, 400, false, 'The receiver number is not exists.') @@ -70,10 +69,10 @@ const sendBulk = async (req, res) => { delay = 1000 } - receiver = formatPhone(receiver) + receiver = formatChatJid(receiver) try { - const exists = await isExists(session, receiver) + const exists = receiver.endsWith('@lid') ? true : await isExists(session, receiver) if (!exists) { errors.push({ key, message: 'number not exists on whatsapp' }) @@ -106,7 +105,7 @@ const deleteChat = async (req, res) => { const { receiver, isGroup, message } = req.body try { - const jidFormat = isGroup ? formatGroup(receiver) : formatPhone(receiver) + const jidFormat = formatChatJid(receiver, isGroup) await sendMessage(session, jidFormat, { delete: message }) response(res, 200, true, 'Message has been successfully deleted.') @@ -120,7 +119,7 @@ const forward = async (req, res) => { const { forward, receiver, isGroup } = req.body const { id, remoteJid } = forward - const jidFormat = isGroup ? formatGroup(receiver) : formatPhone(receiver) + const jidFormat = formatChatJid(receiver, isGroup) try { const messages = await session.store.loadMessages(remoteJid, 25, null) @@ -164,7 +163,7 @@ const sendPresence = async (req, res) => { const { receiver, isGroup, presence } = req.body try { - const jidFormat = isGroup ? formatGroup(receiver) : formatPhone(receiver) + const jidFormat = formatChatJid(receiver, isGroup) await session.sendPresenceUpdate(presence, jidFormat) diff --git a/controllers/getMessages.js b/controllers/getMessages.js index 23ccdf5b..ba8f48b0 100644 --- a/controllers/getMessages.js +++ b/controllers/getMessages.js @@ -1,4 +1,4 @@ -import { getSession, formatGroup, formatPhone } from '../whatsapp.js' +import { getSession, formatChatJid } from '../whatsapp.js' import response from './../response.js' const getMessages = async (req, res) => { @@ -8,7 +8,7 @@ const getMessages = async (req, res) => { const { limit = 25, cursorId = null, cursorFromMe = null, isGroup = false } = req.query const isGroupBool = isGroup === 'true' - const jidFormat = isGroupBool ? formatGroup(jid) : formatPhone(jid) + const jidFormat = formatChatJid(jid, isGroupBool) const cursor = {} diff --git a/controllers/miscControlls.js b/controllers/miscControlls.js index 851fc7ef..8d6b7ac0 100644 --- a/controllers/miscControlls.js +++ b/controllers/miscControlls.js @@ -4,7 +4,7 @@ import { getSession, getProfilePicture, formatPhone, - formatGroup, + formatChatJid, profilePicture, blockAndUnblockUser, sendMessage, @@ -63,7 +63,7 @@ const getProfilePictureUser = async (req, res) => { try { const session = getSession(res.locals.sessionId) const isGroup = req.body.isGroup ?? false - const jid = isGroup ? formatGroup(req.body.jid) : formatPhone(req.body.jid) + const jid = formatChatJid(req.body.jid, isGroup) const imagen = await getProfilePicture(session, jid, 'image') @@ -81,7 +81,7 @@ const blockAndUnblockContact = async (req, res) => { try { const session = getSession(res.locals.sessionId) const { jid, isBlock } = req.body - const jidFormat = formatPhone(jid) + const jidFormat = formatChatJid(jid) const blockFormat = isBlock === true ? 'block' : 'unblock' await blockAndUnblockUser(session, jidFormat, blockFormat) response(res, 200, true, 'The contact has been blocked or unblocked successfully') diff --git a/middlewares/sessionValidator.js b/middlewares/sessionValidator.js index 881f22fe..62a2236a 100644 --- a/middlewares/sessionValidator.js +++ b/middlewares/sessionValidator.js @@ -1,10 +1,16 @@ -import { isSessionExists, isSessionConnected } from '../whatsapp.js' +import { isSessionExists, isSessionConnected, getSessionIssue } from '../whatsapp.js' import response from './../response.js' const validate = (req, res, next) => { const sessionId = req.query.id ?? req.params.id if (!isSessionExists(sessionId)) { + const issue = getSessionIssue(sessionId) + // Fork guard: surface the last mismatch briefly so clients do not see an ambiguous 404. + if (issue && req.baseUrl === '/sessions' && (req.path.startsWith('/status/') || req.path.startsWith('/find/'))) { + return response(res, 409, false, issue.message, issue) + } + return response(res, 404, false, 'Session not found.') } diff --git a/useDBAuthState/mysql-auth-store.js b/useDBAuthState/mysql-auth-store.js index 889e73f4..0b37b409 100644 --- a/useDBAuthState/mysql-auth-store.js +++ b/useDBAuthState/mysql-auth-store.js @@ -3,6 +3,13 @@ import { decryptText, encryptText } from '../persistence/crypto.js'; // --- Mutex en memoria por session_id (serializa escrituras dentro del proceso) --- const _memQueues = new Map(); +const getEnv = (key, fallback = '') => { + const raw = process.env[key]; + if (typeof raw !== 'string') return fallback; + const value = raw.trim(); + return value === '' ? fallback : value; +}; + function withSessionMutex(sessionId, task) { const prev = _memQueues.get(sessionId) || Promise.resolve(); const next = prev.then(() => task()); @@ -17,11 +24,13 @@ export default class MySQLAuthStore { constructor() { if (!MySQLAuthStore.pool) { const dbPoolLimit = Number.parseInt(process.env.DB_POOL_LIMIT ?? '30', 10); + const dbPort = Number.parseInt(getEnv('DB_PORT', '3306'), 10); MySQLAuthStore.pool = mysql.createPool({ - host: 'localhost', - user: process.env.DB_USER, - password: process.env.DB_PASWD, - database: process.env.DB_NAME, + host: getEnv('DB_HOST', '127.0.0.1'), + port: Number.isNaN(dbPort) ? 3306 : dbPort, + user: getEnv('DB_USER', 'root'), + password: getEnv('DB_PASWD', ''), + database: getEnv('DB_NAME', 'baileys_api'), waitForConnections: true, connectionLimit: Number.isNaN(dbPoolLimit) ? 30 : dbPoolLimit, queueLimit: 0, diff --git a/whatsapp.js b/whatsapp.js index e984d202..98122a8c 100644 --- a/whatsapp.js +++ b/whatsapp.js @@ -34,6 +34,8 @@ const msgRetryCounterCache = new NodeCache() const sessions = new Map() const retries = new Map() const creatingSessions = new Set() +const sessionIssues = new Map() +const sessionIssueTimers = new Map() const { driver: SESSION_STORAGE_DRIVER, encryptionEnabled: DB_ENCRYPTION_ENABLED } = getPersistenceInfo() @@ -43,11 +45,129 @@ if (DB_ENCRYPTION_ENABLED) { } const APP_WEBHOOK_ALLOWED_EVENTS = (process.env.APP_WEBHOOK_ALLOWED_EVENTS ?? 'ALL').split(',') +const SESSION_ISSUE_TTL_MS = Number.parseInt(process.env.SESSION_ISSUE_TTL_MS ?? '900000', 10) const sessionsDir = (sessionId = '') => { return join(__dirname, 'sessions', sessionId ? sessionId : '') } +// Fork guard: tie QR auth to the expected phone so a different account cannot claim the session id. +const normalizePhoneDigits = (value = '') => { + if (typeof value !== 'string') { + return '' + } + + const digits = value.replace(/\D/g, '') + return digits.length >= 8 && digits.length <= 15 ? digits : '' +} + +const extractPhoneFromJid = (jid = '') => { + if (typeof jid !== 'string' || jid.length === 0) { + return '' + } + + return normalizePhoneDigits(jid.split(':')[0].split('@')[0]) +} + +const isExplicitJid = (value = '') => { + if (typeof value !== 'string') { + return false + } + + return /@(?:s\.whatsapp\.net|g\.us|lid|broadcast|newsletter)$/i.test(value.trim()) +} + +const isLidJid = (value = '') => { + return typeof value === 'string' && value.trim().endsWith('@lid') +} + +const findContactByJid = (store, jid = '') => { + if (!store?.contacts || typeof jid !== 'string' || jid.length === 0) { + return null + } + + for (const [entryJid, contact] of store.contacts.entries()) { + if (entryJid === jid || contact?.id === jid || contact?.lid === jid) { + return { entryJid, contact } + } + } + + return null +} + +const resolvePhoneJid = (store, jid = '') => { + if (typeof jid !== 'string' || jid.length === 0) { + return '' + } + + if (jid.endsWith('@s.whatsapp.net')) { + return jid + } + + const match = findContactByJid(store, jid) + const candidate = match?.entryJid ?? match?.contact?.id ?? '' + + return candidate.endsWith('@s.whatsapp.net') ? candidate : '' +} + +const enrichMessageAddressing = (store, message = {}) => { + if (!message?.key) { + return message + } + + const remotePhoneJid = resolvePhoneJid(store, message.key.remoteJid) + const participantPhoneJid = resolvePhoneJid(store, message.key.participant) + + return { + ...message, + key: { + ...message.key, + phoneJid: remotePhoneJid || undefined, + phoneNumber: extractPhoneFromJid(remotePhoneJid) || undefined, + participantPhoneJid: participantPhoneJid || undefined, + participantPhoneNumber: extractPhoneFromJid(participantPhoneJid) || undefined, + }, + } +} + +const resolveExpectedPhone = (sessionId, phoneNumber = '') => { + return normalizePhoneDigits(phoneNumber) || normalizePhoneDigits(sessionId) +} + +const clearSessionIssue = (sessionId) => { + const timer = sessionIssueTimers.get(sessionId) + if (timer) { + clearTimeout(timer) + sessionIssueTimers.delete(sessionId) + } + + sessionIssues.delete(sessionId) +} + +const setSessionIssue = (sessionId, issue) => { + clearSessionIssue(sessionId) + sessionIssues.set(sessionId, { + ...issue, + timestamp: new Date().toISOString(), + }) + + if (Number.isNaN(SESSION_ISSUE_TTL_MS) || SESSION_ISSUE_TTL_MS <= 0) { + return + } + + const timer = setTimeout(() => { + sessionIssues.delete(sessionId) + sessionIssueTimers.delete(sessionId) + }, SESSION_ISSUE_TTL_MS) + + timer.unref?.() + sessionIssueTimers.set(sessionId, timer) +} + +const getSessionIssue = (sessionId) => { + return sessionIssues.get(sessionId) ?? null +} + const isSessionExists = (sessionId) => { return sessions.has(sessionId) } @@ -73,6 +193,26 @@ const shouldReconnect = (sessionId) => { return false } +// Fork guard: reject and purge auth state when the linked WhatsApp number does not match. +const rejectSessionPhoneMismatch = async (sessionId, wa, expectedPhone, actualPhone) => { + const issue = { + code: 'session_phone_mismatch', + message: 'The scanned WhatsApp account does not match the expected phone number.', + expectedPhone, + actualPhone, + } + + setSessionIssue(sessionId, issue) + console.warn(`[SESSION] Phone mismatch for ${sessionId}: expected ${expectedPhone}, got ${actualPhone}`) + + try { + await wa.logout() + } catch { + } finally { + await deleteSession(sessionId) + } +} + const callWebhook = async (instance, eventType, eventData) => { if (APP_WEBHOOK_ALLOWED_EVENTS.includes('ALL') || APP_WEBHOOK_ALLOWED_EVENTS.includes(eventType)) { await webhook(instance, eventType, eventData) @@ -154,6 +294,8 @@ const createSession = async (sessionId, res = null, options = { usePairingCode: creatingSessions.add(sessionId) try { + clearSessionIssue(sessionId) + if (isSessionExists(sessionId)) { await closeSessionResources(sessionId, { deleteAuth: false, clearRetry: false }) } @@ -238,8 +380,9 @@ const createSession = async (sessionId, res = null, options = { usePairingCode: messages.map(async (msg) => { try { const typeMessage = Object.keys(msg.message)[0] + const enrichedMessage = enrichMessageAddressing(store, msg) if (msg?.status) { - msg.status = WAMessageStatus[msg?.status] ?? 'UNKNOWN' + enrichedMessage.status = WAMessageStatus[msg?.status] ?? 'UNKNOWN' } if ( @@ -265,7 +408,7 @@ const createSession = async (sessionId, res = null, options = { usePairingCode: }) return { - ...msg, + ...enrichedMessage, message: { [typeMessage]: { ...msg.message[typeMessage], @@ -275,7 +418,7 @@ const createSession = async (sessionId, res = null, options = { usePairingCode: } } - return msg + return enrichedMessage } catch { return {} } @@ -350,6 +493,18 @@ const createSession = async (sessionId, res = null, options = { usePairingCode: callWebhook(sessionId, 'CONNECTION_UPDATE', update) if (connection === 'open') { + const expectedPhone = resolveExpectedPhone(sessionId, options.phoneNumber) + const actualPhone = extractPhoneFromJid( + wa.user?.id ?? wa.authState?.creds?.me?.id ?? state.creds?.me?.id ?? sessions.get(sessionId)?.user?.id, + ) + + // Fork guard: fail fast if the authenticated number is not the one requested for this session. + if (expectedPhone && actualPhone && expectedPhone !== actualPhone) { + await rejectSessionPhoneMismatch(sessionId, wa, expectedPhone, actualPhone) + return + } + + clearSessionIssue(sessionId) retries.delete(sessionId) let removedContacts = 0 @@ -366,6 +521,21 @@ const createSession = async (sessionId, res = null, options = { usePairingCode: } if (connection === 'close') { + // Keep the mismatch reason visible for a short time even after the auth files are deleted. + if (getSessionIssue(sessionId)?.code === 'session_phone_mismatch') { + if (res && !res.headersSent) { + response( + res, + 409, + false, + 'The scanned WhatsApp account does not match the expected phone number.', + getSessionIssue(sessionId), + ) + } + + return await deleteSession(sessionId) + } + if (statusCode === DisconnectReason.loggedOut || !shouldReconnect(sessionId)) { if (res && !res.headersSent) { response(res, 500, false, 'Unable to create session.') @@ -497,6 +667,10 @@ const isExists = async (session, jid, isGroup = false) => { return Boolean(result.id) } + if (isLidJid(jid)) { + return Boolean(findContactByJid(session?.store, jid) || session?.store?.chats?.get?.(jid)) + } + ;[result] = await session.onWhatsApp(jid) return result.exists @@ -562,6 +736,28 @@ const formatPhone = (phone) => { return (formatted += '@s.whatsapp.net') } +const formatChatJid = (value, isGroup = false) => { + if (typeof value !== 'string') { + return '' + } + + const trimmed = value.trim() + + if (!trimmed) { + return '' + } + + if (isGroup) { + return formatGroup(trimmed) + } + + if (isExplicitJid(trimmed)) { + return trimmed + } + + return formatPhone(trimmed) +} + const formatGroup = (group) => { if (group.endsWith('@g.us')) { return group @@ -714,6 +910,7 @@ export { isSessionExists, createSession, getSession, + getSessionIssue, getListSessions, deleteSession, getChatList, @@ -724,6 +921,7 @@ export { updateProfileName, getProfilePicture, formatPhone, + formatChatJid, formatGroup, cleanup, participantsUpdate,