From a48247c2d22817a98267e9df9d7005464c50ae4e Mon Sep 17 00:00:00 2001 From: devlikepro Date: Thu, 17 Sep 2026 13:36:13 +0700 Subject: [PATCH] feat(apps): add mexican phone number apps --- src/apps/app_sdk/apps/apps.ts | 3 + src/apps/app_sdk/apps/registry.ts | 2 + .../argentine-phone-numbers/app.module.ts | 4 +- .../brazilian-phone-numbers/app.module.ts | 4 +- src/apps/mexican-phone-numbers/app.module.ts | 28 +++++ src/apps/mexican-phone-numbers/controller.ts | 21 ++++ src/apps/mexican-phone-numbers/dto.ts | 3 + .../migrations/001_init_app.ts | 9 ++ .../plugins/MexicanPhoneNumbers.test.ts | 106 ++++++++++++++++++ .../rules/MexicanPhoneNumberRules.test.ts | 32 ++++++ .../rules/MexicanPhoneNumberRules.ts | 13 +++ .../services/MexicanPhoneNumbersAppService.ts | 21 ++++ .../MexicanPhoneNumbersCacheRepository.ts | 5 + .../storage/migrations_001_init_cache.test.ts | 3 + 14 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 src/apps/mexican-phone-numbers/app.module.ts create mode 100644 src/apps/mexican-phone-numbers/controller.ts create mode 100644 src/apps/mexican-phone-numbers/dto.ts create mode 100644 src/apps/mexican-phone-numbers/migrations/001_init_app.ts create mode 100644 src/apps/mexican-phone-numbers/plugins/MexicanPhoneNumbers.test.ts create mode 100644 src/apps/mexican-phone-numbers/rules/MexicanPhoneNumberRules.test.ts create mode 100644 src/apps/mexican-phone-numbers/rules/MexicanPhoneNumberRules.ts create mode 100644 src/apps/mexican-phone-numbers/services/MexicanPhoneNumbersAppService.ts create mode 100644 src/apps/mexican-phone-numbers/storage/MexicanPhoneNumbersCacheRepository.ts diff --git a/src/apps/app_sdk/apps/apps.ts b/src/apps/app_sdk/apps/apps.ts index 4d814816..4fa47170 100644 --- a/src/apps/app_sdk/apps/apps.ts +++ b/src/apps/app_sdk/apps/apps.ts @@ -4,6 +4,7 @@ import { BrazilianPhoneNumbersAppConfig } from '@waha/apps/brazilian-phone-numbe import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto'; import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto'; import { McpAppConfig } from '@waha/apps/mcp/dto/config.dto'; +import { MexicanPhoneNumbersAppConfig } from '@waha/apps/mexican-phone-numbers/dto'; import { PhoneNumbersAppConfig } from '@waha/apps/phone-numbers/dto/config.dto'; export enum AppName { @@ -12,6 +13,7 @@ export enum AppName { chatwoot = 'chatwoot', calls = 'calls', mcp = 'mcp', + mexicanPhoneNumbers = 'mexican-phone-numbers', phoneNumbers = 'phone-numbers', } @@ -27,6 +29,7 @@ export const AppConfigClasses: Record> = { [AppName.calls]: CallsAppConfig, [AppName.chatwoot]: ChatWootAppConfig, [AppName.mcp]: McpAppConfig, + [AppName.mexicanPhoneNumbers]: MexicanPhoneNumbersAppConfig, }; export function GetAppConfigClass(name: AppName): Type { diff --git a/src/apps/app_sdk/apps/registry.ts b/src/apps/app_sdk/apps/registry.ts index 8582b970..73a00c04 100644 --- a/src/apps/app_sdk/apps/registry.ts +++ b/src/apps/app_sdk/apps/registry.ts @@ -6,6 +6,7 @@ import { AppApiTag } from '@waha/apps/app_sdk/apps/openapi'; import CallsAppModule from '@waha/apps/calls/app.module'; import ChatWootAppModule from '@waha/apps/chatwoot/app.module'; import McpAppModule from '@waha/apps/mcp/app.module'; +import MexicanPhoneNumbersAppModule from '@waha/apps/mexican-phone-numbers/app.module'; import PhoneNumbersAppModule from '@waha/apps/phone-numbers/app.module'; /** @@ -18,6 +19,7 @@ const APPS: AppModule[] = [ CallsAppModule, ChatWootAppModule, McpAppModule, + MexicanPhoneNumbersAppModule, PhoneNumbersAppModule, ]; diff --git a/src/apps/argentine-phone-numbers/app.module.ts b/src/apps/argentine-phone-numbers/app.module.ts index 24071888..85b67441 100644 --- a/src/apps/argentine-phone-numbers/app.module.ts +++ b/src/apps/argentine-phone-numbers/app.module.ts @@ -3,7 +3,7 @@ import { AppName } from '@waha/apps/app_sdk/apps/apps'; import { ArgentinePhoneNumbersController } from '@waha/apps/argentine-phone-numbers/controller'; import { ArgentinePhoneNumbersAppService } from '@waha/apps/argentine-phone-numbers/services/ArgentinePhoneNumbersAppService'; -const argentinephonenumbersAppModule: AppModule = { +const ArgentinePhoneNumbersAppModule: AppModule = { name: AppName.argentinePhoneNumbers, openapi: { title: 'Phone Numbers: Argentina', @@ -25,4 +25,4 @@ const argentinephonenumbersAppModule: AppModule = { Service: ArgentinePhoneNumbersAppService, }; -export default argentinephonenumbersAppModule; +export default ArgentinePhoneNumbersAppModule; diff --git a/src/apps/brazilian-phone-numbers/app.module.ts b/src/apps/brazilian-phone-numbers/app.module.ts index 05b996e8..0a2c6f53 100644 --- a/src/apps/brazilian-phone-numbers/app.module.ts +++ b/src/apps/brazilian-phone-numbers/app.module.ts @@ -3,7 +3,7 @@ import { AppName } from '@waha/apps/app_sdk/apps/apps'; import { BrazilianPhoneNumbersController } from '@waha/apps/brazilian-phone-numbers/controller'; import { BrazilianPhoneNumbersAppService } from '@waha/apps/brazilian-phone-numbers/services/BrazilianPhoneNumbersAppService'; -const brazilianphonenumbersAppModule: AppModule = { +const BrazilianPhoneNumbersAppModule: AppModule = { name: AppName.brazilianPhoneNumbers, openapi: { title: 'Phone Numbers: Brazil', @@ -24,4 +24,4 @@ const brazilianphonenumbersAppModule: AppModule = { Service: BrazilianPhoneNumbersAppService, }; -export default brazilianphonenumbersAppModule; +export default BrazilianPhoneNumbersAppModule; diff --git a/src/apps/mexican-phone-numbers/app.module.ts b/src/apps/mexican-phone-numbers/app.module.ts new file mode 100644 index 00000000..1de2e201 --- /dev/null +++ b/src/apps/mexican-phone-numbers/app.module.ts @@ -0,0 +1,28 @@ +import { AppModule } from '@waha/apps/app_sdk/apps/definition'; +import { AppName } from '@waha/apps/app_sdk/apps/apps'; +import { MexicanPhoneNumbersController } from '@waha/apps/mexican-phone-numbers/controller'; +import { MexicanPhoneNumbersAppService } from '@waha/apps/mexican-phone-numbers/services/MexicanPhoneNumbersAppService'; + +const MexicanPhoneNumbersAppModule: AppModule = { + name: AppName.mexicanPhoneNumbers, + openapi: { + title: 'Phone Numbers: Mexico', + description: + 'Resolve Mexican phone numbers (with and without the 1 after the country code)', + }, + definition: { + plainkey: false, + queue: false, + migrations: true, + restartOnChange: true, + unique: true, + }, + nestjs: { + imports: [], + controllers: [MexicanPhoneNumbersController], + providers: [MexicanPhoneNumbersAppService], + }, + Service: MexicanPhoneNumbersAppService, +}; + +export default MexicanPhoneNumbersAppModule; diff --git a/src/apps/mexican-phone-numbers/controller.ts b/src/apps/mexican-phone-numbers/controller.ts new file mode 100644 index 00000000..6aeb42ec --- /dev/null +++ b/src/apps/mexican-phone-numbers/controller.ts @@ -0,0 +1,21 @@ +import { Controller, UseGuards } from '@nestjs/common'; +import { ApiSecurity } from '@nestjs/swagger'; +import { AppName } from '@waha/apps/app_sdk/apps/apps'; +import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver'; +import { MexicanPhoneNumbersAppService } from '@waha/apps/mexican-phone-numbers/services/MexicanPhoneNumbersAppService'; +import { PhoneNumbersCacheController } from '@waha/apps/phone-numbers/api/PhoneNumbersCacheController'; +import { SessionManager } from '@waha/core/abc/manager.abc'; +import { PoliciesGuard } from '@waha/core/auth/policies.guard'; + +@ApiSecurity('api_key') +@Controller('api/apps/mexican-phone-numbers/:session') +@UseGuards(PoliciesGuard) +export class MexicanPhoneNumbersController extends PhoneNumbersCacheController { + constructor( + manager: SessionManager, + resolver: UniqueAppResolver, + appService: MexicanPhoneNumbersAppService, + ) { + super(manager, resolver, appService, AppName.mexicanPhoneNumbers); + } +} diff --git a/src/apps/mexican-phone-numbers/dto.ts b/src/apps/mexican-phone-numbers/dto.ts new file mode 100644 index 00000000..fb8978ae --- /dev/null +++ b/src/apps/mexican-phone-numbers/dto.ts @@ -0,0 +1,3 @@ +import { PhoneNumbersBaseConfig } from '@waha/apps/phone-numbers/dto/config.dto'; + +export class MexicanPhoneNumbersAppConfig extends PhoneNumbersBaseConfig {} diff --git a/src/apps/mexican-phone-numbers/migrations/001_init_app.ts b/src/apps/mexican-phone-numbers/migrations/001_init_app.ts new file mode 100644 index 00000000..dd22e68c --- /dev/null +++ b/src/apps/mexican-phone-numbers/migrations/001_init_app.ts @@ -0,0 +1,9 @@ +import { migrations_001_init_cache } from '@waha/apps/phone-numbers/storage/migrations_001_init_cache'; + +const migration = migrations_001_init_cache({ + table: 'app_mexican_phone_numbers_cache', + index: 'mxphone', +}); + +exports.up = migration.up; +exports.down = migration.down; diff --git a/src/apps/mexican-phone-numbers/plugins/MexicanPhoneNumbers.test.ts b/src/apps/mexican-phone-numbers/plugins/MexicanPhoneNumbers.test.ts new file mode 100644 index 00000000..31599dbe --- /dev/null +++ b/src/apps/mexican-phone-numbers/plugins/MexicanPhoneNumbers.test.ts @@ -0,0 +1,106 @@ +import { UnprocessableEntityException } from '@nestjs/common'; +import { MexicanPhoneNumberRules } from '@waha/apps/mexican-phone-numbers/rules/MexicanPhoneNumberRules'; +import { PhoneNumbersBaseConfig } from '@waha/apps/phone-numbers/dto/config.dto'; +import { + buildPlugin as buildPhoneNumbersPlugin, + resolveChat, + stubLookup, +} from '@waha/apps/phone-numbers/plugins/testing'; + +function buildPlugin(config: Partial = {}) { + return buildPhoneNumbersPlugin({ + config: config, + rules: MexicanPhoneNumberRules(), + }); +} + +describe('MexicanPhoneNumbers', () => { + it('uses the supplied form when it exists, without checking the other one', async () => { + const { session } = buildPlugin(); + stubLookup(session, { numberExists: true, chatId: '525512345678@c.us' }); + + const resolved = await resolveChat(session, '+525512345678'); + + expect(resolved).toBe('525512345678@c.us'); + expect(session.checkNumberStatus).toHaveBeenCalledTimes(1); + expect(session.checkNumberStatus).toHaveBeenCalledWith({ + phone: '525512345678', + session: 'test', + }); + }); + + it('tries the form with 1 after the supplied one is absent, keeps a LID answer', async () => { + const { session } = buildPlugin(); + session.checkNumberStatus = jest + .fn() + .mockResolvedValueOnce({ numberExists: false }) + .mockResolvedValueOnce({ numberExists: true, chatId: '123456@lid' }); + + const resolved = await resolveChat(session, '525512345678@c.us'); + + expect(resolved).toBe('123456@lid'); + expect(session.checkNumberStatus).toHaveBeenLastCalledWith({ + phone: '5215512345678', + session: 'test', + }); + // Both forms hit the cache now + expect(await resolveChat(session, '5215512345678@c.us')).toBe('123456@lid'); + expect(session.checkNumberStatus).toHaveBeenCalledTimes(2); + }); + + it('tries the form without 1 as the fallback', async () => { + const { session } = buildPlugin(); + session.checkNumberStatus = jest + .fn() + .mockResolvedValueOnce({ numberExists: false }) + .mockResolvedValueOnce({ + numberExists: true, + chatId: '523312345678@c.us', + }); + + const resolved = await resolveChat(session, '5213312345678@c.us'); + + expect(resolved).toBe('523312345678@c.us'); + }); + + it('prefers the pn when the engine answers with both pn and a LID', async () => { + const { session } = buildPlugin(); + stubLookup(session, { + numberExists: true, + chatId: '123@lid', + pn: '5215512345678@c.us', + }); + + expect(await resolveChat(session, '525512345678@c.us')).toBe( + '5215512345678@c.us', + ); + }); + + it('soft mode keeps the supplied form when both are absent, strict rejects', async () => { + const soft = buildPlugin(); + stubLookup(soft.session, { numberExists: false }); + expect(await resolveChat(soft.session, '525512345678@c.us')).toBe( + '525512345678@c.us', + ); + expect(soft.session.checkNumberStatus).toHaveBeenCalledTimes(2); + + const strict = buildPlugin({ strict: true }); + stubLookup(strict.session, { numberExists: false }); + await expect( + resolveChat(strict.session, '525512345678@c.us'), + ).rejects.toThrow(UnprocessableEntityException); + }); + + it('leaves numbers without the country code and other countries untouched', async () => { + const { session } = buildPlugin(); + stubLookup(session, { numberExists: false }); + + expect(await resolveChat(session, '5512345678@c.us')).toBe( + '5512345678@c.us', + ); + expect(await resolveChat(session, '5511912345678@c.us')).toBe( + '5511912345678@c.us', + ); + expect(session.checkNumberStatus).not.toHaveBeenCalled(); + }); +}); diff --git a/src/apps/mexican-phone-numbers/rules/MexicanPhoneNumberRules.test.ts b/src/apps/mexican-phone-numbers/rules/MexicanPhoneNumberRules.test.ts new file mode 100644 index 00000000..b6df2e9e --- /dev/null +++ b/src/apps/mexican-phone-numbers/rules/MexicanPhoneNumberRules.test.ts @@ -0,0 +1,32 @@ +import { MexicanPhoneNumberRules } from './MexicanPhoneNumberRules'; + +describe('MexicanPhoneNumberRules', () => { + const rules = MexicanPhoneNumberRules(); + + function resolve(digits: string) { + const rule = rules.find((r) => r.matches(digits)); + return rule?.resolve(digits) ?? null; + } + + it.each([ + ['525512345678', ['525512345678', '5215512345678']], + ['5215512345678', ['5215512345678', '525512345678']], + ['523312345678', ['523312345678', '5213312345678']], + ['5216641234567', ['5216641234567', '526641234567']], + ])('keeps the supplied form first for %s', (digits, expected) => { + expect(resolve(digits)).toEqual({ candidates: expected, fallback: digits }); + }); + + it.each([ + '5512345678', + '0445512345678', + '52551234567', + '525512345678901', + '521551234567', + '5491112345678', + '5511912345678', + '', + ])('does not match %s', (digits) => { + expect(resolve(digits)).toBeNull(); + }); +}); diff --git a/src/apps/mexican-phone-numbers/rules/MexicanPhoneNumberRules.ts b/src/apps/mexican-phone-numbers/rules/MexicanPhoneNumberRules.ts new file mode 100644 index 00000000..4c8e4296 --- /dev/null +++ b/src/apps/mexican-phone-numbers/rules/MexicanPhoneNumberRules.ts @@ -0,0 +1,13 @@ +import { PhoneNumberRule } from '@waha/apps/phone-numbers/rules/PhoneNumberRule'; +import { RegexpPhoneNumberRule } from '@waha/apps/phone-numbers/rules/RegexpPhoneNumberRule'; + +/** + * Mexico: since 2019 numbers are dialed as 52 + 10 digits, but accounts registered before that are still stored + * on WhatsApp with the old mobile 1 after the country code - check the supplied form first, then the other one + */ +export function MexicanPhoneNumberRules(): PhoneNumberRule[] { + return [ + new RegexpPhoneNumberRule('^52([2-9]\\d{9})$', '521$1'), + new RegexpPhoneNumberRule('^521(\\d{10})$', '52$1'), + ]; +} diff --git a/src/apps/mexican-phone-numbers/services/MexicanPhoneNumbersAppService.ts b/src/apps/mexican-phone-numbers/services/MexicanPhoneNumbersAppService.ts new file mode 100644 index 00000000..9831db8d --- /dev/null +++ b/src/apps/mexican-phone-numbers/services/MexicanPhoneNumbersAppService.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver'; +import { MexicanPhoneNumbersAppConfig } from '@waha/apps/mexican-phone-numbers/dto'; +import { MexicanPhoneNumberRules } from '@waha/apps/mexican-phone-numbers/rules/MexicanPhoneNumberRules'; +import { MexicanPhoneNumbersCacheRepository } from '@waha/apps/mexican-phone-numbers/storage/MexicanPhoneNumbersCacheRepository'; +import { PhoneNumberRule } from '@waha/apps/phone-numbers/rules/PhoneNumberRule'; +import { PhoneNumbersAppServiceBase } from '@waha/apps/phone-numbers/services/PhoneNumbersAppServiceBase'; + +@Injectable() +export class MexicanPhoneNumbersAppService extends PhoneNumbersAppServiceBase { + protected readonly Repository = MexicanPhoneNumbersCacheRepository; + + constructor(resolver: UniqueAppResolver) { + super(resolver); + } + + protected rules(config: MexicanPhoneNumbersAppConfig): PhoneNumberRule[] { + void config; + return MexicanPhoneNumberRules(); + } +} diff --git a/src/apps/mexican-phone-numbers/storage/MexicanPhoneNumbersCacheRepository.ts b/src/apps/mexican-phone-numbers/storage/MexicanPhoneNumbersCacheRepository.ts new file mode 100644 index 00000000..4c178c71 --- /dev/null +++ b/src/apps/mexican-phone-numbers/storage/MexicanPhoneNumbersCacheRepository.ts @@ -0,0 +1,5 @@ +import { PhoneNumbersCacheRepository } from '@waha/apps/phone-numbers/storage/PhoneNumbersCacheRepository'; + +export class MexicanPhoneNumbersCacheRepository extends PhoneNumbersCacheRepository { + static tableName = 'app_mexican_phone_numbers_cache'; +} diff --git a/src/apps/phone-numbers/storage/migrations_001_init_cache.test.ts b/src/apps/phone-numbers/storage/migrations_001_init_cache.test.ts index 81e8e59b..da469203 100644 --- a/src/apps/phone-numbers/storage/migrations_001_init_cache.test.ts +++ b/src/apps/phone-numbers/storage/migrations_001_init_cache.test.ts @@ -7,6 +7,8 @@ const phoneNumbers = require('../migrations/001_init_app'); // eslint-disable-next-line @typescript-eslint/no-var-requires const argentine = require('../../argentine-phone-numbers/migrations/001_init_app'); // eslint-disable-next-line @typescript-eslint/no-var-requires +const mexican = require('../../mexican-phone-numbers/migrations/001_init_app'); +// eslint-disable-next-line @typescript-eslint/no-var-requires const brazilian = require('../../brazilian-phone-numbers/migrations/001_init_brazilian_phone_numbers'); describe('migrations_001_init_cache', () => { @@ -28,6 +30,7 @@ describe('migrations_001_init_cache', () => { it('every phone numbers app gets its own table', async () => { await phoneNumbers.up(db); await argentine.up(db); + await mexican.up(db); await brazilian.up(db); expect(await db.schema.hasTable('app_phone_numbers_cache')).toBe(true);