Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90ba445a0e | ||
|
|
bf0f49b2e3 | ||
|
|
c9ac14e1a4 | ||
|
|
c4f224b7a6 | ||
|
|
0da9ef68cf | ||
|
|
cc650fca3b | ||
|
|
9c080611b6 | ||
|
|
235ec01d8b | ||
|
|
ce1e42bb17 | ||
|
|
7440cfc72c | ||
|
|
dbb6be3814 | ||
|
|
9035dd95ff | ||
|
|
9a70c50f62 | ||
|
|
7d7d331195 | ||
|
|
55a7d78e3f | ||
|
|
377a2d55c9 | ||
|
|
82046700d7 | ||
|
|
0dc745bab9 | ||
|
|
3a52c7dc19 | ||
|
|
dcb4f5f2c0 | ||
|
|
a48247c2d2 | ||
|
|
0e73561b8e | ||
|
|
3ac4ab0746 | ||
|
|
1c6081a871 | ||
|
|
f1f25cf812 | ||
|
|
8b73f51267 | ||
|
|
b1a87b435a | ||
|
|
5c04d95da7 | ||
|
|
7b6cf27081 | ||
|
|
d86ea302cb | ||
|
|
4f0b255b96 | ||
|
|
def74cd4da | ||
|
|
3a4cb909fd | ||
|
|
495866fc8f | ||
|
|
408128a537 | ||
|
|
49878f943d | ||
|
|
ee6a952490 | ||
|
|
8e36c7a14c | ||
|
|
b5702f1a48 | ||
|
|
300f300a02 | ||
|
|
254906cf4c | ||
|
|
f51a14eaeb | ||
|
|
bf286f406e | ||
|
|
164706ff61 | ||
|
|
86d2816bed | ||
|
|
ae8f68e74f | ||
|
|
a27180f212 | ||
|
|
d0cf4c4f30 | ||
|
|
aa0663b187 |
No files matched your search
+1
-1
@@ -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 \
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
+24
-67
@@ -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',
|
||||
+7
-5
@@ -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', () => {
|
||||
+34
-46
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" للاطلاع على المزيد من التفاصيل.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" را ارسال کنید.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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" भेजें।
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" ਭੇਜੋ।
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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", чтобы увидеть подробности.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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" بھیجیں۔
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
+29
-50
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
+10
-10
@@ -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',
|
||||
})
|
||||
+42
-7
@@ -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();
|
||||
});
|
||||
});
|
||||
+95
-82
@@ -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
Reference in new issue
Block a user