Compare commits

...
49 Commits
Author SHA1 Message Date
devlikepro 90ba445a0e up(dashboard): fix refresh chat on new messages
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-10-02 13:07:08 +07:00
devlikepro bf0f49b2e3 up: GOWS - v1.0.48 2026-10-02 13:07:08 +07:00
devlikepro c9ac14e1a4 fix(WEBJS): hide "what's new" modal - use gating days 2026-10-02 13:07:08 +07:00
devlikepro c4f224b7a6 fix(WEBJS): remove hide ux fresh look 2026-10-02 13:07:08 +07:00
devlikepro 0da9ef68cf feat: groups share history mode - fix #2282 2026-10-02 13:07:08 +07:00
Alison White cc650fca3b feat(NOWEB): expose group member-share-history-mode as a settable endpoint (#2283) 2026-10-02 13:07:08 +07:00
devlikepro 9c080611b6 fix(NOWEB): fix delete channel message - fix #2038 2026-10-02 13:07:08 +07:00
devlikepro 235ec01d8b fix(NOWEB): fix update contact - mention #2285 2026-10-02 13:07:08 +07:00
devlikepro ce1e42bb17 up(WEBJS): fix set push name 2026-10-02 13:07:08 +07:00
devlikepro 7440cfc72c up(WEBJS): fix some modules 2026-10-02 13:07:08 +07:00
devlikepro dbb6be3814 up(WEBJS): fix forward message - fix #2278 2026-10-02 13:07:08 +07:00
devlikepro 9035dd95ff up: WPP 2026-10-02 13:07:08 +07:00
devlikepro 9a70c50f62 version: 2026.9.2 2026-10-02 13:07:08 +07:00
Brian Babón 7d7d331195 fix(NOWEB): fix deleting channel message - fix #2038 (#2284) 2026-09-30 15:41:12 +07:00
devlikepro 55a7d78e3f fix(NOWEB): template message with image header — hasMedia:true but download fails - fix #2275
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-09-22 10:38:41 +07:00
devlikepro 377a2d55c9 up: WPP 2026-09-22 10:31:51 +07:00
devlikepro 82046700d7 up: WEBJS - fix "Data passed to getter must include an id property (it's how we memoize) but got undefined" - fix #2271 fix #2273 2026-09-22 10:29:22 +07:00
devlikepro 0dc745bab9 docs: mentions is null in example 2026-09-22 10:26:45 +07:00
Jefferson Emerick 3a52c7dc19 docs: expose mentions in the OpenAPI spec - closes #2268 (#2270) 2026-09-22 10:23:18 +07:00
devlikepro dcb4f5f2c0 up: dashboard 2026-09-17 13:49:25 +07:00
devlikepro a48247c2d2 feat(apps): add mexican phone number apps 2026-09-17 13:36:13 +07:00
devlikepro 0e73561b8e fix(openapi): tags for apps 2026-09-17 13:00:44 +07:00
devlikepro 3ac4ab0746 feat(apps): phone numbers apps to check chat id before sending a message
closes #2260
Co-authored-by: ropu <rovagnati@gmail.com>
2026-09-17 12:55:23 +07:00
devlikeproandlucirlei 1c6081a871 feat(chatwoot): sync message status (delivered/read) from WhatsApp acks
Closes #2264

Co-authored-by: lucirlei <lucirleisantos@msn.com>
2026-09-16 13:46:19 +07:00
devlikepro f1f25cf812 up(NOWEB): fix thumbnails in history 2026-09-16 12:23:37 +07:00
devlikepro 8b73f51267 fix: logger args 2026-09-16 12:23:37 +07:00
devlikepro b1a87b435a fix(NOWEB): fix media reupload - fix #2265 2026-09-16 12:23:37 +07:00
devlikepro 5c04d95da7 chore: make rebuild-noweb 2026-09-16 11:32:45 +07:00
devlikepro 7b6cf27081 up(WEBJS) - fix "POST /api/{session}/groups/join fails with joinGroupViaInvite is undefined" fix #2266 2026-09-16 11:02:03 +07:00
devlikepro d86ea302cb fix: parse invite code without query params 2026-09-16 10:54:18 +07:00
devlikepro 4f0b255b96 chore: make link/unlink 2026-09-16 10:42:05 +07:00
devlikepro def74cd4da up(WPP) 2026-09-16 10:08:00 +07:00
devlikepro 3a4cb909fd fix(chatwoot): remove tier check messages from Chatwoot 2026-09-16 09:51:23 +07:00
devlikepro 495866fc8f up: chrome 2026-09-11 14:57:47 +07:00
devlikepro 408128a537 up: puppeter 25 2026-09-11 14:57:41 +07:00
devlikepro 49878f943d feat(ChatWoot): contacts pull --name=keep|fill|overwrite - fix #2252 2026-09-11 14:56:06 +07:00
devlikepro ee6a952490 feat(ChatWoot): send messages from device as messages in ChatWoot - fix #2249 2026-09-11 13:39:59 +07:00
devlikepro 8e36c7a14c fix(WEBJS): fix presence.update 2026-09-11 12:38:39 +07:00
devlikepro b5702f1a48 chore: validate poll payload - mention #2261 2026-09-11 11:57:54 +07:00
devlikeproandVíctor Rodríguez-Fernández 300f300a02 fix(WEBJS): fix presence subscribe on LID accounts - close #2262
Co-authored-by: Víctor Rodríguez-Fernández <victor.rodriguezf@uam.es>
2026-09-11 11:46:38 +07:00
devlikeproandPoorvaja 254906cf4c fix(NOWEB): archive/unread chat without recent message (closes #2258, fixes #2181)
Co-Authored-By: Poorvaja <poorvaja.s@outlook.com>
2026-09-11 11:27:09 +07:00
devlikepro f51a14eaeb fix(GOWS) - no empty subject for a group - mention #2257 2026-09-11 11:11:31 +07:00
devlikepro bf286f406e up(GOWS)
Fix setting empty group description (topic) - fix #2257
2026-09-11 11:10:11 +07:00
Berg Pinheiro 164706ff61 feat(GOWS): implement forwardMessage (closes #2256, fixes #1439) 2026-09-11 10:33:07 +07:00
devlikepro 86d2816bed up(NOWEB) 2026-09-11 10:33:07 +07:00
devlikepro ae8f68e74f up(WPP) 2026-09-11 10:33:07 +07:00
Berg Pinheiro a27180f212 [core] fix: pin sharp to one version so Lottie stickers convert
fix #2239
2026-09-11 10:33:07 +07:00
devlikepro d0cf4c4f30 version: 2026.9.1 2026-09-11 10:33:07 +07:00
jperquin aa0663b187 fix: add default limit to chats-list pagination (closes #2254) (fix #2253) 2026-09-09 17:56:21 +07:00
143 changed files with 3987 additions and 2038 deletions

No files matched your search

+1 -1
View File
@@ -179,7 +179,7 @@ RUN if [ "$USE_BROWSER" = "chromium" ]; then \
# Install Chrome
# Available versions:
# https://www.ubuntuupdates.org/package/google_chrome/stable/main/base/google-chrome-stable
ARG CHROME_VERSION="142.0.7444.134-1"
ARG CHROME_VERSION="152.0.7977.82-1"
ARG OPUSTAGS_VERSION="1.10.1"
RUN if [ "$USE_BROWSER" = "chrome" ]; then \
wget --no-verbose -O /tmp/chrome.deb https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb \
+13
View File
@@ -43,6 +43,19 @@ up-noweb-libsignal:
up-webjs:
yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2026-06-26
link-webjs:
yarn up whatsapp-web.js@link:../whatsapp-web.js
unlink-webjs: up-webjs
rebuild-noweb:
cd ../Baileys && yarn build
link-noweb: rebuild-noweb
yarn up @adiwajshing/baileys@link:../Baileys
unlink-noweb: up-noweb
up-wpp:
SHARP_IGNORE_GLOBAL_LIBVIPS=1 yarn up @wppconnect-team/wppconnect@github:wppconnect-team/wppconnect#master
SHARP_IGNORE_GLOBAL_LIBVIPS=1 yarn up @wppconnect/wa-js@github:wppconnect-team/wa-js#main
+4 -3
View File
@@ -111,7 +111,7 @@
"pino-pretty": "^11.2.1",
"pretty-bytes": "5.6.0",
"promise-retry": "^2.0.1",
"puppeteer": "^24.31.0",
"puppeteer": "^25.10.0",
"qrcode": "^1.5.1",
"qrcode-terminal": "^0.12.0",
"reflect-metadata": "^0.1.13",
@@ -138,10 +138,11 @@
"typescript": "5.9.2",
"bufferutil": "^4.0.8",
"ws": "^8.18.0",
"puppeteer": "^24.31.0",
"puppeteer": "^25.10.0",
"whatwg-url": "13.0.0",
"axios": "^1.19.0",
"whatsapp-rust-bridge": "npm:@devlikeapro/whatsapp-rust-bridge@0.5.2"
"whatsapp-rust-bridge": "npm:@devlikeapro/whatsapp-rust-bridge@0.5.2",
"sharp": "0.35.3"
},
"devDependencies": {
"@grpc/grpc-js": "^1.14.1",
+1
View File
@@ -296,6 +296,7 @@ export class ChattingController {
description: 'You can use it as buttons or list replacement',
})
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
@UsePipes(new WAHAValidationPipe())
async sendPoll(@Body() request: MessagePollRequest) {
const whatsapp = await this.manager.getWorkingSession(request.session);
return whatsapp.sendPoll(request);
+39
View File
@@ -41,6 +41,7 @@ import {
JoinGroupResponse,
ParticipantsRequest,
SettingsMemberAddMode,
SettingsMemberShareHistoryMode,
SettingsMembershipApproval,
SettingsSecurityChangeInfo,
SubjectRequest,
@@ -346,6 +347,44 @@ export class GroupsController {
return session.getMemberAddMode(id);
}
@Put(':id/settings/security/member-share-history-mode')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({
summary:
'Update settings - members can send message history to new members',
description:
'Updates the group settings for whether members can share message history with new members, or only admins can.',
})
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
setMemberShareHistoryMode(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
@Body() request: SettingsMemberShareHistoryMode,
): Promise<boolean> {
return session.setMemberShareHistoryMode(
id,
request.membersCanShareHistory,
);
}
@Get(':id/settings/security/member-share-history-mode')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({
summary: 'Get settings - members can send message history to new members',
description:
'The group settings for whether members can share message history with new members, or only admins can.',
})
getMemberShareHistoryMode(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
): Promise<SettingsMemberShareHistoryMode> {
return session.getMemberShareHistoryMode(id);
}
@Put(':id/settings/security/membership-approval')
@SessionApiParam
@GroupIdApiParam
+1 -1
View File
@@ -52,7 +52,7 @@ export class JobLoggerWrapper implements ILogger {
this.job
.log(`[${timestamp}] ${level.toUpperCase()}: ${msg}`)
.catch((err) => {
this.logger.error('Error logging message to job', err);
this.logger.error(err, 'Error logging message to job');
});
}
}
+11 -2
View File
@@ -1,14 +1,20 @@
import { ArgentinePhoneNumbersAppConfig } from '@waha/apps/argentine-phone-numbers/dto';
import { Type } from '@nestjs/common';
import { BrazilianPhoneNumbersAppConfig } from '@waha/apps/brazilian-phone-numbers/dto/config.dto';
import { BrazilianPhoneNumbersAppConfig } from '@waha/apps/brazilian-phone-numbers/dto';
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 {
argentinePhoneNumbers = 'argentine-phone-numbers',
brazilianPhoneNumbers = 'brazilian-phone-numbers',
chatwoot = 'chatwoot',
calls = 'calls',
mcp = 'mcp',
brazilianPhoneNumbers = 'brazilian-phone-numbers',
mexicanPhoneNumbers = 'mexican-phone-numbers',
phoneNumbers = 'phone-numbers',
}
/**
@@ -17,10 +23,13 @@ export enum AppName {
* without pulling in every app module (controllers, services, queues) - that creates require cycles.
*/
export const AppConfigClasses: Record<AppName, Type<any>> = {
[AppName.argentinePhoneNumbers]: ArgentinePhoneNumbersAppConfig,
[AppName.brazilianPhoneNumbers]: BrazilianPhoneNumbersAppConfig,
[AppName.phoneNumbers]: PhoneNumbersAppConfig,
[AppName.calls]: CallsAppConfig,
[AppName.chatwoot]: ChatWootAppConfig,
[AppName.mcp]: McpAppConfig,
[AppName.mexicanPhoneNumbers]: MexicanPhoneNumbersAppConfig,
};
export function GetAppConfigClass(name: AppName): Type<any> {
+7 -1
View File
@@ -1,20 +1,26 @@
import ArgentinePhoneNumbersAppModule from '@waha/apps/argentine-phone-numbers/app.module';
import { ApiTags } from '@nestjs/swagger';
import BrazilianPhoneNumbersAppModule from '@waha/apps/brazilian-phone-numbers/app.module';
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
import { AppApiTag } from '@waha/apps/app_sdk/apps/openapi';
import BrazilianPhoneNumbersAppModule from '@waha/apps/brazilian-phone-numbers/app.module';
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';
/**
* Registry of available apps.
* Add new apps here - the rest of app_sdk works off this list.
*/
const APPS: AppModule[] = [
ArgentinePhoneNumbersAppModule,
BrazilianPhoneNumbersAppModule,
CallsAppModule,
ChatWootAppModule,
McpAppModule,
MexicanPhoneNumbersAppModule,
PhoneNumbersAppModule,
];
// Tag every app controller with the app's OpenAPI tag ("🧩 Apps: {Title}") from AppModule.openapi,
@@ -0,0 +1,28 @@
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
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 = {
name: AppName.argentinePhoneNumbers,
openapi: {
title: 'Phone Numbers: Argentina',
description:
'Resolve Argentine phone numbers (with and without the mobile 9)',
},
definition: {
plainkey: false,
queue: false,
migrations: true,
restartOnChange: true,
unique: true,
},
nestjs: {
imports: [],
controllers: [ArgentinePhoneNumbersController],
providers: [ArgentinePhoneNumbersAppService],
},
Service: ArgentinePhoneNumbersAppService,
};
export default ArgentinePhoneNumbersAppModule;
@@ -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 { ArgentinePhoneNumbersAppService } from '@waha/apps/argentine-phone-numbers/services/ArgentinePhoneNumbersAppService';
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/argentine-phone-numbers/:session')
@UseGuards(PoliciesGuard)
export class ArgentinePhoneNumbersController extends PhoneNumbersCacheController {
constructor(
manager: SessionManager,
resolver: UniqueAppResolver,
appService: ArgentinePhoneNumbersAppService,
) {
super(manager, resolver, appService, AppName.argentinePhoneNumbers);
}
}
+3
View File
@@ -0,0 +1,3 @@
import { PhoneNumbersBaseConfig } from '@waha/apps/phone-numbers/dto/config.dto';
export class ArgentinePhoneNumbersAppConfig extends PhoneNumbersBaseConfig {}
@@ -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_argentine_phone_numbers_cache',
index: 'arphone',
});
exports.up = migration.up;
exports.down = migration.down;
@@ -0,0 +1,106 @@
import { UnprocessableEntityException } from '@nestjs/common';
import { ArgentinePhoneNumberRules } from '@waha/apps/argentine-phone-numbers/rules/ArgentinePhoneNumberRules';
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<PhoneNumbersBaseConfig> = {}) {
return buildPhoneNumbersPlugin({
config: config,
rules: ArgentinePhoneNumberRules(),
});
}
describe('ArgentinePhoneNumbers', () => {
it('uses the supplied form when it exists, without checking the other one', async () => {
const { session } = buildPlugin();
stubLookup(session, { numberExists: true, chatId: '541112345678@c.us' });
const resolved = await resolveChat(session, '+541112345678');
expect(resolved).toBe('541112345678@c.us');
expect(session.checkNumberStatus).toHaveBeenCalledTimes(1);
expect(session.checkNumberStatus).toHaveBeenCalledWith({
phone: '541112345678',
session: 'test',
});
});
it('tries the form with 9 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, '541112345678@c.us');
expect(resolved).toBe('123456@lid');
expect(session.checkNumberStatus).toHaveBeenLastCalledWith({
phone: '5491112345678',
session: 'test',
});
// Both forms hit the cache now
expect(await resolveChat(session, '5491112345678@c.us')).toBe('123456@lid');
expect(session.checkNumberStatus).toHaveBeenCalledTimes(2);
});
it('tries the form without 9 as the fallback', async () => {
const { session } = buildPlugin();
session.checkNumberStatus = jest
.fn()
.mockResolvedValueOnce({ numberExists: false })
.mockResolvedValueOnce({
numberExists: true,
chatId: '542920123456@c.us',
});
const resolved = await resolveChat(session, '5492920123456@c.us');
expect(resolved).toBe('542920123456@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: '5491112345678@c.us',
});
expect(await resolveChat(session, '541112345678@c.us')).toBe(
'5491112345678@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, '541112345678@c.us')).toBe(
'541112345678@c.us',
);
expect(soft.session.checkNumberStatus).toHaveBeenCalledTimes(2);
const strict = buildPlugin({ strict: true });
stubLookup(strict.session, { numberExists: false });
await expect(
resolveChat(strict.session, '541112345678@c.us'),
).rejects.toThrow(UnprocessableEntityException);
});
it('leaves local forms and other countries untouched', async () => {
const { session } = buildPlugin();
stubLookup(session, { numberExists: false });
expect(await resolveChat(session, '1112345678@c.us')).toBe(
'1112345678@c.us',
);
expect(await resolveChat(session, '5511912345678@c.us')).toBe(
'5511912345678@c.us',
);
expect(session.checkNumberStatus).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,33 @@
import { ArgentinePhoneNumberRules } from './ArgentinePhoneNumberRules';
describe('ArgentinePhoneNumberRules', () => {
const rules = ArgentinePhoneNumberRules();
function resolve(digits: string) {
const rule = rules.find((r) => r.matches(digits));
return rule?.resolve(digits) ?? null;
}
it.each([
['541112345678', ['541112345678', '5491112345678']],
['5491112345678', ['5491112345678', '541112345678']],
['543511234567', ['543511234567', '5493511234567']],
['5492920123456', ['5492920123456', '542920123456']],
])('keeps the supplied form first for %s', (digits, expected) => {
expect(resolve(digits)).toEqual({ candidates: expected, fallback: digits });
});
it.each([
'0111512345678',
'1112345678',
'91112345678',
'54111512345678',
'548001234567',
'54911123',
'54911123456789',
'5511912345678',
'',
])('does not match %s', (digits) => {
expect(resolve(digits)).toBeNull();
});
});
@@ -0,0 +1,13 @@
import { PhoneNumberRule } from '@waha/apps/phone-numbers/rules/PhoneNumberRule';
import { RegexpPhoneNumberRule } from '@waha/apps/phone-numbers/rules/RegexpPhoneNumberRule';
/**
* Argentina: mobiles have a 9 after the country code, callers send either form - check the supplied one first.
* 54 + area code (1-3...) + number, 10 digits, international form only
*/
export function ArgentinePhoneNumberRules(): PhoneNumberRule[] {
return [
new RegexpPhoneNumberRule('^54([1-3]\\d{9})$', '549$1'),
new RegexpPhoneNumberRule('^549([1-3]\\d{9})$', '54$1'),
];
}
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
import { ArgentinePhoneNumbersAppConfig } from '@waha/apps/argentine-phone-numbers/dto';
import { ArgentinePhoneNumberRules } from '@waha/apps/argentine-phone-numbers/rules/ArgentinePhoneNumberRules';
import { ArgentinePhoneNumbersCacheRepository } from '@waha/apps/argentine-phone-numbers/storage/ArgentinePhoneNumbersCacheRepository';
import { PhoneNumberRule } from '@waha/apps/phone-numbers/rules/PhoneNumberRule';
import { PhoneNumbersAppServiceBase } from '@waha/apps/phone-numbers/services/PhoneNumbersAppServiceBase';
@Injectable()
export class ArgentinePhoneNumbersAppService extends PhoneNumbersAppServiceBase<ArgentinePhoneNumbersAppConfig> {
protected readonly Repository = ArgentinePhoneNumbersCacheRepository;
constructor(resolver: UniqueAppResolver) {
super(resolver);
}
protected rules(config: ArgentinePhoneNumbersAppConfig): PhoneNumberRule[] {
void config;
return ArgentinePhoneNumberRules();
}
}
@@ -0,0 +1,5 @@
import { PhoneNumbersCacheRepository } from '@waha/apps/phone-numbers/storage/PhoneNumbersCacheRepository';
export class ArgentinePhoneNumbersCacheRepository extends PhoneNumbersCacheRepository {
static tableName = 'app_argentine_phone_numbers_cache';
}
@@ -1,12 +1,12 @@
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
import { AppName } from '@waha/apps/app_sdk/apps/apps';
import { BrazilianPhoneNumbersController } from '@waha/apps/brazilian-phone-numbers/api/brazilian-phone-numbers.controller';
import { BrazilianPhoneNumbersController } from '@waha/apps/brazilian-phone-numbers/controller';
import { BrazilianPhoneNumbersAppService } from '@waha/apps/brazilian-phone-numbers/services/BrazilianPhoneNumbersAppService';
const BrazilianPhoneNumbersAppModule: AppModule = {
name: AppName.brazilianPhoneNumbers,
openapi: {
title: 'Brazilian Phone Numbers',
title: 'Phone Numbers: Brazil',
description: 'Resolve Brazilian phone numbers (with and without 9 digit)',
},
definition: {
@@ -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 { BrazilianPhoneNumbersAppService } from '@waha/apps/brazilian-phone-numbers/services/BrazilianPhoneNumbersAppService';
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/brazilian-phone-numbers/:session')
@UseGuards(PoliciesGuard)
export class BrazilianPhoneNumbersController extends PhoneNumbersCacheController {
constructor(
manager: SessionManager,
resolver: UniqueAppResolver,
appService: BrazilianPhoneNumbersAppService,
) {
super(manager, resolver, appService, AppName.brazilianPhoneNumbers);
}
}
+3
View File
@@ -0,0 +1,3 @@
import { PhoneNumbersBaseConfig } from '@waha/apps/phone-numbers/dto/config.dto';
export class BrazilianPhoneNumbersAppConfig extends PhoneNumbersBaseConfig {}
@@ -1,8 +0,0 @@
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { BrazilianPhoneNumbersAppConfig } from '@waha/apps/brazilian-phone-numbers/dto/config.dto';
import { Type } from 'class-transformer';
export class BrazilianPhoneNumbersAppDto extends App<BrazilianPhoneNumbersAppConfig> {
@Type(() => BrazilianPhoneNumbersAppConfig)
config: BrazilianPhoneNumbersAppConfig;
}
@@ -1,29 +1,9 @@
import { Knex } from 'knex';
import { migrations_001_init_cache } from '@waha/apps/phone-numbers/storage/migrations_001_init_cache';
exports.up = function (knex: Knex) {
return knex.schema
.createTable('app_brazilian_phone_numbers_cache', function (table) {
table.increments('id');
table.integer('app_pk');
table
.foreign('app_pk')
.references('pk')
.inTable('apps')
.onDelete('CASCADE');
// Phone number digits the caller may address the chat by
table.string('key', 32);
// The chat id the number resolved to ('5585...@c.us' or '...@lid')
table.string('chat_id', 64);
table.boolean('verified');
table.datetime('resolved_at');
})
.table('app_brazilian_phone_numbers_cache', function (table) {
table.unique(['app_pk', 'key'], { indexName: 'brphone_app_key_unique' });
table.index(['app_pk'], 'brphone_app_idx');
table.index(['app_pk', 'resolved_at'], 'brphone_app_resolved_idx');
});
};
const migration = migrations_001_init_cache({
table: 'app_brazilian_phone_numbers_cache',
index: 'brphone',
});
exports.down = function (knex: Knex) {
return knex.schema.dropTable('app_brazilian_phone_numbers_cache');
};
exports.up = migration.up;
exports.down = migration.down;
@@ -1,72 +1,29 @@
import { UnprocessableEntityException } from '@nestjs/common';
import { BrazilianPhoneGowsPlugin } from '@waha/apps/brazilian-phone-numbers/plugins/BrazilianPhoneGowsPlugin';
import { BrazilianPhoneNowebPlugin } from '@waha/apps/brazilian-phone-numbers/plugins/BrazilianPhoneNowebPlugin';
import { BrazilianPhoneCorePlugin } from '@waha/apps/brazilian-phone-numbers/plugins/BrazilianPhoneCorePlugin';
import { BrazilianPhoneNumberRule } from '@waha/apps/brazilian-phone-numbers/rules/BrazilianPhoneNumberRule';
import { PhoneNumbersBaseConfig } from '@waha/apps/phone-numbers/dto/config.dto';
import { PhoneNumbersGowsPlugin } from '@waha/apps/phone-numbers/plugins/PhoneNumbersGowsPlugin';
import { PhoneNumbersNowebPlugin } from '@waha/apps/phone-numbers/plugins/PhoneNumbersNowebPlugin';
import { PhoneNumbersCorePlugin } from '@waha/apps/phone-numbers/plugins/PhoneNumbersCorePlugin';
import {
BrazilianPhoneNumbersAppConfig,
BrazilianPhoneNumbersCacheConfig,
} from '@waha/apps/brazilian-phone-numbers/dto/config.dto';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { RegisterPluginHooks } from '@waha/core/abc/session.plugin.hooks';
const logger: any = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
};
logger.child = () => logger;
const BaseSession = WhatsappSession as unknown as new (params: any) => any;
class TestSession extends BaseSession {}
function buildSession(): any {
return new TestSession({
name: 'test',
printQR: false,
loggerBuilder: { child: () => logger },
sessionStore: null,
mediaManager: null,
sessionConfig: null,
engineConfig: null,
ignore: {},
});
}
function buildConfig(
overrides: Partial<BrazilianPhoneNumbersAppConfig> = {},
): BrazilianPhoneNumbersAppConfig {
const config = new BrazilianPhoneNumbersAppConfig();
config.cache = new BrazilianPhoneNumbersCacheConfig();
return Object.assign(config, overrides);
}
buildPlugin as buildPhoneNumbersPlugin,
resolveChat,
stubLookup,
} from '@waha/apps/phone-numbers/plugins/testing';
interface BuildOptions {
config?: Partial<BrazilianPhoneNumbersAppConfig>;
config?: Partial<PhoneNumbersBaseConfig>;
repository?: any;
pluginClass?: typeof BrazilianPhoneCorePlugin;
pluginClass?: typeof PhoneNumbersCorePlugin;
}
function buildPlugin(options: BuildOptions = {}) {
const session = buildSession();
const PluginClass = options.pluginClass ?? BrazilianPhoneCorePlugin;
const plugin = new PluginClass(session, logger, buildConfig(options.config), {
repository: options.repository ?? null,
return buildPhoneNumbersPlugin({
...options,
rules: [new BrazilianPhoneNumberRule()],
});
RegisterPluginHooks(plugin);
return { session: session, plugin: plugin };
}
function resolveChat(session: any, wid: string, method = 'sendText') {
return session.hooks.wid.chat.promise(wid, method);
}
function stubLookup(session: any, answer: any) {
session.checkNumberStatus = jest.fn(async () => answer);
}
describe('BrazilianPhonePlugin - wid.chat', () => {
describe('BrazilianPhoneNumbers - wid.chat', () => {
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 } = buildPlugin();
@@ -180,7 +137,7 @@ describe('BrazilianPhonePlugin - wid.chat', () => {
expect(session.checkNumberStatus).not.toHaveBeenCalled();
});
it('leaves non-Brazilian numbers untouched', async () => {
it('leaves numbers of other countries untouched', async () => {
const { session } = buildPlugin();
stubLookup(session, { numberExists: false });
@@ -314,7 +271,7 @@ describe('BrazilianPhonePlugin - wid.chat', () => {
});
});
describe('BrazilianPhonePlugin - wid.mention', () => {
describe('BrazilianPhoneNumbers - wid.mention', () => {
it('resolves mentions best-effort and never breaks the send', async () => {
const { session } = buildPlugin({ config: { strict: true } });
stubLookup(session, { numberExists: false });
@@ -330,7 +287,7 @@ describe('BrazilianPhonePlugin - wid.mention', () => {
});
});
describe('BrazilianPhonePlugin - persistent cache tier', () => {
describe('BrazilianPhoneNumbers - persistent cache tier', () => {
function buildRepository() {
return {
get: jest.fn().mockResolvedValue(null),
@@ -403,9 +360,9 @@ describe('BrazilianPhonePlugin - persistent cache tier', () => {
});
});
describe('BrazilianPhoneGowsPlugin - local LID map tier', () => {
describe('BrazilianPhoneNumbers GOWS - local LID map tier', () => {
it('resolves via the LID map with no WhatsApp lookup', async () => {
const { session } = buildPlugin({ pluginClass: BrazilianPhoneGowsPlugin });
const { session } = buildPlugin({ pluginClass: PhoneNumbersGowsPlugin });
stubLookup(session, { numberExists: false });
session.findLIDByPhoneNumber = jest.fn(async (phone: string) => {
if (phone === '558591203123') {
@@ -421,7 +378,7 @@ describe('BrazilianPhoneGowsPlugin - local LID map tier', () => {
});
it('falls through to the WhatsApp lookup for unknown numbers', async () => {
const { session } = buildPlugin({ pluginClass: BrazilianPhoneGowsPlugin });
const { session } = buildPlugin({ pluginClass: PhoneNumbersGowsPlugin });
stubLookup(session, {
numberExists: true,
chatId: '5585991203123@c.us',
@@ -437,9 +394,9 @@ describe('BrazilianPhoneGowsPlugin - local LID map tier', () => {
});
});
describe('BrazilianPhoneNowebPlugin - local contact store tier', () => {
describe('BrazilianPhoneNumbers NOWEB - local contact store tier', () => {
it('resolves via the contact store with no WhatsApp lookup', async () => {
const { session } = buildPlugin({ pluginClass: BrazilianPhoneNowebPlugin });
const { session } = buildPlugin({ pluginClass: PhoneNumbersNowebPlugin });
stubLookup(session, { numberExists: false });
session.store = {
getContactById: jest.fn(async (jid: string) => {
@@ -457,7 +414,7 @@ describe('BrazilianPhoneNowebPlugin - local contact store tier', () => {
});
it('falls through to the WhatsApp lookup when the store has no match', async () => {
const { session } = buildPlugin({ pluginClass: BrazilianPhoneNowebPlugin });
const { session } = buildPlugin({ pluginClass: PhoneNumbersNowebPlugin });
stubLookup(session, {
numberExists: true,
chatId: '558591203123@c.us',
@@ -1,5 +1,8 @@
import {
extractPhoneDigits,
shouldSkipPhoneNormalization,
} from '@waha/apps/phone-numbers/utils/phone';
import {
generateBrazilMobileLookupCandidates,
isBrazilCountryCode,
isBrazilLandline,
@@ -8,13 +11,12 @@ import {
needsBrazilWhatsAppLookup,
normalizeBrazilMobileForSendDigits,
normalizeBrazilTollFreeDigits,
shouldSkipBrazilPhoneNormalization,
} from './brPhone';
} from './BrazilianPhoneNumberRule';
describe('brPhone', () => {
describe('BrazilianPhoneNumberRule', () => {
it('skips groups and lids', () => {
expect(shouldSkipBrazilPhoneNormalization('123@g.us')).toBe(true);
expect(shouldSkipBrazilPhoneNormalization('123@lid')).toBe(true);
expect(shouldSkipPhoneNormalization('123@g.us')).toBe(true);
expect(shouldSkipPhoneNormalization('123@lid')).toBe(true);
});
it('detects BR country code', () => {
@@ -1,10 +1,7 @@
import {
isJidBroadcast,
isJidGroup,
isJidMetaAI,
isJidNewsletter,
isLidUser,
} from '@waha/core/utils/jids';
PhoneNumberResolution,
PhoneNumberRule,
} from '@waha/apps/phone-numbers/rules/PhoneNumberRule';
const COUNTRY_CODE = '55';
const LANDLINE_FIRST_DIGIT = /^[2-5]/;
@@ -13,10 +10,6 @@ const MOBILE_FIRST_DIGIT = /^[6-9]/;
export const BR_PHONE_DDD_LOOKUP_MIN = 31;
export const BR_PHONE_DDD_LOOKUP_MAX = 99;
// Unverified best-guesses and confirmed-negatives live only in the in-memory
// cache with this short TTL, so a number registered later is re-checked soon.
export const BR_PHONE_NEGATIVE_CACHE_TTL_SECONDS = 10 * 60;
// 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;
@@ -34,39 +27,6 @@ export function isMalformedBrazilPhone(digits: string): boolean {
);
}
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;
}
@@ -172,7 +132,35 @@ export function generateBrazilMobileLookupCandidates(digits: string): string[] {
return [...new Set(candidates)];
}
export function getBrazilPhoneCacheKeys(digits: string): string[] {
const candidates = generateBrazilMobileLookupCandidates(digits);
return [...new Set([digits, ...candidates])];
/**
* Brazil: mobiles got a 9th digit, callers send either form - static rule for DDD < 31, lookup for the rest
*/
export class BrazilianPhoneNumberRule implements PhoneNumberRule {
matches(digits: string): boolean {
// Dialed toll-free has no country code
return isBrazilCountryCode(digits) || BR_TOLLFREE_DIALED.test(digits);
}
resolve(digits: string): PhoneNumberResolution | null {
// Before the length check - dialed '0800...' is shorter than a full number
const tollFree = normalizeBrazilTollFreeDigits(digits);
if (tollFree) {
return { candidates: [], fallback: tollFree };
}
if (isMalformedBrazilPhone(digits)) {
return null;
}
// Landlines are sent as is
if (!isBrazilMobile(digits)) {
return { candidates: [], fallback: digits };
}
const normalized = normalizeBrazilMobileForSendDigits(digits);
if (!needsBrazilWhatsAppLookup(digits)) {
return { candidates: [], fallback: normalized };
}
return {
candidates: generateBrazilMobileLookupCandidates(digits),
fallback: normalized,
};
}
}
@@ -1,169 +1,21 @@
import { Injectable } from '@nestjs/common';
import {
BrazilianPhoneNumbersAppConfig,
DEFAULT_PERSISTENT_TTL,
} from '@waha/apps/brazilian-phone-numbers/dto/config.dto';
import { BrazilianPhoneGowsPlugin } from '@waha/apps/brazilian-phone-numbers/plugins/BrazilianPhoneGowsPlugin';
import { BrazilianPhoneNowebPlugin } from '@waha/apps/brazilian-phone-numbers/plugins/BrazilianPhoneNowebPlugin';
import { BrazilianPhoneCorePlugin } from '@waha/apps/brazilian-phone-numbers/plugins/BrazilianPhoneCorePlugin';
import { BrazilianPhoneCacheRepository } from '@waha/apps/brazilian-phone-numbers/storage/BrazilianPhoneCacheRepository';
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
import { PluginOptions } from '@waha/core/abc/session.plugin';
import { AppDB } from '@waha/apps/app_sdk/storage/types';
import { DataStore } from '@waha/core/abc/DataStore';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { parseDurationMs } from '@waha/nestjs/validation/IsDuration';
import { WAHAEngine } from '@waha/structures/enums.dto';
import * as ms from 'ms';
// Engines with a local contact/LID store get their own tier; the rest run the
// core pipeline (cache + static rule + WhatsApp lookup).
const PLUGINS: Record<WAHAEngine, typeof BrazilianPhoneCorePlugin> = {
[WAHAEngine.WEBJS]: BrazilianPhoneCorePlugin,
[WAHAEngine.WPP]: BrazilianPhoneCorePlugin,
[WAHAEngine.GOWS]: BrazilianPhoneGowsPlugin,
[WAHAEngine.NOWEB]: BrazilianPhoneNowebPlugin,
};
import { BrazilianPhoneNumbersAppConfig } from '@waha/apps/brazilian-phone-numbers/dto';
import { BrazilianPhoneNumberRule } from '@waha/apps/brazilian-phone-numbers/rules/BrazilianPhoneNumberRule';
import { BrazilianPhoneNumbersCacheRepository } from '@waha/apps/brazilian-phone-numbers/storage/BrazilianPhoneNumbersCacheRepository';
import { PhoneNumberRule } from '@waha/apps/phone-numbers/rules/PhoneNumberRule';
import { PhoneNumbersAppServiceBase } from '@waha/apps/phone-numbers/services/PhoneNumbersAppServiceBase';
@Injectable()
export class BrazilianPhoneNumbersAppService implements IAppService {
constructor(private readonly resolver: UniqueAppResolver) {}
export class BrazilianPhoneNumbersAppService extends PhoneNumbersAppServiceBase<BrazilianPhoneNumbersAppConfig> {
protected readonly Repository = BrazilianPhoneNumbersCacheRepository;
validate(app: App<BrazilianPhoneNumbersAppConfig>): void {
void app;
return;
constructor(resolver: UniqueAppResolver) {
super(resolver);
}
async beforeCreated(app: App<BrazilianPhoneNumbersAppConfig>): Promise<void> {
void app;
return;
}
async beforeEnabled(
manager: SessionManager,
savedApp: App<BrazilianPhoneNumbersAppConfig>,
newApp: App<BrazilianPhoneNumbersAppConfig>,
): Promise<void> {
void manager;
void savedApp;
void newApp;
}
async beforeDisabled(
manager: SessionManager,
savedApp: App<BrazilianPhoneNumbersAppConfig>,
newApp: App<BrazilianPhoneNumbersAppConfig>,
): Promise<void> {
void manager;
void savedApp;
void newApp;
}
async beforeUpdated(
manager: SessionManager,
savedApp: App<BrazilianPhoneNumbersAppConfig>,
newApp: App<BrazilianPhoneNumbersAppConfig>,
): Promise<void> {
void manager;
void savedApp;
void newApp;
}
async beforeDeleted(
manager: SessionManager,
app: App<BrazilianPhoneNumbersAppConfig>,
): Promise<void> {
void manager;
void app;
}
async afterCreated(
manager: SessionManager,
app: App<BrazilianPhoneNumbersAppConfig>,
): Promise<void> {
void manager;
void app;
}
async beforeSessionDeleted(
manager: SessionManager,
app: App<BrazilianPhoneNumbersAppConfig>,
): Promise<void> {
void manager;
void app;
}
async purge(
manager: SessionManager,
app: App<BrazilianPhoneNumbersAppConfig>,
): Promise<void> {
await this.purgeCache(manager, app as AppDB);
}
/**
* Deletes all persistent cache entries and clears the in-memory tier when the session is running.
*/
async purgeCache(manager: SessionManager, app: AppDB): Promise<number> {
const knex = manager.store.getWAHADatabase();
const config = app.config as BrazilianPhoneNumbersAppConfig;
const ttlMs =
parseDurationMs(config?.cache?.persistentTtl) ??
ms(DEFAULT_PERSISTENT_TTL);
const repository = new BrazilianPhoneCacheRepository(knex, app.pk, ttlMs);
const deleted = await repository.purge();
this.resolver.getPlugin(app, BrazilianPhoneCorePlugin)?.clearMemoryCache();
return deleted;
}
async enrich(
manager: SessionManager,
app: App<BrazilianPhoneNumbersAppConfig>,
): Promise<void> {
void manager;
void app;
}
plugins(
app: App<BrazilianPhoneNumbersAppConfig>,
session: WhatsappSession,
store?: DataStore,
): PluginOptions[] {
const config = app.config ?? new BrazilianPhoneNumbersAppConfig();
let repository: BrazilianPhoneCacheRepository | null = null;
const persistent = config.cache?.persistent ?? true;
const appPk = (app as AppDB).pk;
if (persistent && store && appPk) {
const ttlMs =
parseDurationMs(config.cache?.persistentTtl) ??
ms(DEFAULT_PERSISTENT_TTL);
repository = new BrazilianPhoneCacheRepository(
store.getWAHADatabase(),
appPk,
ttlMs,
);
}
const BrazilianPhonePlugin = PLUGINS[session.engine];
return [BrazilianPhonePlugin.with(config, { repository: repository })];
}
beforeSessionStart(
app: App<BrazilianPhoneNumbersAppConfig>,
session: WhatsappSession,
): void {
void app;
void session;
return;
}
afterSessionStart(
app: App<BrazilianPhoneNumbersAppConfig>,
session: WhatsappSession,
): void {
void app;
void session;
return;
protected rules(config: BrazilianPhoneNumbersAppConfig): PhoneNumberRule[] {
void config;
return [new BrazilianPhoneNumberRule()];
}
}
@@ -0,0 +1,5 @@
import { PhoneNumbersCacheRepository } from '@waha/apps/phone-numbers/storage/PhoneNumbersCacheRepository';
export class BrazilianPhoneNumbersCacheRepository extends PhoneNumbersCacheRepository {
static tableName = 'app_brazilian_phone_numbers_cache';
}
@@ -13,6 +13,7 @@ import { ChatWootQueueService } from '@waha/apps/chatwoot/services/ChatWootQueue
import { SessionManager } from '@waha/core/abc/manager.abc';
import { AppRepository } from '@waha/apps/app_sdk/storage/AppRepository';
import { CommandPrefix } from '@waha/apps/chatwoot/cli';
import { IsExternalEcho } from '@waha/apps/chatwoot/api/webhook.guards';
@Controller('webhooks/chatwoot/')
export class ChatwootWebhookController {
@@ -41,8 +42,13 @@ export class ChatwootWebhookController {
}
const isCommandsChat = IsCommandsChat(body);
// Ignore private notes (most of them)
const deleted = body?.content_attributes?.deleted;
// Ignore messages that came from WhatsApp, do not send them back
if (IsExternalEcho(body) && !deleted) {
return { success: true };
}
// Ignore private notes (most of them)
if (body.private) {
// Ignore any private note in commands chats
if (isCommandsChat) {
+15
View File
@@ -0,0 +1,15 @@
import { conversation_message_create } from '@figuro/chatwoot-sdk';
/**
* Mark a message WAHA creates from a WhatsApp message, so the webhook does not send it back
*/
export function SetExternalEcho(body: conversation_message_create) {
body.content_attributes = { ...body.content_attributes, external_echo: true };
}
/**
* Message was created by WAHA from a WhatsApp message, not by an agent
*/
export function IsExternalEcho(body: any): boolean {
return Boolean(body?.content_attributes?.external_echo);
}
+10
View File
@@ -14,6 +14,7 @@ import {
import { ContactsPullOptions } from '@waha/apps/chatwoot/consumers/task/contacts.pull';
import { JobsOptions } from 'bullmq';
import { JobDataTimeout } from '@waha/apps/app_sdk/AppConsumer';
import { NameUpdateMode } from '@waha/apps/chatwoot/client/ContactService';
export function AddContactsCommand(program: Command, ctx: CommandContext) {
const l = ctx.l;
@@ -39,6 +40,14 @@ export function AddContactsCommand(program: Command, ctx: CommandContext) {
.choices(['if-missing', 'update'])
.preset('if-missing'),
)
.addOption(
new Option(
'-n, --update-names <mode>',
l.r('cli.cmd.contacts.pull.option.update-names'),
)
.choices(Object.values(NameUpdateMode))
.default(NameUpdateMode.NO),
)
.option('-g, --groups', l.r('cli.cmd.contacts.pull.option.groups'))
.option('-l, --lids', l.r('cli.cmd.contacts.pull.option.lids'))
.option(
@@ -92,6 +101,7 @@ export function AddContactsCommand(program: Command, ctx: CommandContext) {
batch: opts.batch,
progress: opts.progress,
avatar: opts.avatar,
updateNames: opts.updateNames,
attributes: opts.attributes,
contacts: {
lids: opts.lids,
@@ -24,6 +24,11 @@ import { AttributeKey } from '@waha/apps/chatwoot/const';
export interface ContactInfo {
ChatId(): string;
/**
* Reuse an already fetched WhatsApp contact instead of requesting it again
*/
SetFetchedContact(contact: any): void;
/**
* Linked id - @lid, null when unknown
*/
@@ -39,6 +44,16 @@ export interface ContactInfo {
*/
PhoneNumberE164(): Promise<string | null>;
/**
* Name saved in the phone book, null when not saved
*/
SavedName(): Promise<string | null>;
/**
* Name the contact set for themselves, null when unknown
*/
PushName(): Promise<string | null>;
AvatarUrl(): Promise<string | null>;
Attributes(): Promise<any>;
@@ -1,4 +1,7 @@
import { sanitizeName } from '@waha/apps/chatwoot/client/ContactService';
import {
IsRawName,
sanitizeName,
} from '@waha/apps/chatwoot/client/ContactService';
describe('sanitizeName', () => {
it('should return the same name', () => {
@@ -21,3 +24,36 @@ describe('sanitizeName', () => {
expect(sanitizeName(name)).toBe(name);
});
});
describe('IsRawName', () => {
const placeholders = [
'11111111111@c.us',
'11111111111@c.us',
'011111111111111@lid',
'+11111111111',
'+11111111111',
'Johnny',
];
const cases: Array<{ name: string; expected: boolean }> = [
{ name: '', expected: true },
{ name: ' ', expected: true },
{ name: '11111111111@c.us', expected: true },
{ name: '11111111111', expected: true },
{ name: '+11111111111', expected: true },
{ name: '011111111111111@lid', expected: true },
{ name: '011111111111111', expected: true },
{ name: 'Johnny', expected: true },
{ name: ' johnny ', expected: true },
{ name: 'John Doe', expected: false },
{ name: 'John (Sales)', expected: false },
];
it.each(cases)('$name', ({ name, expected }) => {
expect(IsRawName(name, placeholders)).toBe(expected);
});
it('treats a nameless contact with no ids as placeholder', () => {
expect(IsRawName('', [null, undefined])).toBe(true);
expect(IsRawName('John', [null, undefined])).toBe(false);
});
});
@@ -26,6 +26,43 @@ export enum AvatarUpdateMode {
ALWAYS,
}
export enum NameUpdateMode {
// skip name updates, only new contacts get a name (phone book, push name or phone number)
NO = 'no',
// keep names set by hand, replace raw ones (phone number, push name) from the phone book
IF_RAW = 'if-raw',
// the phone book is the source of truth, overwrite any name
ALWAYS = 'always',
}
/**
* Raw name - empty or one of the ids the contact was created with, nobody typed it by hand
*/
export function IsRawName(
name: string,
placeholders: Array<string | null | undefined>,
): boolean {
const current = normalizeName(name);
if (!current) {
return true;
}
for (const placeholder of placeholders) {
if (!placeholder) {
continue;
}
const value = normalizeName(placeholder);
const bare = value.replace(/@.*$/, '').replace(/^\+/, '');
if (current === value || current === bare || current === `+${bare}`) {
return true;
}
}
return false;
}
function normalizeName(name: string): string {
return name.trim().toLowerCase();
}
export function sanitizeName(name: string) {
// 255 chars max
const limit = 255;
@@ -185,6 +222,37 @@ export class ContactService {
return true;
}
/**
* Set the contact name, returns true when it changed
*/
public async updateName(
contact: ContactResponse,
name: string,
): Promise<boolean> {
name = sanitizeName(name);
const current = contact.data.name ?? '';
if (!name || current.trim() === name.trim()) {
return false;
}
try {
await this.accountAPI.contacts.update({
id: contact.data.id,
accountId: this.config.accountId,
data: { name: name },
});
} catch (err) {
this.logger.warn(
`Error updating name for contact.id: ${contact.data.id} - ${err}`,
);
return false;
}
this.logger.info(
`Renamed contact.id: ${contact.data.id}: '${current}' => '${name}'`,
);
contact.data.name = name;
return true;
}
public async create(
chatId: string,
payload: public_contact_create_update_payload,
+16 -1
View File
@@ -1,6 +1,7 @@
import ChatwootClient from '@figuro/chatwoot-sdk';
import type { conversation_message_create } from '@figuro/chatwoot-sdk/dist/models/conversation_message_create';
import { MessageType } from '@waha/apps/chatwoot/client/types';
import type { conversation_message_update } from '@figuro/chatwoot-sdk/dist/models/conversation_message_update';
import { MessageStatus, MessageType } from '@waha/apps/chatwoot/client/types';
export class Conversation {
public onError: (e: any) => void;
@@ -43,6 +44,20 @@ export class Conversation {
return this.send(data);
}
/**
* Update message status (sent/delivered/read/failed)
*/
public async updateMessageStatus(messageId: number, status: MessageStatus) {
// The SDK model has no "status" field, but the endpoint accepts only status (and external_error)
const data = { status: status } as unknown as conversation_message_update;
return this.accountAPI.messages.update({
accountId: this.accountId,
conversationId: this.conversationId,
messageId: messageId,
data: data,
});
}
public async activity(text: string) {
const data: conversation_message_create = {
content: text,
+7
View File
@@ -36,6 +36,13 @@ export enum MessageType {
ACTIVITY = 'activity',
}
export enum MessageStatus {
SENT = 'sent',
DELIVERED = 'delivered',
READ = 'read',
FAILED = 'failed',
}
export enum CustomAttributeType {
TEXT = 0,
NUMBER = 1,
@@ -38,6 +38,7 @@ import {
} from '@waha/apps/chatwoot/dto/config.dto';
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
import { isJidGroup } from '@waha/core/utils/jids';
import { MessageStatusService } from '@waha/apps/chatwoot/services/MessageStatusService';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mime = require('mime-types');
@@ -65,6 +66,7 @@ export class ChatWootInboxMessageCreatedConsumer extends ChatWootInboxMessageCon
session,
container.ChatWootConfig(),
container.Locale(),
container.MessageStatusService(),
);
return await handler.handle(body);
}
@@ -77,6 +79,7 @@ export class MessageHandler {
private session: WAHASessionAPI,
private config: ChatWootConfig,
private l: Locale,
private statusService: MessageStatusService,
) {}
async handle(body: any) {
@@ -114,6 +117,7 @@ export class MessageHandler {
// Send text (Part 1 if present)
const attachments = message.attachments || [];
const sendText = content && attachments.length !== 1;
const parts = attachments.length + (sendText ? 1 : 0);
if (sendText) {
part += 1; // Text is the first possible part
const exists = await this.getMapping(message, part);
@@ -129,7 +133,7 @@ export class MessageHandler {
});
const msg = await this.sendTextMessage(chatId, text, replyTo, mentions);
results.push(msg);
await this.saveMapping(message, msg, part);
await this.saveMapping(message, msg, part, parts);
this.logger.info(`Text message sent: ${msg.id}`);
}
}
@@ -155,20 +159,42 @@ export class MessageHandler {
`File message sent: ${msg.id} - ${file.data_url} - ${file.file_type}`,
);
results.push(msg);
await this.saveMapping(message, msg, part);
await this.saveMapping(message, msg, part, parts);
}
if (this.config.conversations.syncMessageStatus) {
await this.syncStatus(message, parts);
}
return results;
}
/**
* Push acks that arrived before the last part was mapped, best effort
*/
private async syncStatus(message: any, parts: number) {
try {
await this.statusService.sync({
conversation_id: message.conversation.id,
message_id: message.id,
parts: parts,
});
} catch (err) {
this.logger.warn(
`ChatWoot => WhatsApp: error syncing status for Chatwoot message ${message.id}: ${err}`,
);
}
}
private async saveMapping(
chatwootMessage: any,
whatsappMessage: any,
part: number,
parts: number,
): Promise<void> {
const chatwoot: Omit<ChatwootMessage, 'id'> = {
timestamp: new Date(chatwootMessage.created_at),
conversation_id: chatwootMessage.conversation.id,
message_id: chatwootMessage.id,
parts: parts,
};
const whatsapp = EngineHelper.WhatsAppMessageKeys(whatsappMessage);
await this.mappingService.map(chatwoot, whatsapp, part);
@@ -35,6 +35,7 @@ export class ChatWootInboxMessageUpdatedConsumer extends ChatWootInboxMessageCon
session,
container.ChatWootConfig(),
container.Locale(),
container.MessageStatusService(),
);
return await handler.handle(body);
}
@@ -44,5 +44,7 @@ export class MessageCleanupConsumer extends ChatWootScheduledConsumer {
.MessageMappingService()
.cleanup(removeAfter);
logger.info(`Removed ${removed} mappings for messages`);
const acks = await container.MessageStatusService().cleanup(removeAfter);
logger.info(`Removed ${acks} acks for messages`);
}
}
@@ -17,6 +17,8 @@ import { SortOrder } from '@waha/structures/pagination.dto';
import {
AvatarUpdateMode,
ContactService,
IsRawName,
NameUpdateMode,
} from '@waha/apps/chatwoot/client/ContactService';
import { Conversation } from '@waha/apps/chatwoot/client/Conversation';
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
@@ -34,6 +36,7 @@ export interface ContactsPullOptions {
batch: number;
progress: number | null;
avatar: null | 'if-missing' | 'update';
updateNames: NameUpdateMode;
attributes: boolean;
contacts: {
groups: boolean;
@@ -88,6 +91,7 @@ export class TaskContactsPullConsumer extends ChatWootTaskConsumer {
interface Progress {
created: number;
updated: number;
renamed: number;
skipped: number;
errors: number;
avatar: {
@@ -104,6 +108,7 @@ function total(progress: Progress) {
const NullProgress: Progress = {
created: 0,
updated: 0,
renamed: 0,
skipped: 0,
errors: 0,
avatar: {
@@ -201,12 +206,13 @@ class ContactsPullHandler {
}
this.logger.debug(`Pulling ${chatId}...`);
const result = await this.pullOneContact(options, chatId);
const result = await this.pullOneContact(options, contact);
progress.created += result.created;
progress.updated += result.updated;
progress.renamed += result.renamed;
progress.avatar.updated += result.avatar.updated;
this.logger.info(
`Contact ${chatId}: created=${result.created}, updated=${result.updated}, avatar.updated=${result.avatar.updated}`,
`Contact ${chatId}: created=${result.created}, updated=${result.updated}, renamed=${result.renamed}, avatar.updated=${result.avatar.updated}`,
);
} catch (e) {
this.logger.error(`Error pulling contact ${contact.id}: ${e}`);
@@ -220,11 +226,47 @@ class ContactsPullHandler {
await this.activity.completed(progress);
}
private async pullOneContact(options: ContactsPullOptions, chatId: string) {
// Contact
const contactInfo = WhatsAppContactInfo(this.session, chatId, this.l);
private async pullOneContact(options: ContactsPullOptions, contact: any) {
const contactInfo = WhatsAppContactInfo(this.session, contact.id, this.l);
contactInfo.SetFetchedContact(contact);
let [cwContact, created] =
await this.contactService.findOrCreateContact(contactInfo);
// Name
let renamed = false;
if (!created) {
switch (options.updateNames) {
case NameUpdateMode.ALWAYS: {
const name = await contactInfo.SavedName();
if (name) {
renamed = await this.contactService.updateName(cwContact, name);
}
break;
}
case NameUpdateMode.IF_RAW: {
const name = await contactInfo.SavedName();
if (!name) {
break;
}
// Replace only raw names, nobody typed them by hand
const placeholders = [
contactInfo.ChatId(),
await contactInfo.JidId(),
await contactInfo.LidId(),
await contactInfo.PhoneNumberE164(),
cwContact.data.phone_number,
await contactInfo.PushName(),
];
const current = cwContact.data.name ?? '';
if (!IsRawName(current, placeholders)) {
break;
}
renamed = await this.contactService.updateName(cwContact, name);
break;
}
}
}
// Attributes
if (options.attributes) {
const attributes = await contactInfo.Attributes();
@@ -233,6 +275,7 @@ class ContactsPullHandler {
attributes,
);
}
// Avatar
let avatarUpdated = false;
switch (options.avatar) {
@@ -251,9 +294,11 @@ class ContactsPullHandler {
);
break;
}
return {
created: created ? 1 : 0,
updated: created ? 0 : 1,
renamed: renamed ? 1 : 0,
avatar: {
updated: avatarUpdated ? 1 : 0,
},
@@ -294,6 +294,7 @@ class MessagesPullHandler {
this.session,
container.Locale(),
container.WAHASelf(),
container.OutgoingMode(),
);
handler.force = options.force;
+37 -5
View File
@@ -36,10 +36,11 @@ import { EngineHelper } from '@waha/apps/chatwoot/waha';
import { EnsureSeconds } from '@waha/utils/timehelper';
import { CHATWOOT_MESSAGE_CALENDAR_THRESHOLD_SECONDS } from '@waha/apps/chatwoot/env';
import {
ChatWootAppConfig,
ChatWootConfig,
ChatWootOutgoingMode,
} from '@waha/apps/chatwoot/dto/config.dto';
import { clearContent } from '@waha/apps/chatwoot/consumers/utils';
import { SetExternalEcho } from '@waha/apps/chatwoot/api/webhook.guards';
export function ListenEventsForChatWoot(config: ChatWootConfig) {
const events = [
@@ -52,7 +53,8 @@ export function ListenEventsForChatWoot(config: ChatWootConfig) {
WAHAEvents.CALL_ACCEPTED,
WAHAEvents.CALL_REJECTED,
];
if (config.conversations.markAsRead) {
const conversations = config.conversations;
if (conversations.markAsRead || conversations.syncMessageStatus) {
events.push(WAHAEvents.MESSAGE_ACK);
}
return events;
@@ -235,6 +237,7 @@ export abstract class MessageBaseHandler<
protected session: WAHASessionAPI,
protected l: Locale,
protected waha: WAHASelf,
protected outgoingMode: ChatWootOutgoingMode,
) {}
protected abstract getMessage(
@@ -402,10 +405,35 @@ export abstract class MessageBaseHandler<
},
);
const private_ = message.private ?? payload.fromMe;
const type = private_ ? MessageType.OUTGOING : MessageType.INCOMING;
// Send the message as a private note or as a regular message?
let private_ = message.private;
if (private_ === undefined) {
switch (this.outgoingMode) {
case ChatWootOutgoingMode.MESSAGE:
// Regular outgoing message, not a note
private_ = false;
break;
case ChatWootOutgoingMode.PRIVATE_NOTE:
default: // no value - old config, treat it as PRIVATE_NOTE
// Mark my messages as private notes
private_ = Boolean(payload.fromMe);
break;
}
}
// Send the message as incoming or outgoing?
let type: MessageType = MessageType.INCOMING;
if (payload.fromMe) {
// Any message from me is outgoing, private or not
type = MessageType.OUTGOING;
}
if (private_) {
// Private notes can only be sent as outgoing messages
type = MessageType.OUTGOING;
}
content = this.finalizeContent(content, payload);
return {
const body: conversation_message_create = {
content: content,
message_type: type,
private: private_,
@@ -414,6 +442,10 @@ export abstract class MessageBaseHandler<
in_reply_to: replyTo,
},
};
if (payload.fromMe) {
SetExternalEcho(body);
}
return body;
}
async getReplyToChatWootMessageID(
@@ -59,6 +59,7 @@ export class WAHACallAcceptedConsumer extends ChatWootWAHABaseConsumer {
new WAHASessionAPI(event.session, container.WAHASelf()),
locale,
container.WAHASelf(),
container.OutgoingMode(),
);
const msg = BuildCallMessagePayload(
event.payload,
@@ -60,6 +60,7 @@ export class WAHACallReceivedConsumer extends ChatWootWAHABaseConsumer {
new WAHASessionAPI(event.session, container.WAHASelf()),
locale,
container.WAHASelf(),
container.OutgoingMode(),
);
const msg = BuildCallMessagePayload(
event.payload,
@@ -59,6 +59,7 @@ export class WAHACallRejectedConsumer extends ChatWootWAHABaseConsumer {
new WAHASessionAPI(event.session, container.WAHASelf()),
locale,
container.WAHASelf(),
container.OutgoingMode(),
);
const msg = BuildCallMessagePayload(
event.payload,
@@ -14,12 +14,23 @@ import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import { WAHAEvents } from '@waha/structures/enums.dto';
import { WAHAWebhookMessageAck } from '@waha/structures/webhooks.dto';
import {
WAHAWebhookMessageAck,
WAMessageAckBody,
} from '@waha/structures/webhooks.dto';
import { Job } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { ShouldMarkAsReadInChatWoot } from '@waha/apps/chatwoot/consumers/waha/message.ack.utils';
import { MultipleErrors } from '@waha/utils/errors';
import {
ShouldMarkAsReadInChatWoot,
ShouldProcessAckInChatWoot,
} from '@waha/apps/chatwoot/consumers/waha/message.ack.utils';
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
import { MessageMappingService } from '@waha/apps/chatwoot/storage';
import {
ChatwootMessage,
MessageMappingService,
} from '@waha/apps/chatwoot/storage';
import { MessageStatusService } from '@waha/apps/chatwoot/services/MessageStatusService';
@Processor(QueueName.WAHA_MESSAGE_ACK, { concurrency: JOB_CONCURRENCY })
export class WAHAMessageAckConsumer extends ChatWootWAHABaseConsumer {
@@ -32,7 +43,7 @@ export class WAHAMessageAckConsumer extends ChatWootWAHABaseConsumer {
}
ShouldProcess(event: any): boolean {
return ShouldMarkAsReadInChatWoot(event);
return ShouldProcessAckInChatWoot(event);
}
GetChatId(event: WAHAWebhookMessageAck): string {
@@ -46,13 +57,20 @@ export class WAHAMessageAckConsumer extends ChatWootWAHABaseConsumer {
const container = await this.DIContainer(job, job.data.app);
const event = job.data.event as WAHAWebhookMessageAck;
const session = new WAHASessionAPI(event.session, container.WAHASelf());
const conversations = container.ChatWootConfig().conversations;
const config: MessageAckHandlerConfig = {
markAsRead: conversations.markAsRead,
syncMessageStatus: conversations.syncMessageStatus,
};
const handler = new MessageAckHandler(
config,
container.ContactConversationService(),
container.MessageMappingService(),
container.Logger(),
info,
session,
container.Locale(),
container.MessageStatusService(),
);
try {
await handler.handle(event);
@@ -64,26 +82,72 @@ export class WAHAMessageAckConsumer extends ChatWootWAHABaseConsumer {
}
}
interface MessageAckHandlerConfig {
markAsRead: boolean;
syncMessageStatus: boolean;
}
class MessageAckHandler {
constructor(
private readonly config: MessageAckHandlerConfig,
private readonly contactConversationService: ContactConversationService,
protected mappingService: MessageMappingService,
private readonly logger: ILogger,
private readonly info: IMessageInfo,
private readonly session: WAHASessionAPI,
private readonly locale: Locale,
private readonly statusService: MessageStatusService,
) {}
async handle(event: WAHAWebhookMessageAck): Promise<void> {
const payload = event.payload;
const promises: Promise<void>[] = [];
if (this.config.syncMessageStatus) {
promises.push(this.syncMessageStatus(event));
}
if (this.config.markAsRead && ShouldMarkAsReadInChatWoot(event)) {
promises.push(this.markConversationAsRead(event));
}
const results = await Promise.allSettled(promises);
const errors = results
.filter((result) => result.status === 'rejected')
.map((result: PromiseRejectedResult) => result.reason);
if (errors.length > 0) {
throw new MultipleErrors(errors);
}
}
/**
* No chatwoot message - old message or some service ack, nothing to do
*/
private async getChatWootMessage(
payload: WAMessageAckBody,
): Promise<ChatwootMessage | null> {
const key = parseMessageIdSerialized(payload.id);
const chatwoot = await this.mappingService.getChatWootMessage({
return this.mappingService.getChatWootMessage({
chat_id: null,
message_id: key.id,
});
// No chatwoot message found, so we don't mark it as read
// Filters out old messages and some service ack messages
}
private async syncMessageStatus(event: WAHAWebhookMessageAck) {
const payload = event.payload;
const key = parseMessageIdSerialized(payload.id);
// Save the ack first, then look up the mapping.
await this.statusService.record(
key.id,
payload.ack,
new Date(event.timestamp),
);
const chatwoot = await this.getChatWootMessage(payload);
if (!chatwoot) {
return;
}
await this.statusService.sync(chatwoot);
}
private async markConversationAsRead(event: WAHAWebhookMessageAck) {
const payload = event.payload;
const chatwoot = await this.getChatWootMessage(payload);
if (!chatwoot) {
return;
}
@@ -1,9 +1,13 @@
import type { WAHAEngine, WAHAEvents } from '@waha/structures/enums.dto';
import { ShouldMarkAsReadInChatWoot } from '@waha/apps/chatwoot/consumers/waha/message.ack.utils';
import {
ShouldMarkAsReadInChatWoot,
ShouldProcessAckInChatWoot,
} from '@waha/apps/chatwoot/consumers/waha/message.ack.utils';
interface TestParam {
name: string;
expected: boolean;
ShouldMarkAsReadInChatWoot: boolean;
ShouldProcessAckInChatWoot: boolean;
GOWS: any;
NOWEB: any;
WEBJS: any;
@@ -14,10 +18,11 @@ interface TestParam {
* Participant - 11111111111 (lid - 011111111111111)
* Group - 222222222222222222
*/
const ShouldMarkAsReadCases: TestParam[] = [
const TestCases: TestParam[] = [
{
name: 'Participant read My message (READ ack)',
expected: true,
ShouldMarkAsReadInChatWoot: true,
ShouldProcessAckInChatWoot: true,
GOWS: {
id: 'evt_00000000000000000000000000',
session: 'default',
@@ -131,7 +136,8 @@ const ShouldMarkAsReadCases: TestParam[] = [
},
{
name: 'Participant received My message (DEVICE ack)',
expected: false,
ShouldMarkAsReadInChatWoot: false,
ShouldProcessAckInChatWoot: true,
GOWS: {
id: 'evt_00000000000000000000000000',
session: 'default',
@@ -243,7 +249,8 @@ const ShouldMarkAsReadCases: TestParam[] = [
},
{
name: 'Me read Participant message on another device (READ ack)',
expected: false,
ShouldMarkAsReadInChatWoot: false,
ShouldProcessAckInChatWoot: false,
GOWS: {
id: 'evt_00000000000000000000000000',
session: 'default',
@@ -315,7 +322,8 @@ const ShouldMarkAsReadCases: TestParam[] = [
},
{
name: 'Participant read My Group Message (READ ack)',
expected: false,
ShouldMarkAsReadInChatWoot: false,
ShouldProcessAckInChatWoot: false,
GOWS: {
id: 'evt_00000000000000000000000000',
session: 'default',
@@ -446,15 +454,16 @@ const ShouldMarkAsReadCases: TestParam[] = [
},
];
const engines = ['GOWS', 'NOWEB', 'WEBJS'];
describe('ShouldMarkAsReadInChatWoot', () => {
const engines = ['GOWS', 'NOWEB', 'WEBJS'];
for (const engine of engines) {
describe(engine, () => {
for (const param of ShouldMarkAsReadCases) {
const name = `[${param.expected}] ${param.name}`;
for (const param of TestCases) {
const name = `[${param.ShouldMarkAsReadInChatWoot}] ${param.name}`;
const event = param[engine];
const testfn = event ? test : test.skip;
const expected = param.expected;
const expected = param.ShouldMarkAsReadInChatWoot;
testfn(name, () => {
// Test
const result = ShouldMarkAsReadInChatWoot(event);
@@ -464,3 +473,21 @@ describe('ShouldMarkAsReadInChatWoot', () => {
});
}
});
describe('ShouldProcessAckInChatWoot', () => {
for (const engine of engines) {
describe(engine, () => {
for (const param of TestCases) {
const name = `[${param.ShouldProcessAckInChatWoot}] ${param.name}`;
const event = param[engine];
const testfn = event ? test : test.skip;
const expected = param.ShouldProcessAckInChatWoot;
testfn(name, () => {
// Test
const result = ShouldProcessAckInChatWoot(event);
expect(result).toBe(expected);
});
}
});
}
});
@@ -1,13 +1,21 @@
import type { WAHAWebhookMessageAck } from '@waha/structures/webhooks.dto';
import { isJidCusFormat } from '@waha/utils/wa';
import { WAMessageAck } from '@waha/structures/enums.dto';
import { isLidUser, toCusFormat } from '@waha/core/utils/jids';
import { isLidUser } from '@waha/core/utils/jids';
import { EngineHelper } from '@waha/apps/chatwoot/waha';
export function ShouldMarkAsReadInChatWoot(
const ACKS_TO_PROCESS = new Set([
WAMessageAck.DEVICE,
WAMessageAck.READ,
WAMessageAck.PLAYED,
]);
/**
* Acks for OUR messages in DMs - the ones that can change Chatwoot message status
*/
export function ShouldProcessAckInChatWoot(
event: WAHAWebhookMessageAck,
): boolean {
// Mark as seen only if it's DM
// Ignore groups and other multiple participants chats
const chatId = EngineHelper.ChatID(event.payload);
if (!isJidCusFormat(chatId) && !isLidUser(chatId)) {
@@ -15,18 +23,27 @@ export function ShouldMarkAsReadInChatWoot(
}
const payload = event.payload;
// Only READ and PLAYED
const read = payload.ack === WAMessageAck.READ;
const played = payload.ack == WAMessageAck.PLAYED;
if (!read && !played) {
if (!ACKS_TO_PROCESS.has(payload.ack)) {
return false;
}
// Only process when OUR message (fromMe: true) was read by recipient
// Only process when OUR message (fromMe: true) was received or read by recipient
if (!payload.fromMe) {
return false;
}
// Mark ChatWoot conversation as read when recipient reads our message
return true;
}
/**
* Mark ChatWoot conversation as read when recipient reads our message
*/
export function ShouldMarkAsReadInChatWoot(
event: WAHAWebhookMessageAck,
): boolean {
if (!ShouldProcessAckInChatWoot(event)) {
return false;
}
// Only READ and PLAYED
const ack = event.payload.ack;
return ack === WAMessageAck.READ || ack === WAMessageAck.PLAYED;
}
@@ -65,6 +65,7 @@ export class WAHAMessageAnyConsumer extends ChatWootWAHABaseConsumer {
session,
container.Locale(),
container.WAHASelf(),
container.OutgoingMode(),
);
return await handler.handle(event.payload);
}
@@ -56,6 +56,7 @@ export class WAHAMessageEditedConsumer extends ChatWootWAHABaseConsumer {
session,
container.Locale(),
container.WAHASelf(),
container.OutgoingMode(),
);
return await handler.handle(event.payload);
}
@@ -60,6 +60,7 @@ export class WAHAMessageReactionConsumer extends ChatWootWAHABaseConsumer {
session,
container.Locale(),
container.WAHASelf(),
container.OutgoingMode(),
);
return await handler.handle(event.payload);
}
@@ -14,6 +14,10 @@ export class InboxContactInfo implements ContactInfo {
return INBOX_CONTACT_CHAT_ID;
}
SetFetchedContact(): void {
return;
}
async LidId(): Promise<string | null> {
return null;
}
@@ -26,6 +30,14 @@ export class InboxContactInfo implements ContactInfo {
return null;
}
async SavedName(): Promise<string | null> {
return null;
}
async PushName(): Promise<string | null> {
return null;
}
async AvatarUrl() {
return this.l.key(TKey.APP_INBOX_CONTACT_AVATAR_URL).render();
}
@@ -22,6 +22,9 @@ import { E164Parser } from '@waha/core/utils/PhoneJidNormalizer';
* Base WhatsApp contact info class
*/
abstract class ChatContactInfo implements ContactInfo {
// Already fetched WhatsApp contact, saves a request when known
protected contact: any = null;
constructor(
protected session: WAHASessionAPI,
protected chatId: string,
@@ -32,6 +35,10 @@ abstract class ChatContactInfo implements ContactInfo {
return this.chatId;
}
SetFetchedContact(contact: any) {
this.contact = contact;
}
async LidId(): Promise<string | null> {
return null;
}
@@ -44,6 +51,14 @@ abstract class ChatContactInfo implements ContactInfo {
return null;
}
async SavedName(): Promise<string | null> {
return null;
}
async PushName(): Promise<string | null> {
return null;
}
abstract AvatarUrl(): Promise<string | null>;
abstract Attributes(): Promise<any>;
@@ -91,10 +106,26 @@ class JidContactInfo extends ChatContactInfo {
return attributes;
}
@CacheAsync()
async fetchContact(): Promise<any> {
if (this.contact) {
return this.contact;
}
return await this.session.getContact(this.chatId).catch(() => null);
}
async SavedName(): Promise<string | null> {
const contact = await this.fetchContact();
return contact?.name || null;
}
async PushName(): Promise<string | null> {
const contact = await this.fetchContact();
return contact?.pushName || contact?.pushname || null;
}
async PublicContactCreate(): Promise<Contact> {
const contact: any = await this.session
.getContact(this.chatId)
.catch(() => null);
const contact: any = await this.fetchContact();
const name =
contact?.name || contact?.pushName || contact?.pushname || this.chatId;
const phoneNumberE164 = E164Parser.fromJid(this.chatId);
@@ -134,7 +165,9 @@ class LidContactInfo extends ChatContactInfo {
if (!pn) {
return null;
}
return new JidContactInfo(this.session, pn, this.locale);
const jid = new JidContactInfo(this.session, pn, this.locale);
jid.SetFetchedContact(this.contact);
return jid;
}
async LidId(): Promise<string | null> {
@@ -154,6 +187,32 @@ class LidContactInfo extends ChatContactInfo {
return await jid.PhoneNumberE164();
}
@CacheAsync()
async fetchContact(): Promise<any> {
if (this.contact) {
return this.contact;
}
return await this.session.getContact(this.chatId).catch(() => null);
}
async SavedName(): Promise<string | null> {
const jid = await this.jid();
if (jid) {
return await jid.SavedName();
}
const contact = await this.fetchContact();
return contact?.name || null;
}
async PushName(): Promise<string | null> {
const jid = await this.jid();
if (jid) {
return await jid.PushName();
}
const contact = await this.fetchContact();
return contact?.pushName || contact?.pushname || null;
}
async AvatarUrl(): Promise<string | null> {
const jid = await this.jid();
if (jid) {
@@ -179,9 +238,7 @@ class LidContactInfo extends ChatContactInfo {
if (jid) {
result = await jid.PublicContactCreate();
} else {
const contact: any = await this.session
.getContact(this.chatId)
.catch(() => null);
const contact: any = await this.fetchContact();
const name =
contact?.name || contact?.pushName || contact?.pushname || this.chatId;
result = {
+23
View File
@@ -10,6 +10,7 @@ import { ChatWootInboxAPI } from '@waha/apps/chatwoot/client/interfaces';
import {
ChatWootAppConfig,
ChatWootConfig,
ChatWootOutgoingMode,
DEFAULT_LOCALE,
LinkPreview,
} from '@waha/apps/chatwoot/dto/config.dto';
@@ -20,8 +21,10 @@ import {
ChatwootMessageRepository,
MessageMappingRepository,
MessageMappingService,
WhatsAppAckRepository,
WhatsAppMessageRepository,
} from '@waha/apps/chatwoot/storage';
import { MessageStatusService } from '@waha/apps/chatwoot/services/MessageStatusService';
import { Job } from 'bullmq';
import { Knex } from 'knex';
import { i18n } from '@waha/apps/chatwoot/i18n';
@@ -119,6 +122,10 @@ export class DIContainer {
);
}
public OutgoingMode(): ChatWootOutgoingMode {
return this.ChatWootConfig().conversations.outgoing;
}
@CacheSync()
public ConversationSelector() {
const config = this.ChatWootConfig();
@@ -185,6 +192,20 @@ export class DIContainer {
);
}
@CacheSync()
private WhatsAppAckRepository(): WhatsAppAckRepository {
return new WhatsAppAckRepository(this.Knex(), this.AppPk());
}
@CacheSync()
public MessageStatusService(): MessageStatusService {
return new MessageStatusService(
this.MessageMappingService(),
this.WhatsAppAckRepository(),
this.ContactConversationService(),
);
}
public ChatWootErrorReporter(job: Job): ChatWootErrorReporter {
return new ChatWootErrorReporter(this.Logger(), job, this.Locale());
}
@@ -229,6 +250,8 @@ export function ChatWootConfigDefaults(
sort: ConversationSort.created_newest,
status: null,
markAsRead: true,
syncMessageStatus: false,
outgoing: ChatWootOutgoingMode.PRIVATE_NOTE,
},
};
return lodash.defaultsDeep({}, config, defaults);
+25
View File
@@ -30,6 +30,11 @@ export enum LinkPreview {
HQ = 'HG',
}
export enum ChatWootOutgoingMode {
PRIVATE_NOTE = 'private-note',
MESSAGE = 'message',
}
export class ChatWootConversationsConfig {
@IsEnum(ConversationSort)
sort: ConversationSort;
@@ -46,6 +51,26 @@ export class ChatWootConversationsConfig {
@IsOptional()
@IsBoolean()
markAsRead?: boolean = true;
@ApiPropertyOptional({
description:
'Update Chatwoot message status (delivered/read) for messages sent from Chatwoot using WhatsApp acks. Disabled by default',
default: false,
})
@IsOptional()
@IsBoolean()
syncMessageStatus?: boolean = false;
@ApiPropertyOptional({
description:
'How to show messages sent from WhatsApp (not from ChatWoot): ' +
"'private-note' (default) or 'message' - a regular outgoing message, as if an agent sent it.",
enum: ChatWootOutgoingMode,
default: ChatWootOutgoingMode.PRIVATE_NOTE,
})
@IsOptional()
@IsEnum(ChatWootOutgoingMode)
outgoing?: ChatWootOutgoingMode = ChatWootOutgoingMode.PRIVATE_NOTE;
}
export interface ChatWootConfig {
+13 -22
View File
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
cli.cmd.contacts.pull.option.avatar: |-
اختر كيفية سحب الصور الرمزية: `skip` أو `if-missing` أو `update`.
cli.cmd.contacts.pull.option.update-names: |-
كيفية تحديث أسماء جهات الاتصال الموجودة: `no` - عدم تغيير الأسماء، جهات الاتصال الجديدة فقط تحصل على اسم (دفتر الهاتف أو push name أو رقم الهاتف). `if-raw` - الاحتفاظ بالأسماء المدخلة يدويًا، واستبدال الأسماء المؤقتة (رقم الهاتف، push name) من دفتر الهاتف. `always` - دفتر الهاتف هو المصدر الموثوق، استبدال أي اسم.
cli.cmd.contacts.pull.option.groups: |-
قم بتضمين جهات اتصال مجموعات واتساب في المزامنة.
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
**الخيارات العامة:**
task.contacts.status: |-
👤 سحب جهات الاتصال: **{{state}}**
- ✅ الناجحة: {{{progress.ok}}} جهة اتصال
- ⚪ تم تخطيها: {{{progress.skipped}}} جهة اتصال
- ⚠️ الأخطاء: {{{progress.errors}}} جهة اتصال
👤 جهات الاتصال: **{{state}}**
- 🆕 تم الإنشاء: {{{progress.created}}} جهة اتصال
- 🔄 تم التحديث: {{{progress.updated}}} جهة اتصال
- 🏷️ تمت إعادة التسمية: {{{progress.renamed}}} جهة اتصال
- ⚪ تم التخطي: {{{progress.skipped}}} جهة اتصال
- 🔴 أخطاء: {{{progress.errors}}} جهة اتصال
- 🖼️ تم تحديث الصورة الرمزية: {{{progress.avatar.updated}}} جهة اتصال
{{#job}}
⌛ الجدول الزمني:
- في قائمة الانتظار: {{{timestamp}}}
{{#processedOn}}
- وقت البدء: {{{processedOn}}}
{{/processedOn}}
{{#finishedOn}}
- وقت الانتهاء: {{{finishedOn}}}
{{/finishedOn}}
{{/job}}
{{#error}}
التفاصيل:
[{{{details.text}}}]({{{details.url}}})
{{/error}}
task.contacts.started: |-
جارٍ سحب جهات الاتصال...
task.contacts.progress: |-
سحب جهات الاتصال: ✅ {{progress.ok}}، ⚪ {{progress.skipped}}، ⚠️ {{progress.errors}}
👤⏳ جهات الاتصال: 🆕 {{progress.created}}، 🔄 {{progress.updated}}، 🏷️ {{progress.renamed}}، ⚪ {{progress.skipped}}، 🔴 {{progress.errors}}، 🖼️ {{progress.avatar.updated}}
app.help.reminder: |-
أرسل **help** للحصول على قائمة الأوامر المتاحة.
@@ -638,10 +631,8 @@ cli.cmd.contacts.pull.option.progress:
الإبلاغ عن التقدم كل عدد N من جهات الاتصال. القيمة 0 لتعطيل التقارير.
cli.cmd.contacts.pull.option.lids:
تضمين جهات اتصال واتساب LID (مجهولة) في المزامنة.
task.contacts.completed:
👤✅ تم إنهاء جهات الاتصال → 🆕 {{progress.created}}، 🔄 {{progress.updated}}،
⚪ {{progress.skipped}}، 🔴 {{progress.errors}}، 🖼️
{{progress.avatar.updated}}
task.contacts.completed: |-
👤✅ تم إنهاء جهات الاتصال → 🆕 {{progress.created}}، 🔄 {{progress.updated}}، 🏷️ {{progress.renamed}}، ⚪ {{progress.skipped}}، 🔴 {{progress.errors}}، 🖼️ {{progress.avatar.updated}}
task.messages.started: ⏳ جارٍ سحب الرسائل 💬 {{{chat}}} ⏱️ {{{period}}}...
task.messages.details:
أرسل "{{{prefix}}}messages status" للاطلاع على المزيد من التفاصيل.
+13 -21
View File
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
cli.cmd.contacts.pull.option.avatar: |-
কনট্যাক্টের অ্যাভাটার কীভাবে টানবেন নির্ধারণ করুন: `skip`, `if-missing`, অথবা `update`।
cli.cmd.contacts.pull.option.update-names: |-
বিদ্যমান কন্টাক্টের নাম কীভাবে আপডেট করবেন: `no` - নাম স্পর্শ করবেন না, শুধু নতুন কন্টাক্ট নাম পাবে (ফোন বুক, push name বা ফোন নম্বর)। `if-raw` - হাতে দেওয়া নাম রাখুন, প্লেসহোল্ডার (ফোন নম্বর, push name) ফোন বুক থেকে সমাধান করুন। `always` - ফোন বুকই সত্যের উৎস, যেকোনো নাম প্রতিস্থাপন করুন।
cli.cmd.contacts.pull.option.groups: |-
সিঙ্কে WhatsApp গ্রুপ কনট্যাক্ট অন্তর্ভুক্ত করুন।
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
**গ্লোবাল বিকল্পসমূহ:**
task.contacts.status: |-
👤 কনট্যাক্ট টানা: **{{state}}**
- ✅ সফল: {{{progress.ok}}} কনট্যাক্ট
- ⚪ এড়িয়ে গেছে: {{{progress.skipped}}} কনট্যাক্ট
- ⚠️ ত্রুটি: {{{progress.errors}}} কনট্যাক্ট
👤 কন্টাক্ট: **{{state}}**
- 🆕 তৈরি: {{{progress.created}}} কন্টাক্ট
- 🔄 আপডেট: {{{progress.updated}}} কন্টাক্ট
- 🏷️ নাম পরিবর্তিত: {{{progress.renamed}}} কন্টাক্ট
- ⚪ বাদ: {{{progress.skipped}}} কন্টাক্ট
- 🔴 ত্রুটি: {{{progress.errors}}} কন্টাক্ট
- 🖼️ অ্যাভাটার আপডেট: {{{progress.avatar.updated}}} কন্টাক্ট
{{#job}}
⌛ সময়রেখা:
- কিউ হয়েছে: {{{timestamp}}}
{{#processedOn}}
- শুরু হয়েছে: {{{processedOn}}}
{{/processedOn}}
{{#finishedOn}}
- শেষ হয়েছে: {{{finishedOn}}}
{{/finishedOn}}
{{/job}}
{{#error}}
বিস্তারিত:
[{{{details.text}}}]({{{details.url}}})
{{/error}}
task.contacts.started: |-
কনট্যাক্ট টানা হচ্ছে...
task.contacts.progress: |-
কনট্যাক্ট টানা: ✅ {{progress.ok}}, ⚪ {{progress.skipped}}, ⚠️ {{progress.errors}}
👤⏳ কন্টাক্ট: 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
app.help.reminder: |-
উপলব্ধ কমান্ডগুলির তালিকা পেতে **help** পাঠান।
@@ -643,9 +636,8 @@ cli.cmd.contacts.pull.option.progress:
প্রতি N কন্টাক্টে অগ্রগতি জানান। রিপোর্ট বন্ধ করতে 0 সেট করুন।
cli.cmd.contacts.pull.option.lids:
সিঙ্কে WhatsApp LID (অনামা) কন্টাক্ট অন্তর্ভুক্ত করুন।
task.contacts.completed:
👤✅ কন্টাক্ট সম্পন্ন → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪
{{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.contacts.completed: |-
👤✅ কন্টাক্ট সম্পন্ন → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.messages.started: ⏳ বার্তা টানা হচ্ছে 💬 {{{chat}}} ⏱️ {{{period}}}...
task.messages.details: আরও জানতে "{{{prefix}}}messages status" পাঠান।
task.messages.progress:
+12 -21
View File
@@ -197,6 +197,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
cli.cmd.contacts.pull.option.avatar: |-
Legen Sie fest, wie Avatare geladen werden: `skip`, `if-missing` oder `update`.
cli.cmd.contacts.pull.option.update-names: |-
Wie Namen vorhandener Kontakte aktualisiert werden: `no` - Namen nicht anfassen, nur neue Kontakte erhalten einen (Telefonbuch, Push-Name oder Telefonnummer). `if-raw` - manuell gesetzte Namen behalten, Platzhalter (Telefonnummer, Push-Name) aus dem Telefonbuch auflösen. `always` - das Telefonbuch ist die Quelle der Wahrheit, jeden Namen ersetzen.
cli.cmd.contacts.pull.option.groups: |-
WhatsApp-Gruppenkontakte in die Synchronisierung einbeziehen.
@@ -248,32 +251,22 @@ cli.help.globalOptions.title: |-
**Globale Optionen:**
task.contacts.status: |-
👤 Kontakte abrufen: **{{state}}**
- ✅ Erfolgreich: {{{progress.ok}}} Kontakte
👤 Kontakte: **{{state}}**
- 🆕 Erstellt: {{{progress.created}}} Kontakte
- 🔄 Aktualisiert: {{{progress.updated}}} Kontakte
- 🏷️ Umbenannt: {{{progress.renamed}}} Kontakte
- ⚪ Übersprungen: {{{progress.skipped}}} Kontakte
- ⚠️ Fehler: {{{progress.errors}}} Kontakte
- 🔴 Fehler: {{{progress.errors}}} Kontakte
- 🖼️ Avatar aktualisiert: {{{progress.avatar.updated}}} Kontakte
{{#job}}
⌛ Zeitplan:
- Eingereiht: {{{timestamp}}}
{{#processedOn}}
- Start: {{{processedOn}}}
{{/processedOn}}
{{#finishedOn}}
- Ende: {{{finishedOn}}}
{{/finishedOn}}
{{/job}}
{{#error}}
Details:
[{{{details.text}}}]({{{details.url}}})
{{/error}}
task.contacts.started: |-
Kontakte werden abgerufen...
task.contacts.progress: |-
Kontakte abrufen: ✅ {{progress.ok}}, ⚪ {{progress.skipped}}, ⚠️ {{progress.errors}}
👤⏳ Kontakte: 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
app.help.reminder: |-
Senden Sie **help**, um die Liste der verfügbaren Befehle zu erhalten.
@@ -662,10 +655,8 @@ cli.cmd.contacts.pull.option.progress:
Meldet den Fortschritt nach N Kontakten. 0 deaktiviert die Meldungen.
cli.cmd.contacts.pull.option.lids:
Bezieht WhatsApp-LID-Kontakte (anonyme Kontakte) in die Synchronisierung ein.
task.contacts.completed:
👤✅ Kontakte abgeschlossen → 🆕 {{progress.created}}, 🔄
{{progress.updated}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️
{{progress.avatar.updated}}
task.contacts.completed: |-
👤✅ Kontakte abgeschlossen → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.messages.started:
⏳ Nachrichten werden abgerufen 💬 {{{chat}}} ⏱️ {{{period}}}...
task.messages.details:
+6 -2
View File
@@ -547,6 +547,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
cli.cmd.contacts.pull.option.avatar: |-
Choose how to pull avatars for contacts.
cli.cmd.contacts.pull.option.update-names: |-
How to update names of existing contacts: `no` - do not touch names, only new contacts get one (phone book, push name or phone number). `if-raw` - keep names set by hand, replace raw names (phone number, push name) from the phone book. `always` - the phone book is the source of truth, replace any name.
cli.cmd.contacts.pull.option.groups: |-
Include WhatsApp group contacts in the sync.
@@ -604,15 +607,16 @@ task.contacts.started: |-
👤⏳ Pulling contacts... Send "contacts status" for more details.
task.contacts.progress: |-
👤⏳ Contacts: 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
👤⏳ Contacts: 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.contacts.completed: |-
👤✅ Contacts Completed → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
👤✅ Contacts Completed → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.contacts.status: |-
👤 Contacts: **{{state}}**
- 🆕 Created: {{{progress.created}}} contacts
- 🔄 Updated: {{{progress.updated}}} contacts
- 🏷️ Renamed: {{{progress.renamed}}} contacts
- ⚪ Skipped: {{{progress.skipped}}} contacts
- 🔴 Errors: {{{progress.errors}}} contacts
- 🖼️ Avatar Updated: {{{progress.avatar.updated}}} contacts
+12 -21
View File
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
cli.cmd.contacts.pull.option.avatar: |-
Elige cómo obtener los avatares de los contactos: `skip`, `if-missing` o `update`.
cli.cmd.contacts.pull.option.update-names: |-
Cómo actualizar los nombres de contactos existentes: `no` - no tocar los nombres, solo los contactos nuevos reciben uno (agenda, push name o número de teléfono). `if-raw` - conservar los nombres puestos a mano, resolver los de relleno (número de teléfono, push name) desde la agenda. `always` - la agenda es la fuente de verdad, reemplazar cualquier nombre.
cli.cmd.contacts.pull.option.groups: |-
Incluir contactos de grupos de WhatsApp en la sincronización.
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
**Opciones globales:**
task.contacts.status: |-
👤 Extracción de contactos: **{{state}}**
- ✅ Correctos: {{{progress.ok}}} contactos
👤 Contactos: **{{state}}**
- 🆕 Creados: {{{progress.created}}} contactos
- 🔄 Actualizados: {{{progress.updated}}} contactos
- 🏷️ Renombrados: {{{progress.renamed}}} contactos
- ⚪ Omitidos: {{{progress.skipped}}} contactos
- ⚠️ Errores: {{{progress.errors}}} contactos
- 🔴 Errores: {{{progress.errors}}} contactos
- 🖼️ Avatar actualizado: {{{progress.avatar.updated}}} contactos
{{#job}}
⌛ Cronología:
- En cola: {{{timestamp}}}
{{#processedOn}}
- Inicio: {{{processedOn}}}
{{/processedOn}}
{{#finishedOn}}
- Finalizó: {{{finishedOn}}}
{{/finishedOn}}
{{/job}}
{{#error}}
Detalles:
[{{{details.text}}}]({{{details.url}}})
{{/error}}
task.contacts.started: |-
Extrayendo contactos...
task.contacts.progress: |-
Extracción de contactos: ✅ {{progress.ok}}, ⚪ {{progress.skipped}}, ⚠️ {{progress.errors}}
👤⏳ Contactos: 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
app.help.reminder: |-
Envía **help** para obtener la lista de comandos disponibles.
@@ -660,10 +653,8 @@ cli.cmd.contacts.pull.option.progress:
informes de progreso.
cli.cmd.contacts.pull.option.lids:
Incluye contactos LID de WhatsApp (contactos anónimos) en la sincronización.
task.contacts.completed:
👤✅ Contactos completados → 🆕 {{progress.created}}, 🔄 {{progress.updated}},
⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️
{{progress.avatar.updated}}
task.contacts.completed: |-
👤✅ Contactos completados → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.messages.started: ⏳ Extrayendo mensajes 💬 {{{chat}}} ⏱️ {{{period}}}...
task.messages.details:
Envía "{{{prefix}}}messages status" para ver más detalles.
+13 -21
View File
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
cli.cmd.contacts.pull.option.avatar: |-
نحوه دریافت آواتار مخاطبان را انتخاب کنید: `skip`، `if-missing` یا `update`.
cli.cmd.contacts.pull.option.update-names: |-
نحوه به‌روزرسانی نام مخاطبان موجود: `no` - نام‌ها دست نخورده بمانند، فقط مخاطبان جدید نام می‌گیرند (دفترچه تلفن، push name یا شماره تلفن). `if-raw` - نام‌های دستی حفظ شوند، نام‌های جایگزین (شماره تلفن، push name) از دفترچه تلفن تعیین شوند. `always` - دفترچه تلفن منبع اصلی است، هر نامی جایگزین شود.
cli.cmd.contacts.pull.option.groups: |-
مخاطبان گروه‌های واتساپ را در همگام‌سازی قرار دهید.
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
**گزینه‌های عمومی:**
task.contacts.status: |-
👤 دریافت مخاطبان: **{{state}}**
- ✅ موفق: {{{progress.ok}}} مخاطب
- ⚪ نادیده‌گرفته‌شده: {{{progress.skipped}}} مخاطب
- ⚠️ خطا: {{{progress.errors}}} مخاطب
👤 مخاطبان: **{{state}}**
- 🆕 ایجاد شده: {{{progress.created}}} مخاطب
- 🔄 به‌روزرسانی شده: {{{progress.updated}}} مخاطب
- 🏷️ تغییر نام داده شده: {{{progress.renamed}}} مخاطب
- ⚪ رد شده: {{{progress.skipped}}} مخاطب
- 🔴 خطاها: {{{progress.errors}}} مخاطب
- 🖼️ آواتار به‌روزرسانی شده: {{{progress.avatar.updated}}} مخاطب
{{#job}}
⌛ جدول زمانی:
- در صف: {{{timestamp}}}
{{#processedOn}}
- زمان شروع: {{{processedOn}}}
{{/processedOn}}
{{#finishedOn}}
- زمان پایان: {{{finishedOn}}}
{{/finishedOn}}
{{/job}}
{{#error}}
جزئیات:
[{{{details.text}}}]({{{details.url}}})
{{/error}}
task.contacts.started: |-
در حال دریافت مخاطبان...
task.contacts.progress: |-
دریافت مخاطبان: ✅ {{progress.ok}}، ⚪ {{progress.skipped}}، ⚠️ {{progress.errors}}
👤⏳ مخاطبان: 🆕 {{progress.created}}، 🔄 {{progress.updated}}، 🏷️ {{progress.renamed}}، ⚪ {{progress.skipped}}، 🔴 {{progress.errors}}، 🖼️ {{progress.avatar.updated}}
app.help.reminder: |-
برای دریافت لیست دستورات موجود، **help** را ارسال کنید.
@@ -648,9 +641,8 @@ cli.cmd.contacts.pull.option.progress:
پیشرفت را هر N مخاطب اعلام می‌کند. مقدار 0 گزارش‌ها را غیرفعال می‌کند.
cli.cmd.contacts.pull.option.lids:
مخاطبین ناشناس واتساپ (LID) را در همگام‌سازی وارد می‌کند.
task.contacts.completed:
👤✅ مخاطبین تکمیل شدند → 🆕 {{progress.created}}، 🔄 {{progress.updated}}، ⚪
{{progress.skipped}}، 🔴 {{progress.errors}}، 🖼️ {{progress.avatar.updated}}
task.contacts.completed: |-
👤✅ مخاطبین تکمیل شدند → 🆕 {{progress.created}}، 🔄 {{progress.updated}}، 🏷️ {{progress.renamed}}، ⚪ {{progress.skipped}}، 🔴 {{progress.errors}}، 🖼️ {{progress.avatar.updated}}
task.messages.started: ⏳ در حال دریافت پیام‌ها 💬 {{{chat}}} ⏱️ {{{period}}}...
task.messages.details:
برای جزئیات بیشتر "{{{prefix}}}messages status" را ارسال کنید.
+12 -20
View File
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
cli.cmd.contacts.pull.option.avatar: |-
Choisissez comment charger les avatars des contacts : `skip`, `if-missing` ou `update`.
cli.cmd.contacts.pull.option.update-names: |-
Comment mettre à jour les noms des contacts existants : `no` - ne pas toucher aux noms, seuls les nouveaux contacts en reçoivent un (carnet d'adresses, push name ou numéro de téléphone). `if-raw` - conserver les noms saisis à la main, résoudre les noms génériques (numéro de téléphone, push name) depuis le carnet d'adresses. `always` - le carnet d'adresses fait foi, remplacer tout nom.
cli.cmd.contacts.pull.option.groups: |-
Inclure les contacts de groupes WhatsApp dans la synchronisation.
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
**Options globales :**
task.contacts.status: |-
👤 Import de contacts : **{{state}}**
- ✅ Réussis : {{{progress.ok}}} contacts
👤 Contacts : **{{state}}**
- 🆕 Créés : {{{progress.created}}} contacts
- 🔄 Mis à jour : {{{progress.updated}}} contacts
- 🏷️ Renommés : {{{progress.renamed}}} contacts
- ⚪ Ignorés : {{{progress.skipped}}} contacts
- ⚠️ Erreurs : {{{progress.errors}}} contacts
- 🔴 Erreurs : {{{progress.errors}}} contacts
- 🖼️ Avatar mis à jour : {{{progress.avatar.updated}}} contacts
{{#job}}
⌛ Chronologie :
- En file d'attente : {{{timestamp}}}
{{#processedOn}}
- Début : {{{processedOn}}}
{{/processedOn}}
{{#finishedOn}}
- Fin : {{{finishedOn}}}
{{/finishedOn}}
{{/job}}
{{#error}}
Détails :
[{{{details.text}}}]({{{details.url}}})
{{/error}}
task.contacts.started: |-
Importation des contacts...
task.contacts.progress: |-
Import de contacts : ✅ {{progress.ok}}, ⚪ {{progress.skipped}}, ⚠️ {{progress.errors}}
👤⏳ Contacts : 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
app.help.reminder: |-
Envoyez **help** pour obtenir la liste des commandes disponibles.
@@ -660,9 +653,8 @@ cli.cmd.contacts.pull.option.progress:
rapports.
cli.cmd.contacts.pull.option.lids:
Inclut les contacts LID WhatsApp (contacts anonymes) dans la synchronisation.
task.contacts.completed:
👤✅ Contacts terminés → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪
{{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.contacts.completed: |-
👤✅ Contacts terminés → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.messages.started:
⏳ Récupération des messages 💬 {{{chat}}} ⏱️ {{{period}}}...
task.messages.details:
+13 -21
View File
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
cli.cmd.contacts.pull.option.avatar: |-
בחר כיצד למשוך תמונות פרופיל: `skip`, `if-missing` או `update`.
cli.cmd.contacts.pull.option.update-names: |-
כיצד לעדכן שמות של אנשי קשר קיימים: `no` - לא לגעת בשמות, רק אנשי קשר חדשים מקבלים שם (ספר טלפונים, push name או מספר טלפון). `if-raw` - לשמור שמות שהוזנו ידנית, לפתור שמות זמניים (מספר טלפון, push name) מספר הטלפונים. `always` - ספר הטלפונים הוא מקור האמת, להחליף כל שם.
cli.cmd.contacts.pull.option.groups: |-
כלול בסנכרון אנשי קשר של קבוצות WhatsApp.
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
**אפשרויות גלובליות:**
task.contacts.status: |-
👤 משיכת אנשי קשר: **{{state}}**
- ✅ הצלחות: {{{progress.ok}}} אנשי קשר
- ⚪ שדלגו עליהם: {{{progress.skipped}}} אנשי קשר
- ⚠️ שגיאות: {{{progress.errors}}} אנשי קשר
👤 אנשי קשר: **{{state}}**
- 🆕 נוצרו: {{{progress.created}}} אנשי קשר
- 🔄 עודכנו: {{{progress.updated}}} אנשי קשר
- 🏷️ שונה שמם: {{{progress.renamed}}} אנשי קשר
- ⚪ דולגו: {{{progress.skipped}}} אנשי קשר
- 🔴 שגיאות: {{{progress.errors}}} אנשי קשר
- 🖼️ תמונת פרופיל עודכנה: {{{progress.avatar.updated}}} אנשי קשר
{{#job}}
⌛ ציר זמן:
- נכנסה לתור: {{{timestamp}}}
{{#processedOn}}
- התחלה: {{{processedOn}}}
{{/processedOn}}
{{#finishedOn}}
- סיום: {{{finishedOn}}}
{{/finishedOn}}
{{/job}}
{{#error}}
פרטים:
[{{{details.text}}}]({{{details.url}}})
{{/error}}
task.contacts.started: |-
אנשי קשר נמשכים...
task.contacts.progress: |-
משיכת אנשי קשר: ✅ {{progress.ok}}, ⚪ {{progress.skipped}}, ⚠️ {{progress.errors}}
👤⏳ אנשי קשר: 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
app.help.reminder: |-
שלח **help** כדי לקבל את רשימת הפקודות הזמינות.
@@ -633,9 +626,8 @@ cli.cmd.contacts.pull.option.progress:
דיווח על התקדמות כל N אנשי קשר. ערך 0 מבטל את הדוחות.
cli.cmd.contacts.pull.option.lids:
הכללת אנשי קשר אנונימיים של WhatsApp (LID) בסנכרון.
task.contacts.completed:
👤✅ השלמת אנשי קשר → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪
{{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.contacts.completed: |-
👤✅ השלמת אנשי קשר → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.messages.started: ⏳ מושכים הודעות 💬 {{{chat}}} ⏱️ {{{period}}}...
task.messages.details: שלחו "{{{prefix}}}messages status" לעוד פרטים.
task.messages.progress:
+12 -20
View File
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
cli.cmd.contacts.pull.option.avatar: |-
संपर्क अवतार कैसे खींचें चुनें: `skip`, `if-missing`, या `update`।
cli.cmd.contacts.pull.option.update-names: |-
मौजूदा संपर्कों के नाम कैसे अपडेट करें: `no` - नामों को न छुएँ, केवल नए संपर्कों को नाम मिलेगा (फ़ोन बुक, push name या फ़ोन नंबर)। `if-raw` - हाथ से रखे नाम रखें, प्लेसहोल्डर (फ़ोन नंबर, push name) फ़ोन बुक से हल करें। `always` - फ़ोन बुक ही सत्य का स्रोत है, कोई भी नाम बदल दें।
cli.cmd.contacts.pull.option.groups: |-
सिंक में WhatsApp समूह संपर्क शामिल करें।
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
**ग्लोबल विकल्प:**
task.contacts.status: |-
👤 संपर्क खींचना: **{{state}}**
- ✅ सफल: {{{progress.ok}}} संपर्क
👤 संपर्क: **{{state}}**
- 🆕 बनाए गए: {{{progress.created}}} संपर्क
- 🔄 अपडेट किए गए: {{{progress.updated}}} संपर्क
- 🏷️ नाम बदले गए: {{{progress.renamed}}} संपर्क
- ⚪ छोड़े गए: {{{progress.skipped}}} संपर्क
- ⚠️ त्रुटियाँ: {{{progress.errors}}} संपर्क
- 🔴 त्रुटियाँ: {{{progress.errors}}} संपर्क
- 🖼️ अवतार अपडेट: {{{progress.avatar.updated}}} संपर्क
{{#job}}
⌛ समयरेखा:
- कतार में: {{{timestamp}}}
{{#processedOn}}
- शुरू हुआ: {{{processedOn}}}
{{/processedOn}}
{{#finishedOn}}
- समाप्त: {{{finishedOn}}}
{{/finishedOn}}
{{/job}}
{{#error}}
विवरण:
[{{{details.text}}}]({{{details.url}}})
{{/error}}
task.contacts.started: |-
संपर्क खींचे जा रहे हैं...
task.contacts.progress: |-
संपर्क खींचना: ✅ {{progress.ok}}, ⚪ {{progress.skipped}}, ⚠️ {{progress.errors}}
👤⏳ संपर्क: 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
app.help.reminder: |-
उपलब्ध कमांड की सूची प्राप्त करने के लिए **help** भेजें।
@@ -645,9 +638,8 @@ cli.cmd.contacts.pull.option.progress:
हर N संपर्क पर प्रगति रिपोर्ट करें। रिपोर्ट बंद करने के लिए 0 सेट करें।
cli.cmd.contacts.pull.option.lids:
सिंक में WhatsApp LID (अनाम) संपर्क शामिल करें।
task.contacts.completed:
👤✅ संपर्क पूरे → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪
{{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.contacts.completed: |-
👤✅ संपर्क पूरे → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.messages.started:
⏳ संदेश खींचे जा रहे हैं 💬 {{{chat}}} ⏱️ {{{period}}}...
task.messages.details: और जानकारी के लिए "{{{prefix}}}messages status" भेजें।
+12 -20
View File
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
cli.cmd.contacts.pull.option.avatar: |-
Pilih cara mengambil avatar kontak: `skip`, `if-missing`, atau `update`.
cli.cmd.contacts.pull.option.update-names: |-
Cara memperbarui nama kontak yang sudah ada: `no` - jangan ubah nama, hanya kontak baru yang mendapat nama (buku telepon, push name, atau nomor telepon). `if-raw` - pertahankan nama yang diisi manual, ganti placeholder (nomor telepon, push name) dari buku telepon. `always` - buku telepon adalah sumber kebenaran, ganti nama apa pun.
cli.cmd.contacts.pull.option.groups: |-
Sertakan kontak grup WhatsApp dalam sinkronisasi.
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
**Opsi global:**
task.contacts.status: |-
👤 Penarikan kontak: **{{state}}**
- ✅ Berhasil: {{{progress.ok}}} kontak
👤 Kontak: **{{state}}**
- 🆕 Dibuat: {{{progress.created}}} kontak
- 🔄 Diperbarui: {{{progress.updated}}} kontak
- 🏷️ Diganti nama: {{{progress.renamed}}} kontak
- ⚪ Dilewati: {{{progress.skipped}}} kontak
- ⚠️ Kesalahan: {{{progress.errors}}} kontak
- 🔴 Kesalahan: {{{progress.errors}}} kontak
- 🖼️ Avatar diperbarui: {{{progress.avatar.updated}}} kontak
{{#job}}
⌛ Linimasa:
- Masuk antrean: {{{timestamp}}}
{{#processedOn}}
- Mulai: {{{processedOn}}}
{{/processedOn}}
{{#finishedOn}}
- Selesai: {{{finishedOn}}}
{{/finishedOn}}
{{/job}}
{{#error}}
Detail:
[{{{details.text}}}]({{{details.url}}})
{{/error}}
task.contacts.started: |-
Menarik kontak...
task.contacts.progress: |-
Penarikan kontak: ✅ {{progress.ok}}, ⚪ {{progress.skipped}}, ⚠️ {{progress.errors}}
👤⏳ Kontak: 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
app.help.reminder: |-
Kirim **help** untuk mendapatkan daftar perintah yang tersedia.
@@ -645,9 +638,8 @@ cli.cmd.contacts.pull.option.progress:
Laporkan progres setiap N kontak. Setel ke 0 untuk menonaktifkan laporan.
cli.cmd.contacts.pull.option.lids:
Sertakan kontak WhatsApp LID (kontak anonim) dalam sinkronisasi.
task.contacts.completed:
👤✅ Kontak selesai → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪
{{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.contacts.completed: |-
👤✅ Kontak selesai → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.messages.started: ⏳ Menarik pesan 💬 {{{chat}}} ⏱️ {{{period}}}...
task.messages.details:
Kirim "{{{prefix}}}messages status" untuk melihat rincian.
+6 -2
View File
@@ -547,6 +547,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
cli.cmd.contacts.pull.option.avatar: |-
Scegli come recuperare gli avatar dei contatti.
cli.cmd.contacts.pull.option.update-names: |-
Come aggiornare i nomi dei contatti esistenti: `no` - non toccare i nomi, solo i nuovi contatti ne ricevono uno (rubrica, push name o numero di telefono). `if-raw` - mantieni i nomi inseriti a mano, risolvi i segnaposto (numero di telefono, push name) dalla rubrica. `always` - la rubrica è la fonte di verità, sostituisci qualsiasi nome.
cli.cmd.contacts.pull.option.groups: |-
Includi i contatti dei gruppi WhatsApp.
@@ -604,15 +607,16 @@ task.contacts.started: |-
👤⏳ Recupero contatti... Invia "contacts status" per maggiori dettagli.
task.contacts.progress: |-
👤⏳ Contatti: 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
👤⏳ Contatti: 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.contacts.completed: |-
👤✅ Recupero contatti completato → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
👤✅ Recupero contatti completato → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.contacts.status: |-
👤 Contatti: **{{state}}**
- 🆕 Creati: {{{progress.created}}} contatti
- 🔄 Aggiornati: {{{progress.updated}}} contatti
- 🏷️ Rinominati: {{{progress.renamed}}} contatti
- ⚪ Saltati: {{{progress.skipped}}} contatti
- 🔴 Errori: {{{progress.errors}}} contatti
- 🖼️ Avatar Aggiornati: {{{progress.avatar.updated}}} contatti
+13 -21
View File
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
cli.cmd.contacts.pull.option.avatar: |-
ਚੁਣੋ ਕਿ ਅਵਤਾਰ ਕਿਵੇਂ ਖਿੱਚਣੇ ਹਨ: `skip`, `if-missing`, ਜਾਂ `update`।
cli.cmd.contacts.pull.option.update-names: |-
ਮੌਜੂਦਾ ਸੰਪਰਕਾਂ ਦੇ ਨਾਮ ਕਿਵੇਂ ਅੱਪਡੇਟ ਕਰਨੇ ਹਨ: `no` - ਨਾਮਾਂ ਨੂੰ ਨਾ ਛੂਹੋ, ਸਿਰਫ਼ ਨਵੇਂ ਸੰਪਰਕਾਂ ਨੂੰ ਨਾਮ ਮਿਲਦਾ ਹੈ (ਫ਼ੋਨ ਬੁੱਕ, push name ਜਾਂ ਫ਼ੋਨ ਨੰਬਰ)। `if-raw` - ਹੱਥੀਂ ਰੱਖੇ ਨਾਮ ਰੱਖੋ, ਪਲੇਸਹੋਲਡਰ (ਫ਼ੋਨ ਨੰਬਰ, push name) ਫ਼ੋਨ ਬੁੱਕ ਤੋਂ ਹੱਲ ਕਰੋ। `always` - ਫ਼ੋਨ ਬੁੱਕ ਹੀ ਸੱਚ ਦਾ ਸਰੋਤ ਹੈ, ਕੋਈ ਵੀ ਨਾਮ ਬਦਲ ਦਿਓ।
cli.cmd.contacts.pull.option.groups: |-
ਸਿੰਕ ਵਿੱਚ WhatsApp ਗਰੁੱਪ ਸੰਪਰਕ ਸ਼ਾਮਲ ਕਰੋ।
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
**ਗਲੋਬਲ ਚੋਣਾਂ:**
task.contacts.status: |-
👤 ਸੰਪਰਕ ਖਿੱਚਣਾ: **{{state}}**
- ✅ ਸਫਲ: {{{progress.ok}}} ਸੰਪਰਕ
- ⚪ ਛੱਡੇ ਗਏ: {{{progress.skipped}}} ਸੰਪਰਕ
- ⚠️ ਗਲਤੀਆਂ: {{{progress.errors}}} ਸੰਪਰਕ
👤 ਸੰਪਰਕ: **{{state}}**
- 🆕 ਬਣਾਏ: {{{progress.created}}} ਸੰਪਰਕ
- 🔄 ਅੱਪਡੇਟ ਕੀਤੇ: {{{progress.updated}}} ਸੰਪਰਕ
- 🏷️ ਨਾਮ ਬਦਲੇ: {{{progress.renamed}}} ਸੰਪਰਕ
- ⚪ ਛੱਡੇ: {{{progress.skipped}}} ਸੰਪਰਕ
- 🔴 ਗਲਤੀਆਂ: {{{progress.errors}}} ਸੰਪਰਕ
- 🖼️ ਅਵਤਾਰ ਅੱਪਡੇਟ: {{{progress.avatar.updated}}} ਸੰਪਰਕ
{{#job}}
⌛ ਸਮਾਂ-ਰੇਖਾ:
- ਕਤਾਰ ਵਿੱਚ: {{{timestamp}}}
{{#processedOn}}
- ਸ਼ੁਰੂ: {{{processedOn}}}
{{/processedOn}}
{{#finishedOn}}
- ਸਮਾਪਤ: {{{finishedOn}}}
{{/finishedOn}}
{{/job}}
{{#error}}
ਵੇਰਵੇ:
[{{{details.text}}}]({{{details.url}}})
{{/error}}
task.contacts.started: |-
ਸੰਪਰਕ ਖਿੱਚੇ ਜਾ ਰਹੇ ਹਨ...
task.contacts.progress: |-
ਸੰਪਰਕ ਖਿੱਚਣਾ: ✅ {{progress.ok}}, ⚪ {{progress.skipped}}, ⚠️ {{progress.errors}}
👤⏳ ਸੰਪਰਕ: 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
app.help.reminder: |-
ਉਪਲਬਧ ਕਮਾਂਡਾਂ ਦੀ ਸੂਚੀ ਪ੍ਰਾਪਤ ਕਰਨ ਲਈ **help** ਭੇਜੋ।
@@ -644,9 +637,8 @@ cli.cmd.contacts.pull.option.progress:
ਹਰ N ਸੰਪਰਕ ’ਤੇ ਪ੍ਰਗਤੀ ਦੱਸੋ। ਰਿਪੋਰਟਾਂ ਬੰਦ ਕਰਨ ਲਈ 0 ਸੈੱਟ ਕਰੋ।
cli.cmd.contacts.pull.option.lids:
ਸਿੰਕ ਵਿੱਚ WhatsApp LID (ਅਣਪਛਾਤੇ) ਸੰਪਰਕ ਸ਼ਾਮਲ ਕਰੋ।
task.contacts.completed:
👤✅ ਸੰਪਰਕ ਮੁਕੰਮਲ → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪
{{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.contacts.completed: |-
👤✅ ਸੰਪਰਕ ਮੁਕੰਮਲ → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.messages.started:
⏳ ਸੁਨੇਹੇ ਖਿੱਚੇ ਜਾ ਰਹੇ ਹਨ 💬 {{{chat}}} ⏱️ {{{period}}}...
task.messages.details: ਵਧੇਰੇ ਵੇਰਵਿਆਂ ਲਈ "{{{prefix}}}messages status" ਭੇਜੋ।
+12 -21
View File
@@ -195,6 +195,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
cli.cmd.contacts.pull.option.avatar: |-
Escolha como buscar os avatares dos contatos: `skip`, `if-missing` ou `update`.
cli.cmd.contacts.pull.option.update-names: |-
Como atualizar os nomes de contatos existentes: `no` - não mexer nos nomes, só contatos novos recebem um (agenda, push name ou número de telefone). `if-raw` - manter nomes definidos manualmente, resolver os genéricos (número de telefone, push name) a partir da agenda. `always` - a agenda é a fonte da verdade, substituir qualquer nome.
cli.cmd.contacts.pull.option.groups: |-
Incluir contatos de grupo do WhatsApp na sincronização.
@@ -246,32 +249,22 @@ cli.help.globalOptions.title: |-
**Opções globais:**
task.contacts.status: |-
👤 Busca de contatos: **{{state}}**
- ✅ Sucesso: {{{progress.ok}}} contatos
👤 Contatos: **{{state}}**
- 🆕 Criados: {{{progress.created}}} contatos
- 🔄 Atualizados: {{{progress.updated}}} contatos
- 🏷️ Renomeados: {{{progress.renamed}}} contatos
- ⚪ Ignorados: {{{progress.skipped}}} contatos
- ⚠️ Erros: {{{progress.errors}}} contatos
- 🔴 Erros: {{{progress.errors}}} contatos
- 🖼️ Avatar atualizado: {{{progress.avatar.updated}}} contatos
{{#job}}
⌛ Linha do tempo:
- Na fila: {{{timestamp}}}
{{#processedOn}}
- Início: {{{processedOn}}}
{{/processedOn}}
{{#finishedOn}}
- Fim: {{{finishedOn}}}
{{/finishedOn}}
{{/job}}
{{#error}}
Detalhes:
[{{{details.text}}}]({{{details.url}}})
{{/error}}
task.contacts.started: |-
Buscando contatos...
task.contacts.progress: |-
Busca de contatos: ✅ {{progress.ok}}, ⚪ {{progress.skipped}}, ⚠️ {{progress.errors}}
👤⏳ Contatos: 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
app.help.reminder: |-
Envie **help** para obter a lista de comandos disponíveis.
@@ -655,10 +648,8 @@ cli.cmd.contacts.pull.option.progress:
de progresso.
cli.cmd.contacts.pull.option.lids:
Inclui contatos LID do WhatsApp (contatos anônimos) na sincronização.
task.contacts.completed:
👤✅ Contatos concluídos → 🆕 {{progress.created}}, 🔄 {{progress.updated}},
⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️
{{progress.avatar.updated}}
task.contacts.completed: |-
👤✅ Contatos concluídos → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.messages.started: ⏳ Buscando mensagens 💬 {{{chat}}} ⏱️ {{{period}}}...
task.messages.details:
Envie "{{{prefix}}}messages status" para ver mais detalhes.
+12 -20
View File
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
cli.cmd.contacts.pull.option.avatar: |-
Выберите, как загружать аватары контактов: `skip`, `if-missing` или `update`.
cli.cmd.contacts.pull.option.update-names: |-
Как обновлять имена существующих контактов: `no` - не трогать имена, имя получают только новые контакты (телефонная книга, push name или номер телефона). `if-raw` - сохранять имена, заданные вручную, заглушки (номер телефона, push name) заменять из телефонной книги. `always` - телефонная книга является источником истины, заменять любое имя.
cli.cmd.contacts.pull.option.groups: |-
Включить контакты групп WhatsApp в синхронизацию.
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
**Глобальные опции:**
task.contacts.status: |-
👤 Загрузка контактов: **{{state}}**
- ✅ Успешно: {{{progress.ok}}} контактов
👤 Контакты: **{{state}}**
- 🆕 Создано: {{{progress.created}}} контактов
- 🔄 Обновлено: {{{progress.updated}}} контактов
- 🏷️ Переименовано: {{{progress.renamed}}} контактов
- ⚪ Пропущено: {{{progress.skipped}}} контактов
- ⚠️ Ошибки: {{{progress.errors}}} контактов
- 🔴 Ошибки: {{{progress.errors}}} контактов
- 🖼️ Аватар обновлён: {{{progress.avatar.updated}}} контактов
{{#job}}
⌛ Хронология:
- В очереди: {{{timestamp}}}
{{#processedOn}}
- Начато: {{{processedOn}}}
{{/processedOn}}
{{#finishedOn}}
- Завершено: {{{finishedOn}}}
{{/finishedOn}}
{{/job}}
{{#error}}
Подробности:
[{{{details.text}}}]({{{details.url}}})
{{/error}}
task.contacts.started: |-
Идёт загрузка контактов...
task.contacts.progress: |-
Загрузка контактов: ✅ {{progress.ok}}, ⚪ {{progress.skipped}}, ⚠️ {{progress.errors}}
👤⏳ Контакты: 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
app.help.reminder: |-
Отправьте **help**, чтобы получить список доступных команд.
@@ -647,9 +640,8 @@ cli.cmd.contacts.pull.option.progress:
Сообщать о прогрессе каждые N контактов. Значение 0 отключает отчёты.
cli.cmd.contacts.pull.option.lids:
Включать контакты WhatsApp LID (анонимные контакты) в синхронизацию.
task.contacts.completed:
👤✅ Контакты завершены → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪
{{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.contacts.completed: |-
👤✅ Контакты завершены → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.messages.started: ⏳ Загружаем сообщения 💬 {{{chat}}} ⏱️ {{{period}}}...
task.messages.details:
Отправьте "{{{prefix}}}messages status", чтобы увидеть подробности.
+13 -21
View File
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
cli.cmd.contacts.pull.option.avatar: |-
Kişi avatarlarının nasıl çekileceğini seçin: `skip`, `if-missing` veya `update`.
cli.cmd.contacts.pull.option.update-names: |-
Mevcut kişilerin adlarının nasıl güncelleneceği: `no` - adlara dokunma, yalnızca yeni kişiler ad alır (rehber, push name veya telefon numarası). `if-raw` - elle girilen adları koru, yer tutucuları (telefon numarası, push name) rehberden çöz. `always` - rehber doğruluk kaynağıdır, her adı değiştir.
cli.cmd.contacts.pull.option.groups: |-
WhatsApp grup kişilerini senkronizasyona dahil et.
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
**Genel seçenekler:**
task.contacts.status: |-
👤 Kişi çekme: **{{state}}**
- ✅ Başarılı: {{{progress.ok}}} kişi
- ⚪ Atlanan: {{{progress.skipped}}} kişi
- ⚠️ Hatalar: {{{progress.errors}}} kişi
👤 Kişiler: **{{state}}**
- 🆕 Oluşturuldu: {{{progress.created}}} kişi
- 🔄 Güncellendi: {{{progress.updated}}} kişi
- 🏷️ Yeniden adlandırıldı: {{{progress.renamed}}} kişi
- ⚪ Atlandı: {{{progress.skipped}}} kişi
- 🔴 Hatalar: {{{progress.errors}}} kişi
- 🖼️ Avatar güncellendi: {{{progress.avatar.updated}}} kişi
{{#job}}
⌛ Zaman çizelgesi:
- Sıraya alındı: {{{timestamp}}}
{{#processedOn}}
- Başladı: {{{processedOn}}}
{{/processedOn}}
{{#finishedOn}}
- Bitti: {{{finishedOn}}}
{{/finishedOn}}
{{/job}}
{{#error}}
Ayrıntılar:
[{{{details.text}}}]({{{details.url}}})
{{/error}}
task.contacts.started: |-
Kişiler çekiliyor...
task.contacts.progress: |-
Kişi çekme: ✅ {{progress.ok}}, ⚪ {{progress.skipped}}, ⚠️ {{progress.errors}}
👤⏳ Kişiler: 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
app.help.reminder: |-
Kullanılabilir komutların listesini almak için **help** gönderin.
@@ -646,9 +639,8 @@ cli.cmd.contacts.pull.option.progress:
Her N kişide ilerlemeyi bildirir. 0 değeri raporları kapatır.
cli.cmd.contacts.pull.option.lids:
Senkronizasyona WhatsApp LID (anonim) kişilerini dahil eder.
task.contacts.completed:
👤✅ Kişiler tamamlandı → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪
{{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.contacts.completed: |-
👤✅ Kişiler tamamlandı → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.messages.started: ⏳ Mesajlar çekiliyor 💬 {{{chat}}} ⏱️ {{{period}}}...
task.messages.details:
Daha fazla ayrıntı için "{{{prefix}}}messages status" gönderin.
+12 -20
View File
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
cli.cmd.contacts.pull.option.avatar: |-
Виберіть, як завантажувати аватари контактів: `skip`, `if-missing` чи `update`.
cli.cmd.contacts.pull.option.update-names: |-
Як оновлювати імена наявних контактів: `no` - не чіпати імена, ім'я отримують лише нові контакти (телефонна книга, push name або номер телефону). `if-raw` - зберігати імена, задані вручну, заповнювачі (номер телефону, push name) замінювати з телефонної книги. `always` - телефонна книга є джерелом істини, замінювати будь-яке ім'я.
cli.cmd.contacts.pull.option.groups: |-
Додати контакти груп WhatsApp до синхронізації.
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
**Глобальні опції:**
task.contacts.status: |-
👤 Отримання контактів: **{{state}}**
- ✅ Успішно: {{{progress.ok}}} контактів
👤 Контакти: **{{state}}**
- 🆕 Створено: {{{progress.created}}} контактів
- 🔄 Оновлено: {{{progress.updated}}} контактів
- 🏷️ Перейменовано: {{{progress.renamed}}} контактів
- ⚪ Пропущено: {{{progress.skipped}}} контактів
- ⚠️ Помилки: {{{progress.errors}}} контактів
- 🔴 Помилки: {{{progress.errors}}} контактів
- 🖼️ Аватар оновлено: {{{progress.avatar.updated}}} контактів
{{#job}}
⌛ Хронологія:
- У черзі: {{{timestamp}}}
{{#processedOn}}
- Початок: {{{processedOn}}}
{{/processedOn}}
{{#finishedOn}}
- Завершення: {{{finishedOn}}}
{{/finishedOn}}
{{/job}}
{{#error}}
Деталі:
[{{{details.text}}}]({{{details.url}}})
{{/error}}
task.contacts.started: |-
Отримання контактів...
task.contacts.progress: |-
Отримання контактів: ✅ {{progress.ok}}, ⚪ {{progress.skipped}}, ⚠️ {{progress.errors}}
👤⏳ Контакти: 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
app.help.reminder: |-
Надішліть **help**, щоб отримати список доступних команд.
@@ -645,9 +638,8 @@ cli.cmd.contacts.pull.option.progress:
Повідомляти про прогрес кожні N контактів. Значення 0 вимикає звіти.
cli.cmd.contacts.pull.option.lids:
Включати анонімні контакти WhatsApp LID у синхронізацію.
task.contacts.completed:
👤✅ Контакти завершено → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪
{{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.contacts.completed: |-
👤✅ Контакти завершено → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.messages.started:
⏳ Отримуємо повідомлення 💬 {{{chat}}} ⏱️ {{{period}}}...
task.messages.details:
+13 -21
View File
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
cli.cmd.contacts.pull.option.avatar: |-
یہ منتخب کریں کہ رابطوں کے اوتار کس طرح نکالے جائیں: `skip`, `if-missing`, یا `update`.
cli.cmd.contacts.pull.option.update-names: |-
موجودہ رابطوں کے نام کیسے اپ ڈیٹ کیے جائیں: `no` - ناموں کو نہ چھیڑیں، صرف نئے رابطوں کو نام ملتا ہے (فون بک، push name یا فون نمبر)۔ `if-raw` - ہاتھ سے رکھے نام برقرار رکھیں، عارضی نام (فون نمبر، push name) فون بک سے حل کریں۔ `always` - فون بک ہی معتبر ذریعہ ہے، کوئی بھی نام بدل دیں۔
cli.cmd.contacts.pull.option.groups: |-
واٹس ایپ گروپ روابط کو ہم وقت سازی میں شامل کریں۔
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
**عالمی اختیارات:**
task.contacts.status: |-
👤 رابطے نکالنا: **{{state}}**
- ✅ کامیابی: {{{progress.ok}}} روابط
- ⚪ چھوڑے گئے: {{{progress.skipped}}} روابط
- ⚠️ غلطیاں: {{{progress.errors}}} روابط
👤 رابطے: **{{state}}**
- 🆕 بنائے گئے: {{{progress.created}}} رابطے
- 🔄 اپ ڈیٹ: {{{progress.updated}}} رابطے
- 🏷️ نام تبدیل: {{{progress.renamed}}} رابطے
- ⚪ چھوڑے گئے: {{{progress.skipped}}} رابطے
- 🔴 خرابیاں: {{{progress.errors}}} رابطے
- 🖼️ اوتار اپ ڈیٹ: {{{progress.avatar.updated}}} رابطے
{{#job}}
⌛ ٹائم لائن:
- قطار میں: {{{timestamp}}}
{{#processedOn}}
- آغاز: {{{processedOn}}}
{{/processedOn}}
{{#finishedOn}}
- اختتام: {{{finishedOn}}}
{{/finishedOn}}
{{/job}}
{{#error}}
تفصیلات:
[{{{details.text}}}]({{{details.url}}})
{{/error}}
task.contacts.started: |-
روابط نکالے جا رہے ہیں...
task.contacts.progress: |-
رابطے نکالنا: ✅ {{progress.ok}}، ⚪ {{progress.skipped}}، ⚠️ {{progress.errors}}
👤⏳ رابطے: 🆕 {{progress.created}}، 🔄 {{progress.updated}}، 🏷️ {{progress.renamed}}، ⚪ {{progress.skipped}}، 🔴 {{progress.errors}}، 🖼️ {{progress.avatar.updated}}
app.help.reminder: |-
دستیاب کمانڈز کی فہرست حاصل کرنے کے لیے **help** بھیجیں۔
@@ -644,9 +637,8 @@ cli.cmd.contacts.pull.option.progress:
ہر N رابطوں پر پیش رفت بتائیں۔ رپورٹ بند کرنے کے لیے 0 مقرر کریں۔
cli.cmd.contacts.pull.option.lids:
WhatsApp LID (گمنام) رابطوں کو ہم آہنگی میں شامل کریں۔
task.contacts.completed:
👤✅ رابطے مکمل → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪
{{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
task.contacts.completed: |-
👤✅ رابطے مکمل → 🆕 {{progress.created}}، 🔄 {{progress.updated}}، 🏷️ {{progress.renamed}}، ⚪ {{progress.skipped}}، 🔴 {{progress.errors}}، 🖼️ {{progress.avatar.updated}}
task.messages.started:
⏳ پیغامات کھینچے جا رہے ہیں 💬 {{{chat}}} ⏱️ {{{period}}}...
task.messages.details: مزید تفصیلات کے لیے "{{{prefix}}}messages status" بھیجیں۔
+13 -21
View File
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
cli.cmd.contacts.pull.option.avatar: |-
选择如何拉取联系人头像:`skip`、`if-missing` 或 `update`。
cli.cmd.contacts.pull.option.update-names: |-
如何更新已有联系人的名称:`no` - 不改动名称,只有新联系人会获得名称(通讯录、push name 或电话号码)。`if-raw` - 保留手动设置的名称,占位名称(电话号码、push name)从通讯录解析。`always` - 以通讯录为准,替换任何名称。
cli.cmd.contacts.pull.option.groups: |-
在同步中包含 WhatsApp 群组联系人。
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
**全局选项:**
task.contacts.status: |-
👤 联系人拉取:**{{state}}**
- ✅ 成功:{{{progress.ok}}} 位联系人
- ⚪ 跳过:{{{progress.skipped}}} 位联系人
- ⚠️ 错误:{{{progress.errors}}} 位联系人
👤 联系人:**{{state}}**
- 🆕 已创建:{{{progress.created}}} 位联系人
- 🔄 已更新:{{{progress.updated}}} 位联系人
- 🏷️ 已重命名:{{{progress.renamed}}} 位联系人
- ⚪ 已跳过:{{{progress.skipped}}} 位联系人
- 🔴 错误:{{{progress.errors}}} 位联系人
- 🖼️ 头像已更新:{{{progress.avatar.updated}}} 位联系人
{{#job}}
⌛ 时间线:
- 排队:{{{timestamp}}}
{{#processedOn}}
- 开始:{{{processedOn}}}
{{/processedOn}}
{{#finishedOn}}
- 完成:{{{finishedOn}}}
{{/finishedOn}}
{{/job}}
{{#error}}
详情:
[{{{details.text}}}]({{{details.url}}})
{{/error}}
task.contacts.started: |-
正在拉取联系人...
task.contacts.progress: |-
联系人拉取:✅ {{progress.ok}},⚪ {{progress.skipped}},⚠️ {{progress.errors}}
👤⏳ 联系人:🆕 {{progress.created}},🔄 {{progress.updated}},🏷️ {{progress.renamed}},⚪ {{progress.skipped}},🔴 {{progress.errors}},🖼️ {{progress.avatar.updated}}
app.help.reminder: |-
发送 **help** 获取可用命令列表。
@@ -623,9 +616,8 @@ cli.cmd.messages.pull.no-chats-found: 在该时间段内未找到可拉取的 Wh
cli.cmd.contacts.pull.option.progress:
每隔 N 个联系人报告进度。设置为 0 关闭报告。
cli.cmd.contacts.pull.option.lids: 在同步中包含 WhatsApp LID(匿名)联系人。
task.contacts.completed:
👤✅ 联系人同步完成 → 🆕 {{progress.created}},🔄 {{progress.updated}},⚪
{{progress.skipped}},🔴 {{progress.errors}},🖼️ {{progress.avatar.updated}}
task.contacts.completed: |-
👤✅ 联系人已完成 → 🆕 {{progress.created}},🔄 {{progress.updated}},🏷️ {{progress.renamed}},⚪ {{progress.skipped}},🔴 {{progress.errors}},🖼️ {{progress.avatar.updated}}
task.messages.started: ⏳ 正在拉取消息 💬 {{{chat}}} ⏱️ {{{period}}}...
task.messages.details: 发送 "{{{prefix}}}messages status" 查看更多详情。
task.messages.progress:
@@ -0,0 +1,31 @@
import { Knex } from 'knex';
exports.up = async function (knex: Knex) {
await knex.schema.alterTable('app_chatwoot_chatwoot_messages', (table) => {
table.integer('parts').nullable();
});
// WhatsApp acks for our messages, kept apart from mappings because an ack can arrive before the mapping
await knex.schema.createTable('app_chatwoot_whatsapp_acks', (table) => {
table.increments('id');
table.integer('app_pk');
table
.foreign('app_pk')
.references('pk')
.inTable('apps')
.onDelete('CASCADE');
table.string('message_id', 64);
table.integer('ack');
table.datetime('timestamp');
table.unique(['app_pk', 'message_id'], {
indexName: 'wa_ack_app_msg_unique',
});
table.index(['app_pk', 'timestamp'], 'wa_ack_app_ts_idx');
});
};
exports.down = async function (knex: Knex) {
await knex.schema.dropTable('app_chatwoot_whatsapp_acks');
await knex.schema.alterTable('app_chatwoot_chatwoot_messages', (table) => {
table.dropColumn('parts');
});
};
@@ -121,6 +121,7 @@ export class ChatWootAppService implements IAppService {
const knex = manager.store.getWAHADatabase();
const di = new DIContainer(appDb.pk, app.config, this.logger, knex);
await di.MessageMappingService().purge();
await di.MessageStatusService().purge();
this.cleanCache(app);
}
@@ -31,7 +31,9 @@ export class ChatWootScheduleService {
await messageCleanupQueue.upsertJobScheduler(
this.JobId(QueueName.SCHEDULED_MESSAGE_CLEANUP, appId),
// Every day at 17:00
{ pattern: '0 0 17 * * *' },
{
pattern: '0 0 17 * * *',
},
{
data: {
app: appId,
@@ -46,7 +48,9 @@ export class ChatWootScheduleService {
await checkVersionQueue.upsertJobScheduler(
this.JobId(QueueName.SCHEDULED_CHECK_VERSION, appId),
// Every Wednesday (3) at 18:00
{ pattern: '0 0 18 * * 3' },
{
pattern: '0 0 18 * * 3',
},
{
data: {
app: appId,
@@ -58,17 +62,22 @@ export class ChatWootScheduleService {
const checkTierQueue = this.queueRegistry.queue(
QueueName.SCHEDULED_CHECK_TIER,
);
await checkTierQueue.upsertJobScheduler(
// Disabled - since we do not have PLUS version anymore
await checkTierQueue.removeJobScheduler(
this.JobId(QueueName.SCHEDULED_CHECK_TIER, appId),
// Every Monday (1) at 14:00
{ pattern: '0 0 14 * * 1' },
{
data: {
app: appId,
session: sessionName,
},
},
);
// Enabled
// await checkTierQueue.upsertJobScheduler(
// this.JobId(QueueName.SCHEDULED_CHECK_TIER, appId),
// // Every Monday (1) at 14:00
// { pattern: '0 0 14 * * 1' },
// {
// data: {
// app: appId,
// session: sessionName,
// },
// },
// );
}
async unschedule(appId: string, sessionName: string): Promise<void> {
@@ -0,0 +1,71 @@
import { ContactConversationService } from '@waha/apps/chatwoot/client/ContactConversationService';
import { MessageStatus } from '@waha/apps/chatwoot/client/types';
import { MessageMappingService } from '@waha/apps/chatwoot/storage/MessageMappingService';
import { WhatsAppAckRepository } from '@waha/apps/chatwoot/storage/WhatsAppAckRepository';
import { ChatwootMessage } from '@waha/apps/chatwoot/storage/types';
import { WAMessageAck } from '@waha/structures/enums.dto';
export type ChatwootMessageStatusKey = Pick<
ChatwootMessage,
'conversation_id' | 'message_id' | 'parts'
>;
/**
* Mirrors WhatsApp acks for messages sent from Chatwoot into Chatwoot message status
*/
export class MessageStatusService {
constructor(
private readonly mappingService: MessageMappingService,
private readonly ackRepository: WhatsAppAckRepository,
private readonly contactConversationService: ContactConversationService,
) {}
/**
* Saves the ack for a WhatsApp message, keeps the highest one
*/
async record(
messageId: string,
ack: WAMessageAck,
timestamp: Date,
): Promise<void> {
await this.ackRepository.record(messageId, ack, timestamp);
}
/**
* Pushes the status to Chatwoot once every part has an ack, uses the lowest one
* @returns the status sent to Chatwoot, null if nothing to update yet
*/
async sync(message: ChatwootMessageStatusKey): Promise<MessageStatus | null> {
// Messages mirrored from WhatsApp (and old rows) do not know how many parts were sent
if (!message.parts) {
return null;
}
const parts = await this.mappingService.getWhatsAppMessage(message);
if (parts.length !== message.parts) {
return null;
}
const ack = await this.ackRepository.minimumAck(
parts.map((part) => part.message_id),
);
if (ack === null || ack < WAMessageAck.DEVICE) {
return null;
}
let status = MessageStatus.DELIVERED;
if (ack >= WAMessageAck.READ) {
status = MessageStatus.READ;
}
// Chatwoot only moves status forward, so re-sending the same status is harmless
await this.contactConversationService
.ConversationById(message.conversation_id)
.updateMessageStatus(message.message_id, status);
return status;
}
cleanup(removeAfter: Date): Promise<number> {
return this.ackRepository.deleteOlderThan(removeAfter);
}
purge(): Promise<number> {
return this.ackRepository.deleteAll();
}
}
@@ -77,6 +77,15 @@ export class MessageMappingRepository {
.first();
}
async getAllByChatwootMessageId(id: number): Promise<MessageMapping[]> {
return this.knex(this.tableName)
.where({
app_pk: this.appPk,
chatwoot_message_id: id,
})
.orderBy('part', 'asc');
}
async getByChatwootMessageIdAndPart(
id: number,
part: number,
@@ -126,11 +126,11 @@ export class MessageMappingService {
}
const mappings = [];
for (const message of messages) {
const mapping =
await this.messageMappingRepository.getByChatwootMessageId(message.id);
if (mapping) {
mappings.push(mapping);
}
const parts =
await this.messageMappingRepository.getAllByChatwootMessageId(
message.id,
);
mappings.push(...parts);
}
const whatsapp = [];
for (const mapping of mappings) {
@@ -0,0 +1,60 @@
import { Knex } from 'knex';
export class WhatsAppAckRepository {
static tableName = 'app_chatwoot_whatsapp_acks';
constructor(
private readonly knex: Knex,
private readonly appPk: number,
) {}
get tableName() {
return WhatsAppAckRepository.tableName;
}
/**
* Saves the ack for a WhatsApp message, keeps the highest one
*/
async record(messageId: string, ack: number, timestamp: Date): Promise<void> {
const key = { app_pk: this.appPk, message_id: messageId };
await this.knex(this.tableName)
.insert({ ...key, ack: ack, timestamp: timestamp })
.onConflict(['app_pk', 'message_id'])
.ignore();
await this.knex(this.tableName)
.where(key)
.where('ack', '<', ack)
.update({ ack: ack });
}
/**
* The lowest ack across the messages, null if any message has no ack yet
*/
async minimumAck(messageIds: string[]): Promise<number | null> {
if (messageIds.length === 0) {
return null;
}
const rows = await this.knex(this.tableName)
.where({ app_pk: this.appPk })
.whereIn('message_id', messageIds)
.select('ack');
if (rows.length !== messageIds.length) {
return null;
}
return Math.min(...rows.map((row) => row.ack));
}
async deleteOlderThan(date: Date): Promise<number> {
return this.knex(this.tableName)
.where('app_pk', this.appPk)
.andWhere('timestamp', '<', date)
.del();
}
/**
* Deletes all rows for the app.
*/
async deleteAll(): Promise<number> {
return this.knex(this.tableName).where({ app_pk: this.appPk }).delete();
}
}
+2
View File
@@ -5,6 +5,7 @@ import { ChatwootMessageRepository } from './ChatwootMessageRepository';
import { MessageMappingRepository } from './MessageMappingRepository';
import { MessageMappingService } from './MessageMappingService';
import { ChatwootMessage, MessageMapping, WhatsAppMessage } from './types';
import { WhatsAppAckRepository } from './WhatsAppAckRepository';
import { WhatsAppMessageRepository } from './WhatsAppMessageRepository';
// Export all types
@@ -15,6 +16,7 @@ export {
AppRepository,
ChatwootMessageRepository,
MessageMappingRepository,
WhatsAppAckRepository,
WhatsAppMessageRepository,
};
+3
View File
@@ -15,6 +15,9 @@ export interface ChatWootCombinedKey {
export interface ChatwootMessage extends ChatWootCombinedKey {
id?: number;
timestamp: Date;
// WhatsApp messages sent for it (text + attachments)
// null - mirrored from WhatsApp
parts?: number | null;
}
export interface MessageMapping {
+42
View File
@@ -9,6 +9,7 @@ import {
GroupIdInput,
GroupJoinInput,
GroupMembershipApprovalInput,
GroupMemberShareHistoryInput,
GroupParticipantsInput,
GroupPictureInput,
GroupsListInput,
@@ -405,6 +406,47 @@ export class GroupTools extends McpController {
});
}
@Tool('groups-get-member-share-history', {
title: 'Get member share history setting',
description:
'Get whether all members or only admins can share message history with new members',
inputSchema: GroupIdInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getMemberShareHistory({ session, id }: z.infer<typeof GroupIdInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/groups/${id}/settings/security/member-share-history-mode`,
});
}
@Tool('groups-set-member-share-history', {
title: 'Set member share history setting',
description:
'Allow all members or only admins to share message history with new members',
inputSchema: GroupMemberShareHistoryInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async setMemberShareHistory({
session,
id,
...body
}: z.infer<typeof GroupMemberShareHistoryInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/${session}/groups/${id}/settings/security/member-share-history-mode`,
data: body,
});
}
@Tool('groups-get-join-requests', {
title: 'Get pending requests to join the group',
description: 'Get pending requests to join the group',
+8
View File
@@ -8,6 +8,7 @@ import {
JoinGroupRequest,
ParticipantsRequest,
SettingsMembershipApproval,
SettingsMemberShareHistoryMode,
SettingsSecurityChangeInfo,
SubjectRequest,
} from '@waha/structures/groups.dto';
@@ -72,3 +73,10 @@ export const GroupMembershipApprovalInput = DtoToZod(
session: SessionField,
id: GroupIdField,
});
export const GroupMemberShareHistoryInput = DtoToZod(
SettingsMemberShareHistoryMode,
).extend({
session: SessionField,
id: GroupIdField,
});
@@ -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;
@@ -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);
}
}
+3
View File
@@ -0,0 +1,3 @@
import { PhoneNumbersBaseConfig } from '@waha/apps/phone-numbers/dto/config.dto';
export class MexicanPhoneNumbersAppConfig extends PhoneNumbersBaseConfig {}
@@ -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;
@@ -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<PhoneNumbersBaseConfig> = {}) {
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();
});
});
@@ -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();
});
});
@@ -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'),
];
}
@@ -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<MexicanPhoneNumbersAppConfig> {
protected readonly Repository = MexicanPhoneNumbersCacheRepository;
constructor(resolver: UniqueAppResolver) {
super(resolver);
}
protected rules(config: MexicanPhoneNumbersAppConfig): PhoneNumberRule[] {
void config;
return MexicanPhoneNumberRules();
}
}
@@ -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';
}
@@ -1,51 +1,42 @@
import {
Controller,
Delete,
Get,
Param,
Query,
UnprocessableEntityException,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity } from '@nestjs/swagger';
import {
BrazilianPhoneCachePurgeResponse,
BrazilianPhoneCacheStatsResponse,
BrazilianPhoneDbCacheEntry,
BrazilianPhoneMemoryCacheEntry,
} from '@waha/apps/brazilian-phone-numbers/dto/cache.dto';
import {
BrazilianPhoneNumbersAppConfig,
DEFAULT_PERSISTENT_TTL,
} from '@waha/apps/brazilian-phone-numbers/dto/config.dto';
import { BrazilianPhoneCorePlugin } from '@waha/apps/brazilian-phone-numbers/plugins/BrazilianPhoneCorePlugin';
import { BrazilianPhoneNumbersAppService } from '@waha/apps/brazilian-phone-numbers/services/BrazilianPhoneNumbersAppService';
import { BrazilianPhoneCacheRepository } from '@waha/apps/brazilian-phone-numbers/storage/BrazilianPhoneCacheRepository';
import { ApiOperation } from '@nestjs/swagger';
import { AppName } from '@waha/apps/app_sdk/apps/apps';
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
import { AppDB } from '@waha/apps/app_sdk/storage/types';
import {
PhoneNumbersCachePurgeResponse,
PhoneNumbersCacheStatsResponse,
PhoneNumbersDbCacheEntry,
PhoneNumbersMemoryCacheEntry,
} from '@waha/apps/phone-numbers/dto/cache.dto';
import { PhoneNumbersCorePlugin } from '@waha/apps/phone-numbers/plugins/PhoneNumbersCorePlugin';
import { PhoneNumbersAppServiceBase } from '@waha/apps/phone-numbers/services/PhoneNumbersAppServiceBase';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { Action } from '@waha/core/auth/casl.types';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { SessionApiParam } from '@waha/nestjs/params/SessionApiParam';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import { parseDurationMs } from '@waha/nestjs/validation/IsDuration';
import { LimitOffsetParams } from '@waha/structures/pagination.dto';
import * as ms from 'ms';
const DEFAULT_LIMIT = 100;
@ApiSecurity('api_key')
@Controller('api/apps/brazilian-phone-numbers/:session')
@UseGuards(PoliciesGuard)
export class BrazilianPhoneNumbersController {
constructor(
private manager: SessionManager,
private resolver: UniqueAppResolver,
private appService: BrazilianPhoneNumbersAppService,
/**
* Cache endpoints shared by every phone numbers app, the app sets the route
*/
export abstract class PhoneNumbersCacheController {
protected constructor(
protected readonly manager: SessionManager,
protected readonly resolver: UniqueAppResolver,
protected readonly appService: PhoneNumbersAppServiceBase<any>,
protected readonly appName: AppName,
) {}
@Get('cache/memory')
@@ -61,9 +52,9 @@ export class BrazilianPhoneNumbersController {
async memory(
@Param('session') session: string,
@Query(new WAHAValidationPipe()) query: LimitOffsetParams,
): Promise<BrazilianPhoneMemoryCacheEntry[]> {
): Promise<PhoneNumbersMemoryCacheEntry[]> {
const app = await this.getApp(session);
const plugin = this.resolver.getPlugin(app, BrazilianPhoneCorePlugin);
const plugin = this.resolver.getPlugin(app, PhoneNumbersCorePlugin);
if (!plugin) {
throw new UnprocessableEntityException(
`Session '${session}' is not running - the in-memory cache is not available.`,
@@ -87,9 +78,9 @@ export class BrazilianPhoneNumbersController {
async db(
@Param('session') session: string,
@Query(new WAHAValidationPipe()) query: LimitOffsetParams,
): Promise<BrazilianPhoneDbCacheEntry[]> {
): Promise<PhoneNumbersDbCacheEntry[]> {
const app = await this.getApp(session);
if (!this.persistentEnabled(app)) {
if (!this.appService.persistentEnabled(app)) {
throw new UnprocessableEntityException(
`Persistent cache is disabled for the app in session '${session}'.`,
);
@@ -111,12 +102,12 @@ export class BrazilianPhoneNumbersController {
@UsePipes(new WAHAValidationPipe())
async stats(
@Param('session') session: string,
): Promise<BrazilianPhoneCacheStatsResponse> {
): Promise<PhoneNumbersCacheStatsResponse> {
const app = await this.getApp(session);
const plugin = this.resolver.getPlugin(app, BrazilianPhoneCorePlugin);
const plugin = this.resolver.getPlugin(app, PhoneNumbersCorePlugin);
const memory = plugin ? plugin.getMemoryCacheStats() : null;
let db = null;
if (this.persistentEnabled(app)) {
if (this.appService.persistentEnabled(app)) {
db = await this.repository(app).stats();
}
return { memory: memory, db: db };
@@ -134,30 +125,18 @@ export class BrazilianPhoneNumbersController {
@UsePipes(new WAHAValidationPipe())
async purge(
@Param('session') session: string,
): Promise<BrazilianPhoneCachePurgeResponse> {
): Promise<PhoneNumbersCachePurgeResponse> {
const app = await this.getApp(session);
const deleted = await this.appService.purgeCache(this.manager, app);
return { deleted: deleted };
}
private async getApp(session: string): Promise<AppDB> {
return await this.resolver.getEnabledApp(
session,
AppName.brazilianPhoneNumbers,
);
return await this.resolver.getEnabledApp(session, this.appName);
}
private persistentEnabled(app: AppDB): boolean {
const config = app.config as BrazilianPhoneNumbersAppConfig;
return config?.cache?.persistent ?? true;
}
private repository(app: AppDB): BrazilianPhoneCacheRepository {
private repository(app: AppDB) {
const knex = this.manager.store.getWAHADatabase();
const config = app.config as BrazilianPhoneNumbersAppConfig;
const ttlMs =
parseDurationMs(config?.cache?.persistentTtl) ??
ms(DEFAULT_PERSISTENT_TTL);
return new BrazilianPhoneCacheRepository(knex, app.pk, ttlMs);
return this.appService.repository(knex, app);
}
}
@@ -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 { PhoneNumbersCacheController } from '@waha/apps/phone-numbers/api/PhoneNumbersCacheController';
import { PhoneNumbersAppService } from '@waha/apps/phone-numbers/services/PhoneNumbersAppService';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
@ApiSecurity('api_key')
@Controller('api/apps/phone-numbers/:session')
@UseGuards(PoliciesGuard)
export class PhoneNumbersController extends PhoneNumbersCacheController {
constructor(
manager: SessionManager,
resolver: UniqueAppResolver,
appService: PhoneNumbersAppService,
) {
super(manager, resolver, appService, AppName.phoneNumbers);
}
}
+28
View File
@@ -0,0 +1,28 @@
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
import { AppName } from '@waha/apps/app_sdk/apps/apps';
import { PhoneNumbersController } from '@waha/apps/phone-numbers/api/phone-numbers.controller';
import { PhoneNumbersAppService } from '@waha/apps/phone-numbers/services/PhoneNumbersAppService';
const PhoneNumbersAppModule: AppModule = {
name: AppName.phoneNumbers,
openapi: {
title: 'Phone Numbers',
description:
'Resolve phone numbers matching the configured rules to the chat id WhatsApp knows before sending',
},
definition: {
plainkey: false,
queue: false,
migrations: true,
restartOnChange: true,
unique: true,
},
nestjs: {
imports: [],
controllers: [PhoneNumbersController],
providers: [PhoneNumbersAppService],
},
Service: PhoneNumbersAppService,
};
export default PhoneNumbersAppModule;
@@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
export class BrazilianPhoneMemoryCacheEntry {
export class PhoneNumbersMemoryCacheEntry {
@ApiProperty({
description: 'Phone number digits the cache entry is keyed by',
})
@@ -21,7 +21,7 @@ export class BrazilianPhoneMemoryCacheEntry {
expiresAt: Date | null;
}
export class BrazilianPhoneDbCacheEntry {
export class PhoneNumbersDbCacheEntry {
@ApiProperty({
description: 'Record id',
})
@@ -48,14 +48,14 @@ export class BrazilianPhoneDbCacheEntry {
resolvedAt: Date;
}
export class BrazilianPhoneMemoryCacheStats {
export class PhoneNumbersMemoryCacheStats {
@ApiProperty({
description: 'Number of entries in the in-memory cache',
})
total: number;
}
export class BrazilianPhoneDbCacheStats {
export class PhoneNumbersDbCacheStats {
@ApiProperty({
description: 'Total number of entries in the persistent cache',
})
@@ -67,24 +67,24 @@ export class BrazilianPhoneDbCacheStats {
verified: number;
}
export class BrazilianPhoneCacheStatsResponse {
export class PhoneNumbersCacheStatsResponse {
@ApiProperty({
description: 'In-memory cache stats; null when the session is not running',
nullable: true,
type: BrazilianPhoneMemoryCacheStats,
type: PhoneNumbersMemoryCacheStats,
})
memory: BrazilianPhoneMemoryCacheStats | null;
memory: PhoneNumbersMemoryCacheStats | null;
@ApiProperty({
description:
'Persistent cache stats; null when the persistent cache is disabled',
nullable: true,
type: BrazilianPhoneDbCacheStats,
type: PhoneNumbersDbCacheStats,
})
db: BrazilianPhoneDbCacheStats | null;
db: PhoneNumbersDbCacheStats | null;
}
export class BrazilianPhoneCachePurgeResponse {
export class PhoneNumbersCachePurgeResponse {
@ApiProperty({
description: 'Number of entries removed from the persistent cache',
})
@@ -12,7 +12,7 @@ import * as ms from 'ms';
export const DEFAULT_MEMORY_TTL: ms.StringValue = '24h';
export const DEFAULT_PERSISTENT_TTL: ms.StringValue = '31d';
export class BrazilianPhoneNumbersCacheConfig {
export class PhoneNumbersCacheConfig {
@ApiProperty({
description:
'TTL for resolved numbers in the in-memory cache tier, as a duration string.',
@@ -49,10 +49,13 @@ export class BrazilianPhoneNumbersCacheConfig {
persistentTtl?: string = DEFAULT_PERSISTENT_TTL;
}
export class BrazilianPhoneNumbersAppConfig {
/**
* Shared by every phone numbers app - the country ones add nothing on top
*/
export class PhoneNumbersBaseConfig {
@ApiProperty({
description:
'When a Brazilian mobile number is confirmed NOT to exist on WhatsApp: ' +
'When a number is confirmed NOT to exist on WhatsApp: ' +
'false (default) - warn and send the best-guess anyway; ' +
'true - reject the send with 422. Strict trades delivery for certainty ' +
'and can block valid sends on lookup false-negatives (throttling).',
@@ -78,11 +81,43 @@ export class BrazilianPhoneNumbersAppConfig {
@ApiProperty({
description: 'Cache tuning for resolved numbers.',
required: false,
type: BrazilianPhoneNumbersCacheConfig,
type: PhoneNumbersCacheConfig,
})
@IsOptional()
@ValidateNested()
@Type(() => BrazilianPhoneNumbersCacheConfig)
cache?: BrazilianPhoneNumbersCacheConfig =
new BrazilianPhoneNumbersCacheConfig();
@Type(() => PhoneNumbersCacheConfig)
cache?: PhoneNumbersCacheConfig = new PhoneNumbersCacheConfig();
}
export class PhoneNumbersRuleConfig {
@ApiProperty({
description:
'Handle numbers (digits only, no + or @c.us) matching this regexp - resolve them to the chat id WhatsApp knows.',
example: '^52',
})
@IsString()
regexp: string;
@ApiProperty({
description: 'Also check the replaced form ("^52(\\d{10})$" => "521$1").',
required: false,
example: '521$1',
})
@IsOptional()
@IsString()
replace?: string;
}
export class PhoneNumbersAppConfig extends PhoneNumbersBaseConfig {
@ApiProperty({
description:
'Which numbers to handle, first matching rule wins. No rules - handle every number.',
required: false,
type: [PhoneNumbersRuleConfig],
example: [{ regexp: '^52(\\d{10})$', replace: '521$1' }],
})
@IsOptional()
@ValidateNested({ each: true })
@Type(() => PhoneNumbersRuleConfig)
rules?: PhoneNumbersRuleConfig[];
}
@@ -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_phone_numbers_cache',
index: 'phone',
});
exports.up = migration.up;
exports.down = migration.down;
@@ -0,0 +1,90 @@
import { PhoneNumbersRuleConfig } from '@waha/apps/phone-numbers/dto/config.dto';
import {
buildPlugin as buildPhoneNumbersPlugin,
resolveChat,
stubLookup,
} from '@waha/apps/phone-numbers/plugins/testing';
import { RulesFromConfig } from '@waha/apps/phone-numbers/services/PhoneNumbersAppService';
function buildPlugin(rules?: PhoneNumbersRuleConfig[]) {
return buildPhoneNumbersPlugin({ rules: RulesFromConfig(rules) });
}
describe('PhoneNumbers - rules', () => {
it('handles every number via lookup when no rules are set', async () => {
const { session } = buildPlugin();
stubLookup(session, { numberExists: true, pn: '12132132130@c.us' });
const resolved = await resolveChat(session, '+12132132130');
expect(resolved).toBe('12132132130@c.us');
expect(session.checkNumberStatus).toHaveBeenCalledWith({
phone: '12132132130',
session: 'test',
});
// Cached now
const again = await resolveChat(session, '12132132130@c.us');
expect(again).toBe('12132132130@c.us');
expect(session.checkNumberStatus).toHaveBeenCalledTimes(1);
});
it('regexp rule handles only matching numbers', async () => {
const { session } = buildPlugin([{ regexp: '^52' }]);
stubLookup(session, { numberExists: true, chatId: '5212345678901@c.us' });
expect(await resolveChat(session, '12132132130@c.us')).toBe(
'12132132130@c.us',
);
expect(session.checkNumberStatus).not.toHaveBeenCalled();
expect(await resolveChat(session, '5212345678901@c.us')).toBe(
'5212345678901@c.us',
);
expect(session.checkNumberStatus).toHaveBeenCalledTimes(1);
});
it('regexp rule with replace checks the replaced form too', async () => {
const { session } = buildPlugin([
{ regexp: '^52(\\d{10})$', replace: '521$1' },
]);
session.checkNumberStatus = jest.fn(async ({ phone }) => ({
numberExists: phone === '5211234567890',
chatId: `${phone}@c.us`,
}));
const resolved = await resolveChat(session, '521234567890@c.us');
expect(resolved).toBe('5211234567890@c.us');
expect(session.checkNumberStatus).toHaveBeenCalledTimes(2);
// Both forms share the cache entry
expect(await resolveChat(session, '5211234567890@c.us')).toBe(
'5211234567890@c.us',
);
expect(session.checkNumberStatus).toHaveBeenCalledTimes(2);
});
it('first matching rule wins', async () => {
const { session } = buildPlugin([
{ regexp: '^55', replace: '55' },
{ regexp: '.*', replace: '1$&' },
]);
stubLookup(session, { numberExists: false });
await resolveChat(session, '5511991203123@c.us');
// Only the first rule's candidate was tried
expect(session.checkNumberStatus).toHaveBeenCalledTimes(1);
expect(session.checkNumberStatus).toHaveBeenCalledWith({
phone: '5511991203123',
session: 'test',
});
});
it('groups, lids and other non-phone chats are never touched', async () => {
const { session } = buildPlugin();
stubLookup(session, { numberExists: false });
expect(await resolveChat(session, '123@g.us')).toBe('123@g.us');
expect(await resolveChat(session, '123@lid')).toBe('123@lid');
expect(session.checkNumberStatus).not.toHaveBeenCalled();
});
});
@@ -1,26 +1,18 @@
import { UnprocessableEntityException } from '@nestjs/common';
import {
BrazilianPhoneMemoryCacheEntry,
BrazilianPhoneMemoryCacheStats,
} from '@waha/apps/brazilian-phone-numbers/dto/cache.dto';
PhoneNumbersMemoryCacheEntry,
PhoneNumbersMemoryCacheStats,
} from '@waha/apps/phone-numbers/dto/cache.dto';
import {
BrazilianPhoneNumbersAppConfig,
DEFAULT_MEMORY_TTL,
} from '@waha/apps/brazilian-phone-numbers/dto/config.dto';
import { BrazilianPhoneCacheRepository } from '@waha/apps/brazilian-phone-numbers/storage/BrazilianPhoneCacheRepository';
PhoneNumbersBaseConfig,
} from '@waha/apps/phone-numbers/dto/config.dto';
import { PhoneNumberRule } from '@waha/apps/phone-numbers/rules/PhoneNumberRule';
import { PhoneNumbersCacheRepository } from '@waha/apps/phone-numbers/storage/PhoneNumbersCacheRepository';
import {
BR_PHONE_NEGATIVE_CACHE_TTL_SECONDS,
extractPhoneDigits,
generateBrazilMobileLookupCandidates,
getBrazilPhoneCacheKeys,
isBrazilCountryCode,
isBrazilMobile,
isMalformedBrazilPhone,
needsBrazilWhatsAppLookup,
normalizeBrazilMobileForSendDigits,
normalizeBrazilTollFreeDigits,
shouldSkipBrazilPhoneNormalization,
} from '@waha/apps/brazilian-phone-numbers/utils/brPhone';
shouldSkipPhoneNormalization,
} from '@waha/apps/phone-numbers/utils/phone';
import { ensureSuffix, WhatsappSession } from '@waha/core/abc/session.abc';
import { SessionPlugin } from '@waha/core/abc/session.plugin';
import { PluginHook } from '@waha/core/abc/session.plugin.hooks';
@@ -62,26 +54,32 @@ const SEND_METHODS = new Set([
// Confirmed-negative marker in the memory cache ('' is not a valid chat id).
const NOT_FOUND = '';
export interface BrazilianPhoneCorePluginDeps {
repository: BrazilianPhoneCacheRepository | null;
// Unverified best-guesses and confirmed-negatives live only in the in-memory
// cache with this short TTL, so a number registered later is re-checked soon.
export const NEGATIVE_CACHE_TTL_SECONDS = 10 * 60;
export interface PhoneNumbersCorePluginDeps {
repository: PhoneNumbersCacheRepository | null;
rules: PhoneNumberRule[];
}
/**
* Resolves Brazilian phone numbers into the chat id the account is actually
* registered under (9th-digit ambiguity for DDD 31-99, static rule for
* DDD < 31, 0800 toll-free rewrite), tiered cheapest-first:
* Resolves phone numbers into the chat id the account is actually registered
* under. Which numbers and which forms to try come from the injected rules,
* the tiers are shared, cheapest-first:
*
* 1. in-memory cache, keyed by both forms of the number
* 2. static 9th-digit rule for DDD < 31 - no network
* 1. in-memory cache, keyed by every form of the number
* 2. deterministic rewrite from the rule - no network
* 3. persistent database cache (verified resolutions only)
* 4. local contact / LID store (engine-specific subclasses) - no network
* 5. WhatsApp lookup, single-flight so concurrent sends share one query
*/
export class BrazilianPhoneCorePlugin extends SessionPlugin<
BrazilianPhoneNumbersAppConfig,
BrazilianPhoneCorePluginDeps
export class PhoneNumbersCorePlugin extends SessionPlugin<
PhoneNumbersBaseConfig,
PhoneNumbersCorePluginDeps
> {
protected memory: NodeCache;
protected rules: PhoneNumberRule[];
// Single-flight guard: concurrent first-time resolutions of the same number
// share one in-flight WhatsApp lookup instead of each firing its own query.
private inflight: Map<string, Promise<string>> = new Map();
@@ -89,8 +87,8 @@ export class BrazilianPhoneCorePlugin extends SessionPlugin<
constructor(
session: WhatsappSession,
logger: Logger,
config: BrazilianPhoneNumbersAppConfig,
deps: BrazilianPhoneCorePluginDeps,
config: PhoneNumbersBaseConfig,
deps: PhoneNumbersCorePluginDeps,
) {
super(session, logger, config, deps);
const memoryTtlMs =
@@ -98,6 +96,7 @@ export class BrazilianPhoneCorePlugin extends SessionPlugin<
this.memory = new NodeCache({
stdTTL: Math.floor(memoryTtlMs / 1000),
});
this.rules = deps.rules;
}
@PluginHook((hooks) => hooks.wid.chat)
@@ -128,13 +127,13 @@ export class BrazilianPhoneCorePlugin extends SessionPlugin<
this.memory.flushAll();
}
public getMemoryCacheStats(): BrazilianPhoneMemoryCacheStats {
public getMemoryCacheStats(): PhoneNumbersMemoryCacheStats {
return { total: this.memory.keys().length };
}
// Entries sorted by key, so API pagination over them is stable.
public getMemoryCacheEntries(): BrazilianPhoneMemoryCacheEntry[] {
const entries: BrazilianPhoneMemoryCacheEntry[] = [];
public getMemoryCacheEntries(): PhoneNumbersMemoryCacheEntry[] {
const entries: PhoneNumbersMemoryCacheEntry[] = [];
for (const key of this.memory.keys().sort()) {
const chatId = this.memory.get<string>(key);
if (chatId === undefined) {
@@ -165,34 +164,33 @@ export class BrazilianPhoneCorePlugin extends SessionPlugin<
// re-adding '@c.us' to LID digits builds an id that addresses nobody.
protected async resolve(wid: string, validate: boolean): Promise<string> {
const withSuffix = ensureSuffix(wid);
if (shouldSkipBrazilPhoneNormalization(withSuffix)) {
if (shouldSkipPhoneNormalization(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);
}
if (!isBrazilCountryCode(digits)) {
const rule = this.rules.find((r) => r.matches(digits));
if (!rule) {
return withSuffix;
}
// Malformed Brazilian numbers (e.g. 55859912): hard error only on the send
// path; local-only ops just pass it through untouched.
if (isMalformedBrazilPhone(digits)) {
const resolution = rule.resolve(digits);
// Malformed: hard error only on the send path, local-only ops pass it through
if (!resolution) {
if (validate) {
throw new UnprocessableEntityException(
`Invalid Brazilian phone number '${withSuffix}'.`,
`Invalid phone number '${withSuffix}'.`,
);
}
return withSuffix;
}
// Landlines and already-valid non-mobile numbers are left untouched.
if (!isBrazilMobile(digits)) {
return withSuffix;
const candidates = resolution.candidates;
const fallback = ensureSuffix(resolution.fallback);
// Nothing to check - the rule already knows the form
if (candidates.length === 0) {
if (fallback !== withSuffix) {
this.cacheInMemory(digits, [], fallback);
}
return fallback;
}
// Tier 1: in-memory cache. undefined = miss, '' = confirmed-negative
@@ -202,7 +200,7 @@ export class BrazilianPhoneCorePlugin extends SessionPlugin<
if (cached === NOT_FOUND) {
if (validate) {
throw new UnprocessableEntityException(
`Brazilian mobile phone number '${withSuffix}' does not exist on WhatsApp.`,
`Phone number '${withSuffix}' does not exist on WhatsApp.`,
);
}
return withSuffix;
@@ -210,30 +208,19 @@ export class BrazilianPhoneCorePlugin extends SessionPlugin<
return cached;
}
// DDD below the lookup range: static 9th-digit rule, no network needed.
if (!needsBrazilWhatsAppLookup(digits)) {
const normalized = ensureSuffix(
normalizeBrazilMobileForSendDigits(digits),
);
this.cacheInMemory(digits, normalized);
return normalized;
}
// Tier 2: database cache (verified resolutions only).
const fromDb = await this.getFromDb(digits);
if (fromDb) {
this.cacheInMemory(digits, fromDb);
this.cacheInMemory(digits, candidates, fromDb);
return fromDb;
}
const candidates = generateBrazilMobileLookupCandidates(digits);
// Tier 3: local contact/LID store (engine-specific), no network.
const fromStore = await this.lookupKnownChatId(candidates);
if (fromStore) {
await this.cacheResolved(digits, fromStore);
await this.cacheResolved(digits, candidates, fromStore);
this.logger.debug(
`BR mobile '${withSuffix}' resolved locally to '${fromStore}' (no WhatsApp lookup).`,
`'${withSuffix}' resolved locally to '${fromStore}' (no WhatsApp lookup).`,
);
return fromStore;
}
@@ -248,15 +235,21 @@ export class BrazilianPhoneCorePlugin extends SessionPlugin<
}
// Tier 4: WhatsApp lookup as last resort, de-duplicated via single-flight.
return await this.resolveViaWhatsApp(digits, withSuffix, candidates);
return await this.resolveViaWhatsApp(
digits,
withSuffix,
candidates,
fallback,
);
}
private resolveViaWhatsApp(
digits: string,
withSuffix: string,
candidates: string[],
fallback: string,
): Promise<string> {
const key = getBrazilPhoneCacheKeys(digits).sort().join('|');
const key = cacheKeys(digits, candidates).sort().join('|');
const inflight = this.inflight.get(key);
if (inflight) {
return inflight;
@@ -265,6 +258,7 @@ export class BrazilianPhoneCorePlugin extends SessionPlugin<
digits,
withSuffix,
candidates,
fallback,
).finally(() => this.inflight.delete(key));
this.inflight.set(key, promise);
return promise;
@@ -274,9 +268,10 @@ export class BrazilianPhoneCorePlugin extends SessionPlugin<
digits: string,
withSuffix: string,
candidates: string[],
fallback: string,
): Promise<string> {
this.logger.debug(
`BR mobile '${withSuffix}' not found locally, performing WhatsApp lookup for: ${candidates.join(
`'${withSuffix}' not found locally, performing WhatsApp lookup for: ${candidates.join(
', ',
)}`,
);
@@ -291,7 +286,7 @@ export class BrazilianPhoneCorePlugin extends SessionPlugin<
} catch (error) {
lookupFailed = true;
this.logger.warn(
`Failed to verify Brazilian mobile candidate '${candidate}': ${error}`,
`Failed to verify phone number candidate '${candidate}': ${error}`,
);
continue;
}
@@ -300,7 +295,7 @@ export class BrazilianPhoneCorePlugin extends SessionPlugin<
// routable, and it must not be reduced to digits).
const chatId = result?.pn || result?.chatId;
if (result?.numberExists && chatId) {
await this.cacheResolved(digits, chatId);
await this.cacheResolved(digits, candidates, chatId);
return chatId;
}
}
@@ -308,7 +303,7 @@ export class BrazilianPhoneCorePlugin extends SessionPlugin<
// 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(
`Could not validate phone number '${withSuffix}', sending as-is. Tried: ${candidates.join(
', ',
)}`,
);
@@ -319,11 +314,12 @@ export class BrazilianPhoneCorePlugin extends SessionPlugin<
if (this.config.strict) {
this.cacheInMemory(
digits,
candidates,
NOT_FOUND,
BR_PHONE_NEGATIVE_CACHE_TTL_SECONDS,
NEGATIVE_CACHE_TTL_SECONDS,
);
throw new UnprocessableEntityException(
`Brazilian mobile phone number '${withSuffix}' does not exist on WhatsApp. Tried: ${candidates.join(
`Phone number '${withSuffix}' does not exist on WhatsApp. Tried: ${candidates.join(
', ',
)}`,
);
@@ -331,37 +327,50 @@ export class BrazilianPhoneCorePlugin extends SessionPlugin<
// Soft (default): warn and send the best-guess anyway, so a lookup
// false-negative never blocks a valid send. Short TTL - the number may
// get registered later.
const bestGuess = ensureSuffix(normalizeBrazilMobileForSendDigits(digits));
this.cacheInMemory(digits, bestGuess, BR_PHONE_NEGATIVE_CACHE_TTL_SECONDS);
this.cacheInMemory(
digits,
candidates,
fallback,
NEGATIVE_CACHE_TTL_SECONDS,
);
this.logger.warn(
`Brazilian mobile number '${withSuffix}' not found on WhatsApp, sending best-guess '${bestGuess}'. Tried: ${candidates.join(
`Phone number '${withSuffix}' not found on WhatsApp, sending '${fallback}'. Tried: ${candidates.join(
', ',
)}`,
);
return bestGuess;
return fallback;
}
// Cache under every form of the number, so both '5585...' and '55859...'
// hit the same entry. Default TTL for resolutions, the short negative TTL
// for best-guesses and confirmed-negatives.
private cacheInMemory(digits: string, chatId: string, ttl?: number): void {
for (const key of getBrazilPhoneCacheKeys(digits)) {
private cacheInMemory(
digits: string,
candidates: string[],
chatId: string,
ttl?: number,
): void {
for (const key of cacheKeys(digits, candidates)) {
this.memory.set(key, chatId, ttl);
}
}
// Verified resolution: memory plus the database tier (when enabled).
private async cacheResolved(digits: string, chatId: string): Promise<void> {
this.cacheInMemory(digits, chatId);
private async cacheResolved(
digits: string,
candidates: string[],
chatId: string,
): Promise<void> {
this.cacheInMemory(digits, candidates, chatId);
if (!this.deps.repository) {
return;
}
try {
const keys = getBrazilPhoneCacheKeys(digits);
const keys = cacheKeys(digits, candidates);
await this.deps.repository.setMany(keys, chatId, true, new Date());
} catch (error) {
this.logger.warn(
`Failed to persist BR phone resolution for '${digits}': ${error}`,
`Failed to persist phone number resolution for '${digits}': ${error}`,
);
}
}
@@ -375,9 +384,13 @@ export class BrazilianPhoneCorePlugin extends SessionPlugin<
return entry?.chatId ?? null;
} catch (error) {
this.logger.warn(
`Failed to read BR phone cache for '${digits}': ${error}`,
`Failed to read phone number cache for '${digits}': ${error}`,
);
return null;
}
}
}
function cacheKeys(digits: string, candidates: string[]): string[] {
return [...new Set([digits, ...candidates])];
}
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff. Show more