From 4a00aa82a4308860a7fe3a8e49c200245c1ea497 Mon Sep 17 00:00:00 2001 From: Berg Pinheiro Date: Mon, 17 Aug 2026 12:35:53 -0300 Subject: [PATCH] [core] Resolve Brazilian 9th-digit ambiguity on send (opt-in) Brazilian mobile numbers may or may not carry the 9th digit, and only one of the two forms is on WhatsApp. Sending to the wrong one fails silently. Behind WAHA_BR_PHONE_NORMALIZE (off by default), outbound chat ids for Brazilian mobiles are resolved to the form the account actually uses, querying the engine once and caching the answer per session. Reject is deliberately left alone: the caller's own JID is not an outbound destination, and normalizing it would retarget the reject at a different number, which WhatsApp drops silently while the caller keeps ringing. --- src/core/abc/session.abc.brPhone.test.ts | 114 +++++++ src/core/abc/session.abc.ts | 297 +++++++++++++++++++ src/core/engines/gows/session.gows.core.ts | 108 ++++--- src/core/engines/noweb/session.noweb.core.ts | 69 +++-- src/core/engines/webjs/session.webjs.core.ts | 32 +- src/core/engines/wpp/session.wpp.core.ts | 61 ++-- src/core/env.ts | 21 ++ src/core/utils/brPhone.test.ts | 120 ++++++++ src/core/utils/brPhone.ts | 180 +++++++++++ 9 files changed, 882 insertions(+), 120 deletions(-) create mode 100644 src/core/abc/session.abc.brPhone.test.ts create mode 100644 src/core/utils/brPhone.test.ts create mode 100644 src/core/utils/brPhone.ts diff --git a/src/core/abc/session.abc.brPhone.test.ts b/src/core/abc/session.abc.brPhone.test.ts new file mode 100644 index 000000000..3597a2aec --- /dev/null +++ b/src/core/abc/session.abc.brPhone.test.ts @@ -0,0 +1,114 @@ +/** + * Brazilian 9th-digit resolution (resolveOutboundChatId). + * + * Lives in its own file because BR_PHONE_NORMALIZE is read from the + * environment when core/env is first loaded, so the switch has to be in place + * before session.abc is required. + */ + +const logger: any = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +}; +logger.child = () => logger; + +const IGNORE_NOTHING = { + status: false, + groups: false, + channels: false, + broadcast: false, +}; + +describe('resolveOutboundChatId - BR 9th digit', () => { + let BaseSession: any; + + beforeAll(() => { + process.env.WAHA_BR_PHONE_NORMALIZE = 'true'; + jest.resetModules(); + BaseSession = require('@waha/core/abc/session.abc').WhatsappSession; + }); + + function buildSession(chatIdToAnswer: string): any { + class TestSession extends BaseSession { + public lookups: string[] = []; + + constructor(params: any) { + super(params); + } + + async checkNumberStatus(request: any) { + this.lookups.push(request.phone); + return { numberExists: true, chatId: chatIdToAnswer }; + } + } + + return new TestSession({ + name: 'test', + printQR: false, + loggerBuilder: { child: () => logger }, + sessionStore: null, + mediaManager: null, + sessionConfig: null, + engineConfig: null, + ignore: IGNORE_NOTHING, + }); + } + + it('resolves and caches the canonical phone when the engine answers with a PN', async () => { + // '558591203123' is the real number: DDD 85 with an 8-digit local part. + const session = buildSession('558591203123@c.us'); + + const first = await session.resolveOutboundChatId('5585991203123@c.us'); + const second = await session.resolveOutboundChatId('5585991203123@c.us'); + + expect(first).toBe('558591203123@c.us'); + expect(second).toBe('558591203123@c.us'); + // Second call is served from cache - no extra WhatsApp lookup. + expect(session.lookups).toEqual(['558591203123']); + }); + + it('caches a LID answer as-is instead of stapling a phone suffix on it', async () => { + // Engines answer with a LID only when the account has no phone form. The + // LID is routable, but '77820596330581@c.us' - its digits with a phone + // suffix - addresses nobody. + const session = buildSession('77820596330581@lid'); + + const first = await session.resolveOutboundChatId('5585991203123@c.us'); + const second = await session.resolveOutboundChatId('5585991203123@c.us'); + + expect(first).toBe('77820596330581@lid'); + expect(second).toBe('77820596330581@lid'); + expect(session.lookups).toEqual(['558591203123']); + }); + + it('reuses the cache across both forms of the same number', async () => { + const session = buildSession('558591203123@c.us'); + + // Wrong form first, then the already-correct one: same target, one lookup. + const wrongForm = await session.resolveOutboundChatId('5585991203123@c.us'); + const rightForm = await session.resolveOutboundChatId('558591203123@c.us'); + + expect(wrongForm).toBe('558591203123@c.us'); + expect(rightForm).toBe('558591203123@c.us'); + expect(session.lookups).toEqual(['558591203123']); + }); + + it('rewrites a dialed toll-free (0800) to the stored form, no lookup', async () => { + const session = buildSession('unused@c.us'); + + // Dialed forms with and without the country code, plus the already-stored + // form, all address the same chat id - and none hits the WhatsApp lookup. + expect(await session.resolveOutboundChatId('08000464636@c.us')).toBe( + '558000464636@c.us', + ); + expect(await session.resolveOutboundChatId('5508000464636@c.us')).toBe( + '558000464636@c.us', + ); + expect(await session.resolveOutboundChatId('558000464636@c.us')).toBe( + '558000464636@c.us', + ); + expect(session.lookups).toEqual([]); + }); +}); diff --git a/src/core/abc/session.abc.ts b/src/core/abc/session.abc.ts index b513f9f4f..a6570acea 100644 --- a/src/core/abc/session.abc.ts +++ b/src/core/abc/session.abc.ts @@ -90,6 +90,7 @@ import { MessageVideoRequest, MessageVoiceRequest, SendSeenRequest, + WANumberExistResult, } from '../../structures/chatting.dto'; import { ContactQuery, @@ -143,8 +144,28 @@ import { fetchBuffer } from '@waha/utils/fetch'; import { PRESENCE_AUTO_ONLINE, PRESENCE_AUTO_ONLINE_DURATION_SECONDS, + BR_PHONE_NORMALIZE, + BR_PHONE_STRICT, } from '@waha/core/env'; import { Activity } from '@waha/core/abc/activity'; +import { + BR_PHONE_CACHE_TTL_SECONDS, + BR_PHONE_DDD_LOOKUP_MAX_DEFAULT, + BR_PHONE_DDD_LOOKUP_MIN_DEFAULT, + BR_PHONE_NEGATIVE_CACHE_TTL_SECONDS, + extractPhoneDigits, + generateBrazilMobileLookupCandidates, + getBrazilPhoneCacheKeys, + isBrazilCountryCode, + isBrazilMobile, + isMalformedBrazilPhone, + needsBrazilWhatsAppLookup, + normalizeBrazilMobileForSendDigits, + normalizeBrazilTollFreeDigits, + shouldSkipBrazilPhoneNormalization, +} from '@waha/core/utils/brPhone'; +import { toJID } from '@waha/core/utils/jids'; +import { UnprocessableEntityException } from '@nestjs/common'; // eslint-disable-next-line @typescript-eslint/no-var-requires const qrcode = require('qrcode-terminal'); @@ -220,6 +241,15 @@ export abstract class WhatsappSession { protected profilePictures: NodeCache = new NodeCache({ stdTTL: 24 * 60 * 60, // 1 day }); + // BR phone resolution cache. Stores the resolved canonical digits. Verified + // hits use the long TTL; unverified best-guesses use the short (negative) TTL + // so we re-check later without re-running the WhatsApp lookup every send. + protected brPhoneCache: NodeCache = new NodeCache({ + stdTTL: BR_PHONE_CACHE_TTL_SECONDS, + }); + // Single-flight guard: concurrent first-time resolutions of the same number + // share one in-flight WhatsApp lookup instead of each firing its own usync. + private brPhoneInflight: Map> = new Map(); // Save sent messages ids in cache so we can determine if a message was sent // via API or APP @@ -1271,6 +1301,273 @@ export abstract class WhatsappSession { return ensureSuffix(phone); } + // Cached values are full chat ids ('5511...@c.us' or '123@lid'), never bare + // digits: stripping the suffix loses which addressing form was resolved, and + // re-adding '@c.us' to LID digits builds an id that addresses nobody. + protected cacheBrazilPhoneResolution( + inputDigits: string, + resolvedChatId: string, + ) { + const keys = getBrazilPhoneCacheKeys(inputDigits); + for (const key of keys) { + this.brPhoneCache.set(key, resolvedChatId); + } + } + + // Cache an unverified best-guess (e.g. WhatsApp said it does not exist, but we + // send anyway). Short TTL so a number registered later is re-checked soon. + protected cacheBrazilPhoneUnverified( + inputDigits: string, + bestGuessChatId: string, + ) { + const keys = getBrazilPhoneCacheKeys(inputDigits); + for (const key of keys) { + this.brPhoneCache.set( + key, + bestGuessChatId, + BR_PHONE_NEGATIVE_CACHE_TTL_SECONDS, + ); + } + } + + // Cache a confirmed-negative (strict mode): the number does not exist on + // WhatsApp. Stored as '' with the short TTL so retries re-check after a while. + protected cacheBrazilPhoneNegative(inputDigits: string) { + const keys = getBrazilPhoneCacheKeys(inputDigits); + for (const key of keys) { + this.brPhoneCache.set(key, '', BR_PHONE_NEGATIVE_CACHE_TTL_SECONDS); + } + } + + // undefined = cache miss, '' = confirmed-negative (strict), otherwise the + // resolved chat id (verified canonical or unverified best-guess). + protected getCachedBrazilPhoneResolution(digits: string): string | undefined { + return this.brPhoneCache.get(digits); + } + + // Optional per-engine hook: resolve a candidate against the local contact + // store without hitting WhatsApp servers. Default: no local store available. + protected async lookupKnownChatId( + candidates: string[], + ): Promise { + void candidates; + return null; + } + + protected async resolveOutboundChatId( + chatId: string, + opts: { validate?: boolean } = {}, + ): Promise { + // validate=true (default): full resolution incl. WhatsApp lookup, used by + // message-send paths. validate=false: local-only (cache + static + store), + // never hits the network and never throws, used by read/presence ops. + const validate = opts.validate ?? true; + const withSuffix = this.ensureSuffix(chatId); + if (!BR_PHONE_NORMALIZE) { + return withSuffix; + } + if (shouldSkipBrazilPhoneNormalization(withSuffix)) { + return withSuffix; + } + + const digits = extractPhoneDigits(withSuffix); + // Brazilian toll-free (0800): deterministic rewrite to the stored form, no + // lookup. Handled before the country-code gate because the dialed form + // ('0800...') has no 55 prefix. + const tollFree = normalizeBrazilTollFreeDigits(digits); + if (tollFree) { + return ensureSuffix(tollFree); + } + // Only Brazilian numbers (country code 55) are handled here. + if (!isBrazilCountryCode(digits)) { + return withSuffix; + } + // Tier 0: malformed Brazilian numbers (e.g. 55859912). Hard error only on + // the send path; read/presence ops just pass it through untouched. + if (isMalformedBrazilPhone(digits)) { + if (validate) { + throw new UnprocessableEntityException( + `Invalid Brazilian phone number '${withSuffix}'.`, + ); + } + return withSuffix; + } + // Landlines and already-valid non-mobile numbers are left untouched. + if (!isBrazilMobile(digits)) { + return withSuffix; + } + + // Tier 1: in-memory cache. undefined = miss, '' = confirmed-negative + // (strict mode), otherwise the resolved/best-guess chat id, stored ready + // to use - no suffix is re-derived here. + const cached = this.getCachedBrazilPhoneResolution(digits); + if (cached !== undefined) { + if (cached === '') { + if (validate) { + throw new UnprocessableEntityException( + `Brazilian mobile phone number '${withSuffix}' does not exist on WhatsApp.`, + ); + } + return withSuffix; + } + return cached; + } + + // DDD below the lookup range: static 9th-digit rule, no network needed. + if ( + !needsBrazilWhatsAppLookup( + digits, + BR_PHONE_DDD_LOOKUP_MIN_DEFAULT, + BR_PHONE_DDD_LOOKUP_MAX_DEFAULT, + ) + ) { + const normalized = ensureSuffix( + normalizeBrazilMobileForSendDigits(digits), + ); + this.cacheBrazilPhoneResolution(digits, normalized); + return normalized; + } + + const candidates = generateBrazilMobileLookupCandidates(digits); + + // Tier 2: local contact/LID store (engine-specific), no network. + const fromStore = await this.lookupKnownChatId(candidates); + if (fromStore) { + this.cacheBrazilPhoneResolution(digits, fromStore); + this.logger.debug( + `BR mobile '${withSuffix}' resolved locally to '${fromStore}' (no WhatsApp lookup).`, + ); + return fromStore; + } + + // Read/presence ops never reach the network: return the best-guess as-is. + if (!validate) { + return withSuffix; + } + + // Tier 3: WhatsApp lookup as last resort, de-duplicated via single-flight. + return this.resolveBrazilPhoneViaWhatsApp(digits, withSuffix, candidates); + } + + // Single-flight wrapper around the WhatsApp existence lookup so concurrent + // sends to the same new number trigger a single usync, not one per message. + private resolveBrazilPhoneViaWhatsApp( + digits: string, + withSuffix: string, + candidates: string[], + ): Promise { + const key = getBrazilPhoneCacheKeys(digits).sort().join('|'); + const inflight = this.brPhoneInflight.get(key); + if (inflight) { + return inflight; + } + const promise = this.lookupBrazilPhoneOnWhatsApp( + digits, + withSuffix, + candidates, + ).finally(() => this.brPhoneInflight.delete(key)); + this.brPhoneInflight.set(key, promise); + return promise; + } + + private async lookupBrazilPhoneOnWhatsApp( + digits: string, + withSuffix: string, + candidates: string[], + ): Promise { + this.logger.debug( + `BR mobile '${withSuffix}' not found locally, performing WhatsApp lookup for: ${candidates.join(', ')}`, + ); + let lookupFailed = false; + for (const candidate of candidates) { + let result: WANumberExistResult; + try { + result = await this.checkNumberStatus({ + phone: candidate, + session: this.name, + }); + } catch (error) { + lookupFailed = true; + this.logger.warn( + `Failed to verify Brazilian mobile candidate '${candidate}': ${error}`, + ); + continue; + } + if (result?.numberExists && result.chatId) { + // Cache the chat id exactly as resolved. Engines answer with the phone + // number whenever they can and fall back to a LID for accounts that + // have no phone form - both are routable, and neither survives being + // reduced to digits. + this.cacheBrazilPhoneResolution(digits, result.chatId); + return result.chatId; + } + } + + // Could not validate due to network/engine error: send as-is, do not cache. + if (lookupFailed) { + this.logger.warn( + `Could not validate Brazilian mobile number '${withSuffix}', sending as-is. Tried: ${candidates.join(', ')}`, + ); + return withSuffix; + } + + // Verified not to exist in any form. + if (BR_PHONE_STRICT) { + // Strict (opt-in via WAHA_BR_PHONE_STRICT): reject so the caller knows. + this.cacheBrazilPhoneNegative(digits); + throw new UnprocessableEntityException( + `Brazilian mobile phone number '${withSuffix}' does not exist on WhatsApp. Tried: ${candidates.join(', ')}`, + ); + } + // Soft (default): warn and send the best-guess anyway, so a usync + // false-negative never blocks a valid send. + const bestGuess = ensureSuffix(normalizeBrazilMobileForSendDigits(digits)); + this.cacheBrazilPhoneUnverified(digits, bestGuess); + this.logger.warn( + `Brazilian mobile number '${withSuffix}' not found on WhatsApp, sending best-guess '${bestGuess}'. Tried: ${candidates.join(', ')}`, + ); + return bestGuess; + } + + // Mentions are best-effort: a non-existent mention must never break the send. + protected async resolveOutboundMentions( + mentions?: string[], + ): Promise { + if (!mentions?.length) { + return undefined; + } + const resolved: string[] = []; + for (const mention of mentions) { + const chatId = await this.resolveOutboundMention(mention); + resolved.push(toJID(chatId)); + } + return resolved; + } + + protected async resolveOutboundMentionsCus( + mentions?: string[], + ): Promise { + if (!mentions?.length) { + return undefined; + } + const resolved: string[] = []; + for (const mention of mentions) { + resolved.push(await this.resolveOutboundMention(mention)); + } + return resolved; + } + + private async resolveOutboundMention(mention: string): Promise { + try { + return await this.resolveOutboundChatId(mention); + } catch (error) { + this.logger.warn( + `Could not resolve mention '${mention}', using as-is: ${error}`, + ); + return this.ensureSuffix(mention); + } + } + protected deserializeId(messageId: string): MessageId { const parts = messageId.split('_'); return { diff --git a/src/core/engines/gows/session.gows.core.ts b/src/core/engines/gows/session.gows.core.ts index d27cc0376..6a4f6b85c 100644 --- a/src/core/engines/gows/session.gows.core.ts +++ b/src/core/engines/gows/session.gows.core.ts @@ -28,7 +28,6 @@ import { parseJsonList, statusToAck, } from '@waha/core/engines/gows/helpers'; -import { parseMessageCapping } from '@waha/core/abc/capping'; import { parseGowsReachoutTimelock } from '@waha/core/engines/gows/reachouttimelock'; import { GowsAuthFactoryCore } from '@waha/core/engines/gows/store/GowsAuthFactoryCore'; import { @@ -137,9 +136,7 @@ import { import { CallData } from '@waha/structures/calls.dto'; import { MeInfo, - MessageCappingData, ProxyConfig, - ReachoutTimelockData, SessionConfig, } from '@waha/structures/sessions.dto'; import { @@ -225,8 +222,6 @@ function getGowsStorageConfig( groups: storeConfig?.groups !== false, chats: storeConfig?.chats !== false, labels: storeConfig?.labels !== false, - contacts: storeConfig?.contacts !== false, - message_secrets: storeConfig?.messageSecrets !== false, }); } @@ -243,7 +238,6 @@ enum WhatsMeowEvent { PUSH_NAME_SETTING = 'events.PushNameSetting', LOGGED_OUT = 'events.LoggedOut', NOTIFY_ACCOUNT_REACHOUT_TIMELOCK = 'events.NotifyAccountReachoutTimelock', - MESSAGE_CAPPING = 'gows.MessageCapping', // Groups GROUP_INFO = 'events.GroupInfo', JOINED_GROUP = 'events.JoinedGroup', @@ -485,9 +479,6 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { events.on(WhatsMeowEvent.NOTIFY_ACCOUNT_REACHOUT_TIMELOCK, (data) => { this.reachoutTimelock.update(parseGowsReachoutTimelock(data)); }); - events.on(WhatsMeowEvent.MESSAGE_CAPPING, (data) => { - this.messageCapping.update(parseMessageCapping(data)); - }); events.on(WhatsMeowEvent.PRESENCE, (event: gows.Presence) => { if (isJidGroup(event.From)) { // So group is not "online" @@ -845,7 +836,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { @Activity() async fetchContactProfilePicture(id: string): Promise { - const jid = normalizeJid(toJID(this.ensureSuffix(id))); + const jid = normalizeJid(toJID(await this.resolveOutboundChatId(id, { validate: false }))); const request = new messages.ProfilePictureRequest({ jid: jid, session: this.session, @@ -936,11 +927,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { if (!this.me) { return null; } - return { - ...this.me, - reachoutTimelock: this.reachoutTimelock.value, - messageCapping: this.messageCapping.value, - }; + return { ...this.me, reachoutTimelock: this.reachoutTimelock.value }; } /** @@ -1060,7 +1047,8 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { @Activity() async sendText(request: MessageTextRequest) { - const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId))); + const jid = normalizeJid(toJID(await this.resolveOutboundChatId(request.chatId))); + const mentions = await this.resolveOutboundMentions(request.mentions); const message = new messages.MessageRequest({ id: request.id, jid: jid, @@ -1069,9 +1057,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { linkPreview: request.linkPreview ?? true, linkPreviewHighQuality: request.linkPreviewHighQuality, replyTo: getMessageIdFromSerialized(request.reply_to), - mentions: request.mentions?.map((mention) => - normalizeJid(toJID(mention)), - ), + mentions: mentions?.map((mention) => normalizeJid(mention)), }); const response = await promisify(this.client.SendMessage)(message); const data = response.toObject(); @@ -1084,7 +1070,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { messageId: string, request: EditMessageRequest, ) { - const jid = normalizeJid(toJID(this.ensureSuffix(chatId))); + const jid = normalizeJid(toJID(await this.resolveOutboundChatId(chatId, { validate: false }))); const key = parseMessageIdSerialized(messageId, true); const message = new messages.EditMessageRequest({ session: this.session, @@ -1101,7 +1087,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { @Activity() async sendContactVCard(request: MessageContactVcardRequest) { - const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId))); + const jid = normalizeJid(toJID(await this.resolveOutboundChatId(request.chatId))); const contacts = request.contacts.map((el) => ({ displayName: (el as any).fullName || parseVCardV3(el.vcard || '').fullName, @@ -1121,7 +1107,9 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { @Activity() async sendPoll(request: MessagePollRequest) { - const jid = normalizeJid(toJID(request.chatId)); + const jid = normalizeJid( + toJID(await this.resolveOutboundChatId(request.chatId)), + ); const message = new messages.MessageRequest({ id: request.id, jid: jid, @@ -1140,7 +1128,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { @Activity() async sendPollVote(request: MessagePollVoteRequest) { - const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId))); + const jid = normalizeJid(toJID(await this.resolveOutboundChatId(request.chatId, { validate: false }))); const key = parseMessageIdSerialized(request.pollMessageId, true); const pollVote = new messages.PollVoteMessage({ pollMessageId: key.id, @@ -1162,7 +1150,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { @Activity() async sendList(request: SendListRequest): Promise { - const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId))); + const jid = normalizeJid(toJID(await this.resolveOutboundChatId(request.chatId))); if (isJidGroup(jid) || isJidBroadcast(jid) || isJidNewsletter(jid)) { throw new UnprocessableEntityException( `List message can only be sent to a direct message chat.`, @@ -1189,7 +1177,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { @Activity() public async deleteMessage(chatId: string, messageId: string) { - const jid = normalizeJid(toJID(this.ensureSuffix(chatId))); + const jid = normalizeJid(toJID(await this.resolveOutboundChatId(chatId, { validate: false }))); const key = parseMessageIdSerialized(messageId); const message = new messages.RevokeMessageRequest({ session: this.session, @@ -1281,9 +1269,10 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { }; } + @Activity() async sendLocation(request: MessageLocationRequest) { - const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId))); + const jid = normalizeJid(toJID(await this.resolveOutboundChatId(request.chatId))); const message = new messages.MessageRequest({ id: request.id, jid: jid, @@ -1305,7 +1294,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { } private async sendMedia(type: messages.MediaType, request: any) { - const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId))); + const jid = normalizeJid(toJID(await this.resolveOutboundChatId(request.chatId))); const media = await this.fileToMedia(request.file); media.type = type; if (type === messages.MediaType.IMAGE) { @@ -1364,9 +1353,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { session: this.session, media: media, backgroundColor: backgroundColor, - mentions: request.mentions?.map((mention) => - normalizeJid(toJID(mention)), - ), + mentions: await this.resolveOutboundMentions(request.mentions), participants: participants, }); @@ -1442,7 +1429,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { async sendLinkCustomPreview( request: MessageLinkCustomPreviewRequest, ): Promise { - const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId))); + const jid = normalizeJid(toJID(await this.resolveOutboundChatId(request.chatId))); const media = await this.fileToMedia(request.preview.image as RemoteFile); const preview = new messages.LinkPreview({ url: request.preview.url, @@ -1469,7 +1456,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { throw new NotImplementedByEngineError(); // Doesn't work yet - const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId))); + const jid = normalizeJid(toJID(await this.resolveOutboundChatId(request.chatId))); const message = new messages.ButtonReplyRequest({ jid: jid, session: this.session, @@ -1828,7 +1815,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { @Activity() async sendEvent(request: EventMessageRequest): Promise { - const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId))); + const jid = normalizeJid(toJID(await this.resolveOutboundChatId(request.chatId))); const event = request.event; // Create EventLocation if provided @@ -1884,7 +1871,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { public async setPresence(presence: WAHAPresenceStatus, chatId?: string) { let request: any; let method: any; - const jid = chatId ? normalizeJid(toJID(this.ensureSuffix(chatId))) : null; + const jid = chatId ? normalizeJid(toJID(await this.resolveOutboundChatId(chatId, { validate: false }))) : null; switch (presence) { case WAHAPresenceStatus.ONLINE: request = new messages.PresenceRequest({ @@ -2289,28 +2276,6 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { return this.toWAContact(data); } - @Activity() - public async fetchMessageCapping(): Promise { - const response = await promisify(this.client.FetchMessageCapping)( - this.session, - ); - const capping = parseMessageCapping(parseJson(response)); - // Keep the tracker in sync so MeInfo and 'session.status' reflect the fetch - this.messageCapping.update(capping); - return capping; - } - - @Activity() - public async fetchReachoutTimelock(): Promise { - const response = await promisify(this.client.FetchReachoutTimelock)( - this.session, - ); - const timelock = parseGowsReachoutTimelock(parseJson(response)); - // Keep the tracker in sync so MeInfo and 'session.status' reflect the fetch - this.reachoutTimelock.update(timelock); - return timelock; - } - public async getContacts(pagination: PaginationParams) { const request = new messages.GetContactsRequest({ session: this.session, @@ -2387,6 +2352,33 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { }; } + // gows-specific Tier 2 for Brazilian 9th-digit resolution. + // + // Resolve a candidate against the local PN<->LID map (whatsmeow's + // `whatsmeow_lid_map`), which is populated from contact sync and received + // messages and persisted in the gows store. A PN that has a LID mapping is a + // number this session already knows in its canonical form, so we can pick the + // correct 9th-digit variant with ZERO network calls (no IsOnWhatsApp/usync). + // Only genuinely cold numbers (never seen) fall through to the Tier 3 lookup. + protected async lookupKnownChatId( + candidates: string[], + ): Promise { + for (const candidate of candidates) { + try { + const { lid, pn } = await this.findLIDByPhoneNumber(candidate); + // A non-empty LID user part means this exact PN is known locally. + if (lid && lid.split('@')[0]) { + return pn; + } + } catch (error) { + this.logger.debug( + `LID map lookup failed for candidate '${candidate}': ${error}`, + ); + } + } + return null; + } + /** * Chats methods */ @@ -2490,7 +2482,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { jid = null; } else { jid = new messages.OptionalString({ - value: normalizeJid(toJID(this.ensureSuffix(chatId))), + value: normalizeJid(toJID(await this.resolveOutboundChatId(chatId, { validate: false }))), }); } @@ -2651,7 +2643,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { @Activity() public async chatsUnreadChat(chatId: string): Promise { - const jid = normalizeJid(toJID(this.ensureSuffix(chatId))); + const jid = normalizeJid(toJID(await this.resolveOutboundChatId(chatId, { validate: false }))); const request = new messages.ChatUnreadRequest({ session: this.session, jid: jid, diff --git a/src/core/engines/noweb/session.noweb.core.ts b/src/core/engines/noweb/session.noweb.core.ts index 1ebe41bdb..1ddc6a021 100644 --- a/src/core/engines/noweb/session.noweb.core.ts +++ b/src/core/engines/noweb/session.noweb.core.ts @@ -1101,10 +1101,10 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { @Activity() async sendText(request: MessageTextRequest) { - const chatId = toJID(this.ensureSuffix(request.chatId)); + const chatId = toJID(await this.resolveOutboundChatId(request.chatId)); const message = { text: request.text, - mentions: request.mentions?.map(toJID), + mentions: await this.resolveOutboundMentions(request.mentions), linkPreview: this.getLinkPreview(request), }; const options: any = await this.getMessageOptions(request); @@ -1113,8 +1113,8 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { } @Activity() - public deleteMessage(chatId: string, messageId: string) { - const jid = toJID(this.ensureSuffix(chatId)); + public async deleteMessage(chatId: string, messageId: string) { + const jid = toJID(await this.resolveOutboundChatId(chatId, { validate: false })); const key = parseMessageIdSerialized(messageId); const options = { messageId: this.generateMessageID(), @@ -1128,7 +1128,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { messageId: string, request: EditMessageRequest, ) { - const jid = toJID(this.ensureSuffix(chatId)); + const jid = toJID(await this.resolveOutboundChatId(chatId, { validate: false })); const key = parseMessageIdSerialized(messageId); const stored = await this.store ?.loadMessage(key.remoteJid, key.id) @@ -1166,7 +1166,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { } let message: any = { text: request.text, - mentions: request.mentions?.map(toJID), + mentions: await this.resolveOutboundMentions(request.mentions), edit: key, editedMessage: editedMessage, linkPreview: this.getLinkPreview(request), @@ -1184,7 +1184,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { @Activity() async sendContactVCard(request: MessageContactVcardRequest) { - const chatId = toJID(this.ensureSuffix(request.chatId)); + const chatId = toJID(await this.resolveOutboundChatId(request.chatId)); const contacts = request.contacts.map((el) => ({ vcard: toVcardV3(el) })); const options = await this.getMessageOptions(request); const msg = { contacts: { contacts: contacts } }; @@ -1202,7 +1202,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { : 1, }; const message = { poll: poll }; - const remoteJid = toJID(request.chatId); + const remoteJid = toJID(await this.resolveOutboundChatId(request.chatId)); const options = await this.getMessageOptions(request); const result = await this.sock.sendMessage(remoteJid, message, options); return this.toWAMessage(result); @@ -1211,11 +1211,12 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { @Activity() async reply(request: MessageReplyRequest) { const options = await this.getMessageOptions(request); + const chatId = toJID(await this.resolveOutboundChatId(request.chatId)); const message = { text: request.text, - mentions: request.mentions?.map(toJID), + mentions: await this.resolveOutboundMentions(request.mentions), }; - return await this.sock.sendMessage(request.chatId, message, options); + return await this.sock.sendMessage(chatId, message, options); } @Activity() @@ -1226,7 +1227,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { request.caption, ); message.mimetype = message.mimetype || WAMimeType.IMAGE; - const chatId = toJID(this.ensureSuffix(request.chatId)); + const chatId = toJID(await this.resolveOutboundChatId(request.chatId)); // Baileys' newsletter media path skips thumbnail and dimension computation. // Pre-compute them so iOS renders the image with the correct aspect ratio. if (isJidNewsletter(chatId)) { @@ -1243,7 +1244,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { } } if (request.mentions?.length) { - message.mentions = request.mentions.map((mention) => toJID(mention)); + message.mentions = await this.resolveOutboundMentions(request.mentions); } const options = await this.getMessageOptions(request); return this.sock.sendMessage(chatId, message, options); @@ -1260,9 +1261,9 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { message.mimetype = await detectMimetype(message['document']); } if (request.mentions?.length) { - message.mentions = request.mentions.map((mention) => toJID(mention)); + message.mentions = await this.resolveOutboundMentions(request.mentions); } - const chatId = toJID(this.ensureSuffix(request.chatId)); + const chatId = toJID(await this.resolveOutboundChatId(request.chatId)); const options = await this.getMessageOptions(request); return this.sock.sendMessage(chatId, message, options); } @@ -1275,7 +1276,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { message['audio'] = await this.mediaConverter.voice(message['audio']); message.mimetype = WAMimeType.VOICE; } - const chatId = toJID(this.ensureSuffix(request.chatId)); + const chatId = toJID(await this.resolveOutboundChatId(request.chatId)); const options = await this.getMessageOptions(request); return this.sock.sendMessage(chatId, message, options); } @@ -1293,7 +1294,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { message.mimetype = WAMimeType.VIDEO; } if (request.mentions?.length) { - message.mentions = request.mentions.map((mention) => toJID(mention)); + message.mentions = await this.resolveOutboundMentions(request.mentions); } const duration = await esm.b .getAudioDuration(message['video']) @@ -1307,7 +1308,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { message.gifPlayback = true; message.externalShareFullVideoDurationInSeconds = 0; } - const chatId = toJID(this.ensureSuffix(request.chatId)); + const chatId = toJID(await this.resolveOutboundChatId(request.chatId)); const options = await this.getMessageOptions(request); message.ptv = parseBool(request.asNote); return this.sock.sendMessage(chatId, message, options); @@ -1317,7 +1318,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { async sendLinkCustomPreview( request: MessageLinkCustomPreviewRequest, ): Promise { - const chatId = toJID(this.ensureSuffix(request.chatId)); + const chatId = toJID(await this.resolveOutboundChatId(request.chatId)); const options = await this.getMessageOptions(request); const preview = request.preview; const urlInfo = { @@ -1433,7 +1434,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { @Activity() async sendButtons(request: SendButtonsRequest) { - const chatId = toJID(this.ensureSuffix(request.chatId)); + const chatId = toJID(await this.resolveOutboundChatId(request.chatId)); const headerImage = await this.uploadMedia(request.headerImage, 'image'); return await sendButtonMessage( this.sock, @@ -1448,7 +1449,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { @Activity() async sendList(request: SendListRequest): Promise { - const jid = toJID(this.ensureSuffix(request.chatId)); + const jid = toJID(await this.resolveOutboundChatId(request.chatId)); if (!isLidUser(jid) && !isPnUser(jid)) { throw new UnprocessableEntityException( `List message can only be sent to a direct message chat.`, @@ -1468,7 +1469,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { @Activity() async sendLocation(request: MessageLocationRequest) { - const chatId = toJID(this.ensureSuffix(request.chatId)); + const chatId = toJID(await this.resolveOutboundChatId(request.chatId)); const msg = { location: { name: request.title || null, @@ -1489,7 +1490,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { `Message with id '${request.messageId}' not found`, ); } - const chatId = toJID(this.ensureSuffix(request.chatId)); + const chatId = toJID(await this.resolveOutboundChatId(request.chatId)); const message = { forward: forwardMessage, force: true, @@ -1502,7 +1503,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { @Activity() async sendLinkPreview(request: MessageLinkPreviewRequest) { const text = `${request.title}\n${request.url}`; - const chatId = toJID(this.ensureSuffix(request.chatId)); + const chatId = toJID(await this.resolveOutboundChatId(request.chatId)); const msg = { text: text }; const options = await this.getMessageOptions(request); return this.sock.sendMessage(chatId, msg, options); @@ -1528,13 +1529,13 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { @Activity() async startTyping(request: ChatRequest): Promise { - const chatId = toJID(this.ensureSuffix(request.chatId)); + const chatId = toJID(await this.resolveOutboundChatId(request.chatId, { validate: false })); await this.sock.sendPresenceUpdate('composing', chatId); } @Activity() async stopTyping(request: ChatRequest) { - const chatId = toJID(this.ensureSuffix(request.chatId)); + const chatId = toJID(await this.resolveOutboundChatId(request.chatId, { validate: false })); return this.sock.sendPresenceUpdate('paused', chatId); } @@ -1903,6 +1904,22 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { this.sock.ev.emit('contacts.update', updates); } + protected async lookupKnownChatId( + candidates: string[], + ): Promise { + if (!this.store) { + return null; + } + for (const candidate of candidates) { + const jid = toJID(candidate); + const contact = await this.store.getContactById(jid).catch(() => null); + if (contact?.id) { + return toCusFormat(contact.id); + } + } + return null; + } + async getContact(query: ContactQuery) { const jid = toJID(query.contactId); const contact = await this.store.getContactById(jid); @@ -2155,7 +2172,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { ); } if (chatId) { - chatId = toJID(this.ensureSuffix(chatId)); + chatId = toJID(await this.resolveOutboundChatId(chatId, { validate: false })); } await this.sock.sendPresenceUpdate(enginePresence, chatId); this.presence = presence; diff --git a/src/core/engines/webjs/session.webjs.core.ts b/src/core/engines/webjs/session.webjs.core.ts index 1715015a5..621d10605 100644 --- a/src/core/engines/webjs/session.webjs.core.ts +++ b/src/core/engines/webjs/session.webjs.core.ts @@ -980,10 +980,10 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { } @Activity() - sendText(request: MessageTextRequest) { + async sendText(request: MessageTextRequest) { const options = this.getMessageOptions(request); return this.whatsapp.sendMessage( - this.ensureSuffix(request.chatId), + await this.resolveOutboundChatId(request.chatId), request.text, options, ); @@ -1012,7 +1012,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { @Activity() async sendContactVCard(request: MessageContactVcardRequest) { - const chatId = this.ensureSuffix(request.chatId); + const chatId = await this.resolveOutboundChatId(request.chatId); const vcards = request.contacts.map((el) => toVcardV3(el as any)); const options = this.getMessageOptions(request); @@ -1037,7 +1037,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { async reply(request: MessageReplyRequest) { const options = this.getMessageOptions(request); return this.whatsapp.sendMessage( - this.ensureSuffix(request.chatId), + await this.resolveOutboundChatId(request.chatId), request.text, options, ); @@ -1051,7 +1051,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { }); const options = this.getMessageOptions(request); return this.whatsapp.sendMessage( - this.ensureSuffix(request.chatId), + await this.resolveOutboundChatId(request.chatId), poll, options, ); @@ -1070,7 +1070,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { caption: request.caption, }; return this.whatsapp.sendMessage( - this.ensureSuffix(request.chatId), + await this.resolveOutboundChatId(request.chatId), media, options, ); @@ -1086,7 +1086,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { caption: request.caption, }; return this.whatsapp.sendMessage( - this.ensureSuffix(request.chatId), + await this.resolveOutboundChatId(request.chatId), media, options, ); @@ -1105,7 +1105,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { sendAudioAsVoice: true, }; return this.whatsapp.sendMessage( - this.ensureSuffix(request.chatId), + await this.resolveOutboundChatId(request.chatId), media, options, ); @@ -1126,7 +1126,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { caption: request.caption, }; return this.whatsapp.sendMessage( - this.ensureSuffix(request.chatId), + await this.resolveOutboundChatId(request.chatId), media, options, ); @@ -1180,7 +1180,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { }; options.extra = extra; return this.whatsapp.sendMessage( - this.ensureSuffix(request.chatId), + await this.resolveOutboundChatId(request.chatId), request.selectedDisplayText, options, ); @@ -1201,7 +1201,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { }); const options = this.getMessageOptions(request); return this.whatsapp.sendMessage( - this.ensureSuffix(request.chatId), + await this.resolveOutboundChatId(request.chatId), location, options, ); @@ -1210,7 +1210,9 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { @Activity() async forwardMessage(request: MessageForwardRequest): Promise { const forwardMessage = this.recreateMessage(request.messageId); - const msg = await forwardMessage.forward(this.ensureSuffix(request.chatId)); + const msg = await forwardMessage.forward( + await this.resolveOutboundChatId(request.chatId), + ); // Return "sent: true" for now // need to research how to get the data from WebJS // @ts-ignore @@ -1220,7 +1222,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { @Activity() async sendSeen(request: SendSeenRequest) { const chat: Chat = await this.whatsapp.getChatById( - this.ensureSuffix(request.chatId), + await this.resolveOutboundChatId(request.chatId, { validate: false }), ); await chat.sendSeen(); } @@ -1228,7 +1230,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { @Activity() async startTyping(request: ChatRequest): Promise { const chat: Chat = await this.whatsapp.getChatById( - this.ensureSuffix(request.chatId), + await this.resolveOutboundChatId(request.chatId, { validate: false }), ); await chat.sendStateTyping(); } @@ -1236,7 +1238,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { @Activity() async stopTyping(request: ChatRequest) { const chat: Chat = await this.whatsapp.getChatById( - this.ensureSuffix(request.chatId), + await this.resolveOutboundChatId(request.chatId, { validate: false }), ); await chat.clearState(); } diff --git a/src/core/engines/wpp/session.wpp.core.ts b/src/core/engines/wpp/session.wpp.core.ts index 1dab6ce39..c2af57ff9 100644 --- a/src/core/engines/wpp/session.wpp.core.ts +++ b/src/core/engines/wpp/session.wpp.core.ts @@ -556,7 +556,7 @@ export class WhatsappSessionWPPCore extends WhatsappSession { options.quotedMsg = quotedMessageId; } const sent = await this.wpp!.sendLocation( - this.ensureSuffix(request.chatId), + await this.resolveOutboundChatId(request.chatId), options, ); return this.toWAMessage(sent); @@ -567,7 +567,7 @@ export class WhatsappSessionWPPCore extends WhatsappSession { request: MessageForwardRequest, ): Promise { const sentMessages = await this.wpp!.forwardMessagesV2( - this.ensureSuffix(request.chatId), + await this.resolveOutboundChatId(request.chatId), request.messageId, ); const sent = Array.isArray(sentMessages) && sentMessages.length > 0; @@ -588,7 +588,7 @@ export class WhatsappSessionWPPCore extends WhatsappSession { options.quotedMsg = quotedMessageId; } const sent = await this.wpp!.sendPollMessage( - this.ensureSuffix(request.chatId), + await this.resolveOutboundChatId(request.chatId), request.poll.name, request.poll.options, options, @@ -604,7 +604,7 @@ export class WhatsappSessionWPPCore extends WhatsappSession { public async sendContactVCard( request: MessageContactVcardRequest, ): Promise { - const chatId = this.ensureSuffix(request.chatId); + const chatId = await this.resolveOutboundChatId(request.chatId); const contacts: Array<{ id: string; name: string }> = []; // Raw vcard @@ -653,17 +653,20 @@ export class WhatsappSessionWPPCore extends WhatsappSession { const content = await this.fileToBuffer(request.file); const mimetype = request.file.mimetype || WAMimeType.IMAGE; const media = WPPMedia(content, mimetype); + const mentionedList = await this.resolveOutboundMentionsCus( + request.mentions, + ); const options: ImageMessageOptions = { type: 'image', caption: request.caption, filename: request.file.filename, mimetype: mimetype, quotedMsg: quotedMessageId, - mentionedList: request.mentions?.map((id) => this.ensureSuffix(id)), + mentionedList: mentionedList, waitForAck: false, }; return await this.sendMedia( - this.ensureSuffix(request.chatId), + await this.resolveOutboundChatId(request.chatId), media, options, ); @@ -675,17 +678,20 @@ export class WhatsappSessionWPPCore extends WhatsappSession { const content = await this.fileToBuffer(request.file); const mimetype = request.file.mimetype || (await detectMimetype(content)); const media = WPPMedia(content, mimetype); + const mentionedList = await this.resolveOutboundMentionsCus( + request.mentions, + ); const options: DocumentMessageOptions = { type: 'document', caption: request.caption, filename: request.file.filename, mimetype: mimetype, quotedMsg: quotedMessageId, - mentionedList: request.mentions?.map((id) => this.ensureSuffix(id)), + mentionedList: mentionedList, waitForAck: false, }; return await this.sendMedia( - this.ensureSuffix(request.chatId), + await this.resolveOutboundChatId(request.chatId), media, options, ); @@ -709,7 +715,7 @@ export class WhatsappSessionWPPCore extends WhatsappSession { waitForAck: false, }; return await this.sendMedia( - this.ensureSuffix(request.chatId), + await this.resolveOutboundChatId(request.chatId), media, options, ); @@ -725,6 +731,9 @@ export class WhatsappSessionWPPCore extends WhatsappSession { mimetype = WAMimeType.VIDEO; } const media = WPPMedia(content, mimetype); + const mentionedList = await this.resolveOutboundMentionsCus( + request.mentions, + ); const options: VideoMessageOptions = { type: 'video', isPtv: request.asNote, @@ -732,11 +741,11 @@ export class WhatsappSessionWPPCore extends WhatsappSession { filename: request.file.filename, mimetype: mimetype, quotedMsg: quotedMessageId, - mentionedList: request.mentions?.map((id) => this.ensureSuffix(id)), + mentionedList: mentionedList, waitForAck: false, }; return await this.sendMedia( - this.ensureSuffix(request.chatId), + await this.resolveOutboundChatId(request.chatId), media, options, ); @@ -864,13 +873,16 @@ export class WhatsappSessionWPPCore extends WhatsappSession { @Activity() public async reply(request: MessageReplyRequest) { const quotedMessageId = this.getReplyToMessageId(request as any); + const mentionedList = await this.resolveOutboundMentionsCus( + request.mentions, + ); const options: WppSendTextOptions = { - mentionedList: request.mentions?.map((id) => this.ensureSuffix(id)), + mentionedList: mentionedList, quotedMsg: quotedMessageId, waitForAck: false, }; const sent = await this.wpp!.sendText( - this.ensureSuffix(request.chatId), + await this.resolveOutboundChatId(request.chatId), request.text, options, ); @@ -879,12 +891,14 @@ export class WhatsappSessionWPPCore extends WhatsappSession { @Activity() public async startTyping(request: ChatRequest): Promise { - await this.wpp!.startTyping(this.ensureSuffix(request.chatId)); + await this.wpp!.startTyping( + await this.resolveOutboundChatId(request.chatId, { validate: false }), + ); } @Activity() public async stopTyping(request: ChatRequest) { - const chatId = this.ensureSuffix(request.chatId); + const chatId = await this.resolveOutboundChatId(request.chatId, { validate: false }); await Promise.all([ this.wpp!.stopTyping(chatId), this.wpp!.stopRecording(chatId), @@ -905,13 +919,16 @@ export class WhatsappSessionWPPCore extends WhatsappSession { @Activity() async sendText(request: MessageTextRequest) { const quotedMessageId = this.getReplyToMessageId(request as any); + const mentionedList = await this.resolveOutboundMentionsCus( + request.mentions, + ); const options: WppSendTextOptions = { - mentionedList: request.mentions?.map((id) => this.ensureSuffix(id)), + mentionedList: mentionedList, quotedMsg: quotedMessageId, waitForAck: false, }; const sent = await this.wpp!.sendText( - this.ensureSuffix(request.chatId), + await this.resolveOutboundChatId(request.chatId), request.text, options, ); @@ -924,7 +941,9 @@ export class WhatsappSessionWPPCore extends WhatsappSession { @Activity() async sendSeen(request: SendSeenRequest) { - await this.wpp!.sendSeen(this.ensureSuffix(request.chatId)); + await this.wpp!.sendSeen( + await this.resolveOutboundChatId(request.chatId, { validate: false }), + ); } @Activity() @@ -938,19 +957,19 @@ export class WhatsappSessionWPPCore extends WhatsappSession { break; case WAHAPresenceStatus.TYPING: { await this.maintainPresenceOnline(); - const normalizedChatId = this.ensureSuffix(chatId); + const normalizedChatId = await this.resolveOutboundChatId(chatId, { validate: false }); await this.wpp!.startTyping(normalizedChatId); break; } case WAHAPresenceStatus.RECORDING: { await this.maintainPresenceOnline(); - const normalizedChatId = this.ensureSuffix(chatId); + const normalizedChatId = await this.resolveOutboundChatId(chatId, { validate: false }); await this.wpp!.startRecording(normalizedChatId); break; } case WAHAPresenceStatus.PAUSED: { await this.maintainPresenceOnline(); - const normalizedChatId = this.ensureSuffix(chatId); + const normalizedChatId = await this.resolveOutboundChatId(chatId, { validate: false }); await Promise.all([ this.wpp!.stopTyping(normalizedChatId), this.wpp!.stopRecording(normalizedChatId), diff --git a/src/core/env.ts b/src/core/env.ts index 2380ea9a7..b05e9b296 100644 --- a/src/core/env.ts +++ b/src/core/env.ts @@ -30,3 +30,24 @@ export const WAHA_CLIENT_DEVICE_NAME = process.env.WAHA_CLIENT_DEVICE_NAME || null; export const WAHA_CLIENT_BROWSER_NAME = process.env.WAHA_CLIENT_BROWSER_NAME || null; + +// +// Brazil phone normalization (send lookup) +// +// Single switch. When enabled, the engine validates and resolves Brazilian +// mobile numbers on send (9th-digit ambiguity) using a fixed strategy: +// syntax check -> in-memory cache -> local contact store -> WhatsApp lookup. +// Everything else (DDD range, cache TTLs) is fixed in code; only the behavior +// for a confirmed-nonexistent number is tunable via WAHA_BR_PHONE_STRICT. +export const BR_PHONE_NORMALIZE = process.env.WAHA_BR_PHONE_NORMALIZE + ? parseBool(process.env.WAHA_BR_PHONE_NORMALIZE) + : false; + +// When a Brazilian mobile is confirmed NOT to exist on WhatsApp: +// false (default) = soft (warn and send the best-guess anyway); +// true = strict (reject the send with 422). Strict trades delivery for +// certainty and can block valid sends on usync false-negatives (throttling), +// so it is opt-in. +export const BR_PHONE_STRICT = process.env.WAHA_BR_PHONE_STRICT + ? parseBool(process.env.WAHA_BR_PHONE_STRICT) + : false; diff --git a/src/core/utils/brPhone.test.ts b/src/core/utils/brPhone.test.ts new file mode 100644 index 000000000..594d3dc1a --- /dev/null +++ b/src/core/utils/brPhone.test.ts @@ -0,0 +1,120 @@ +import { + extractPhoneDigits, + generateBrazilMobileLookupCandidates, + isBrazilCountryCode, + isBrazilLandline, + isBrazilMobile, + isMalformedBrazilPhone, + needsBrazilWhatsAppLookup, + normalizeBrazilMobileForSendDigits, + normalizeBrazilTollFreeDigits, + shouldSkipBrazilPhoneNormalization, +} from './brPhone'; + +describe('brPhone', () => { + it('skips groups and lids', () => { + expect(shouldSkipBrazilPhoneNormalization('123@g.us')).toBe(true); + expect(shouldSkipBrazilPhoneNormalization('123@lid')).toBe(true); + }); + + it('detects BR country code', () => { + expect(isBrazilCountryCode('558591203123')).toBe(true); + expect(isBrazilCountryCode('5491123456789')).toBe(false); + }); + + it('flags malformed BR numbers and accepts valid lengths', () => { + expect(isMalformedBrazilPhone('55859912')).toBe(true); + expect(isMalformedBrazilPhone('558591203123')).toBe(false); + expect(isMalformedBrazilPhone('5585991203123')).toBe(false); + // not a BR number, not our concern + expect(isMalformedBrazilPhone('123')).toBe(false); + }); + + it('detects BR landline numbers', () => { + expect(isBrazilLandline('558540423147')).toBe(true); + expect(isBrazilMobile('558540423147')).toBe(false); + }); + + it('detects BR mobile numbers', () => { + expect(isBrazilMobile('558591203123')).toBe(true); + expect(isBrazilMobile('5585991203123')).toBe(true); + }); + + it('accepts any digit after the leading 9 on a 9-digit local', () => { + // Brazil has no 9-digit landline, so '9' + 8 digits is always mobile. + // The old rule required 6-9 right after the 9 and rejected real SP lines. + expect(isBrazilMobile('5511953523741')).toBe(true); + expect(isBrazilLandline('5511953523741')).toBe(false); + expect(isBrazilMobile('5511912345678')).toBe(true); + expect(isBrazilMobile('5511902345678')).toBe(true); + }); + + it('keeps 9-digit mobiles out of the lookup range untouched', () => { + // DDD 11 is below the lookup range: no candidates, no WhatsApp lookup. + expect(needsBrazilWhatsAppLookup('5511953523741', 31, 99)).toBe(false); + expect(normalizeBrazilMobileForSendDigits('5511953523741')).toBe( + '5511953523741', + ); + }); + + it('detects landlines in both DDD ranges', () => { + expect(isBrazilLandline('551151923057')).toBe(true); + expect(isBrazilLandline('558540428310')).toBe(true); + expect(isBrazilMobile('551151923057')).toBe(false); + expect(isBrazilMobile('558540428310')).toBe(false); + }); + + it('rewrites toll-free (0800) numbers to the stored form', () => { + // Dialed '0800' + 7 digits -> stored '55800' + 7 digits (leading 0 dropped, + // country code added). Both the bare and the 55-prefixed dialed forms map + // to the same stored number the server resolves them to. + expect(normalizeBrazilTollFreeDigits('08000464636')).toBe('558000464636'); + expect(normalizeBrazilTollFreeDigits('5508000464636')).toBe('558000464636'); + }); + + it('leaves non-toll-free and already-stored numbers untouched', () => { + // Already-stored form routes through normally, so no rewrite here. + expect(normalizeBrazilTollFreeDigits('558000464636')).toBeNull(); + expect(normalizeBrazilTollFreeDigits('5585991203123')).toBeNull(); + expect(normalizeBrazilTollFreeDigits('551151923057')).toBeNull(); + // 0300/0500/0900 share the shape but are not handled here. + expect(normalizeBrazilTollFreeDigits('03001234567')).toBeNull(); + }); + + it('requires lookup only for DDD 31-99 mobile numbers', () => { + expect(needsBrazilWhatsAppLookup('5511987654321', 31, 99)).toBe(false); + expect(needsBrazilWhatsAppLookup('558591203123', 31, 99)).toBe(true); + expect(needsBrazilWhatsAppLookup('558540423147', 31, 99)).toBe(false); + }); + + it('keeps send heuristic without 9 for DDD 31-99 until lookup resolves', () => { + expect(normalizeBrazilMobileForSendDigits('558591203123')).toBe( + '558591203123', + ); + expect(normalizeBrazilMobileForSendDigits('555399034520')).toBe( + '555399034520', + ); + }); + + it('normalizes low DDD mobile numbers for send with 9 digits', () => { + expect(normalizeBrazilMobileForSendDigits('551198765432')).toBe( + '5511998765432', + ); + }); + + it('generates with and without 9 candidates', () => { + expect(generateBrazilMobileLookupCandidates('558591203123')).toEqual([ + '558591203123', + '5585991203123', + ]); + expect(generateBrazilMobileLookupCandidates('5585991203123')).toEqual([ + '558591203123', + '5585991203123', + ]); + }); + + it('extracts digits from chat ids', () => { + expect(extractPhoneDigits('558591203123@c.us')).toBe('558591203123'); + expect(extractPhoneDigits('+558591203123')).toBe('558591203123'); + }); +}); diff --git a/src/core/utils/brPhone.ts b/src/core/utils/brPhone.ts new file mode 100644 index 000000000..f7359e1d6 --- /dev/null +++ b/src/core/utils/brPhone.ts @@ -0,0 +1,180 @@ +import { + isJidBroadcast, + isJidGroup, + isJidMetaAI, + isJidNewsletter, + isLidUser, +} from '@waha/core/utils/jids'; + +const COUNTRY_CODE = '55'; +const LANDLINE_FIRST_DIGIT = /^[2-5]/; +const MOBILE_FIRST_DIGIT = /^[6-9]/; + +export const BR_PHONE_DDD_LOOKUP_MIN_DEFAULT = 31; +export const BR_PHONE_DDD_LOOKUP_MAX_DEFAULT = 99; + +// Fixed cache tuning (not configurable): resolved numbers rarely change. +export const BR_PHONE_CACHE_TTL_SECONDS = 24 * 60 * 60; // 24h positive entries +export const BR_PHONE_NEGATIVE_CACHE_TTL_SECONDS = 10 * 60; // 10min negative entries + +// A Brazil number (country code 55) must be 55 + DDD(2) + local(8 or 9) digits. +const BR_PHONE_MIN_LENGTH = 12; +const BR_PHONE_MAX_LENGTH = 13; + +export function isBrazilCountryCode(digits: string): boolean { + return digits.startsWith(COUNTRY_CODE); +} + +export function isMalformedBrazilPhone(digits: string): boolean { + if (!isBrazilCountryCode(digits)) { + return false; + } + return digits.length < BR_PHONE_MIN_LENGTH || digits.length > BR_PHONE_MAX_LENGTH; +} + +export function extractPhoneDigits(value: string): string { + if (!value) { + return ''; + } + const local = value.split('@')[0] ?? value; + return local.split(':')[0].replace(/\D/g, ''); +} + +export function shouldSkipBrazilPhoneNormalization(chatId: string): boolean { + if (!chatId) { + return true; + } + if (isJidGroup(chatId)) { + return true; + } + if (isJidBroadcast(chatId)) { + return true; + } + if (isLidUser(chatId)) { + return true; + } + if (isJidNewsletter(chatId)) { + return true; + } + if (isJidMetaAI(chatId)) { + return true; + } + if (chatId === 'me') { + return true; + } + return false; +} + +export function isBrazilPhone(digits: string): boolean { + return digits.startsWith(COUNTRY_CODE) && digits.length >= 12; +} + +export function getBrazilDdd(digits: string): number { + return parseInt(digits.substring(2, 4), 10); +} + +export function getBrazilLocalPart(digits: string): string { + return digits.substring(4); +} + +export function isBrazilLandline(digits: string): boolean { + if (!isBrazilPhone(digits)) { + return false; + } + const local = getBrazilLocalPart(digits); + if (local.length !== 8) { + return false; + } + return LANDLINE_FIRST_DIGIT.test(local); +} + +export function isBrazilMobile(digits: string): boolean { + if (!isBrazilPhone(digits) || isBrazilLandline(digits)) { + return false; + } + const local = getBrazilLocalPart(digits); + // 8-digit local: the legacy form, missing its 9. Only 6-9 tells it apart + // from a landline - 2-5 is genuinely ambiguous and treated as a landline. + if (local.length === 8) { + return MOBILE_FIRST_DIGIT.test(local); + } + // 9-digit local starting with 9: unambiguously mobile. Brazil has no + // 9-digit landline, and the digit after the leading 9 is unrestricted - + // e.g. SP 11 9535-23741. Do not test it. + if (local.length === 9 && local[0] === '9') { + return true; + } + return false; +} + +// Brazilian toll-free (0800) numbers are non-geographic: dialed as '0800' plus +// 7 subscriber digits. WhatsApp stores them under country code 55 with the +// leading 0 dropped ('08000464636' -> '558000464636'). Callers send the dialed +// form, so map it to the stored one. The rewrite is deterministic - no WhatsApp +// lookup, unlike the 9th-digit mobile case. Returns null when not a toll-free +// number in a dialed form (the stored form '55800...' already routes untouched). +const BR_TOLLFREE_DIALED = /^0800\d{7}$/; +const BR_TOLLFREE_DIALED_CC = /^550800\d{7}$/; + +export function normalizeBrazilTollFreeDigits(digits: string): string | null { + if (BR_TOLLFREE_DIALED.test(digits)) { + return `${COUNTRY_CODE}${digits.slice(1)}`; + } + if (BR_TOLLFREE_DIALED_CC.test(digits)) { + return `${COUNTRY_CODE}${digits.slice(3)}`; + } + return null; +} + +export function needsBrazilWhatsAppLookup( + digits: string, + dddLookupMin: number, + dddLookupMax: number, +): boolean { + if (!isBrazilMobile(digits)) { + return false; + } + const ddd = getBrazilDdd(digits); + return ddd >= dddLookupMin && ddd <= dddLookupMax; +} + +export function normalizeBrazilMobileForSendDigits(digits: string): string { + if (!isBrazilMobile(digits)) { + return digits; + } + const ddd = getBrazilDdd(digits); + if (ddd >= BR_PHONE_DDD_LOOKUP_MIN_DEFAULT) { + return digits; + } + const local = getBrazilLocalPart(digits); + if (local.length === 8 && MOBILE_FIRST_DIGIT.test(local)) { + return `${COUNTRY_CODE}${ddd}9${local}`; + } + return digits; +} + +export function generateBrazilMobileLookupCandidates(digits: string): string[] { + if (!isBrazilMobile(digits)) { + return [digits]; + } + const ddd = digits.substring(2, 4); + const local = getBrazilLocalPart(digits); + let without9 = digits; + let with9 = digits; + + if (local.length === 9 && local[0] === '9') { + without9 = `${COUNTRY_CODE}${ddd}${local.substring(1)}`; + with9 = `${COUNTRY_CODE}${ddd}${local}`; + } else if (local.length === 8) { + without9 = `${COUNTRY_CODE}${ddd}${local}`; + with9 = `${COUNTRY_CODE}${ddd}9${local}`; + } + + const candidates = [without9, with9]; + return [...new Set(candidates)]; +} + +export function getBrazilPhoneCacheKeys(digits: string): string[] { + const candidates = generateBrazilMobileLookupCandidates(digits); + return [...new Set([digits, ...candidates])]; +}