Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78186c2bfd | ||
|
|
ec2d8d9ddd | ||
|
|
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 | ||
|
|
8ab642b20d | ||
|
|
93fa55b7b7 | ||
|
|
2881458baf | ||
|
|
2595ccfe7b | ||
|
|
b7c4eac93b | ||
|
|
89543ab371 | ||
|
|
86aba9ea94 | ||
|
|
6d25680d43 | ||
|
|
068658df23 | ||
|
|
69306942ee | ||
|
|
e968b49349 | ||
|
|
6dc19229ae | ||
|
|
63d56b8467 | ||
|
|
53687647e9 | ||
|
|
fdf0589b68 | ||
|
|
f32dace3f8 | ||
|
|
ed056cb1f6 | ||
|
|
0e9b02d92c | ||
|
|
fc556dad77 | ||
|
|
5780bc09f9 | ||
|
|
d79940e494 | ||
|
|
1df6ec68db | ||
|
|
a06a72901a | ||
|
|
7e859b4ca9 | ||
|
|
9490a05586 | ||
|
|
b244115600 | ||
|
|
287d52fe1a | ||
|
|
21efcf07b1 | ||
|
|
aaa0eb12d5 | ||
|
|
1c5ece5a86 | ||
|
|
061601826e | ||
|
|
af51c0ae65 | ||
|
|
16b50c672b | ||
|
|
dbda9c0457 | ||
|
|
ff30e5ca90 | ||
|
|
2770c649c6 | ||
|
|
61f1f71343 | ||
|
|
99e4dfcfb5 | ||
|
|
cc12380e48 | ||
|
|
b5e223a333 | ||
|
|
f6db89c141 | ||
|
|
d2545c2de6 | ||
|
|
12a774c864 | ||
|
|
186847a455 | ||
|
|
8c44b70109 |
No files matched your search
@@ -56,6 +56,20 @@ WAHA_LOG_LEVEL=info
|
|||||||
# Don't print QR codes in logs
|
# Don't print QR codes in logs
|
||||||
WAHA_PRINT_QR=False
|
WAHA_PRINT_QR=False
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# ===== PROMETHEUS =====
|
||||||
|
# ======================
|
||||||
|
# Prometheus metrics endpoint - GET /metrics
|
||||||
|
# WAHA_PROMETHEUS_ENABLED=True
|
||||||
|
# WAHA_PROMETHEUS_PATH=/metrics
|
||||||
|
# WAHA_PROMETHEUS_METRIC_PREFIX=waha_
|
||||||
|
# WAHA_PROMETHEUS_HTTP_DURATION_BUCKETS=0.005,0.01,0.025,0.05,0.1,0.25,0.5,1,2.5,5,10,30
|
||||||
|
# Events to count in waha_events_total ('*' for all) - the server actively processes listed events even with no webhooks
|
||||||
|
# WAHA_PROMETHEUS_TRACK_EVENTS=message.any
|
||||||
|
# Optional basic auth for the metrics endpoint (both must be set)
|
||||||
|
# WAHA_PROMETHEUS_USERNAME=admin
|
||||||
|
# WAHA_PROMETHEUS_PASSWORD=secret
|
||||||
|
|
||||||
# =========================
|
# =========================
|
||||||
# ===== MEDIA STORAGE =====
|
# ===== MEDIA STORAGE =====
|
||||||
# =========================
|
# =========================
|
||||||
@@ -64,7 +78,17 @@ WAHA_MEDIA_STORAGE=LOCAL
|
|||||||
WHATSAPP_FILES_LIFETIME=0
|
WHATSAPP_FILES_LIFETIME=0
|
||||||
WHATSAPP_FILES_FOLDER=/app/.media
|
WHATSAPP_FILES_FOLDER=/app/.media
|
||||||
|
|
||||||
|
#
|
||||||
# Media download settings
|
# Media download settings
|
||||||
|
#
|
||||||
|
# Events (webhooks, websockets)
|
||||||
|
# WAHA_EVENTS_DOWNLOAD_MEDIA=true
|
||||||
|
# WAHA_EVENTS_DOWNLOAD_MEDIA_MIMETYPES=image/jpeg,image/png
|
||||||
|
# API default behavior (GET /api/{session}/chats/{chatId}/messages, etc.)
|
||||||
|
# WAHA_API_DOWNLOAD_MEDIA=true
|
||||||
|
# WAHA_API_DOWNLOAD_MEDIA_MIMETYPES=image/jpeg,image/png
|
||||||
|
|
||||||
|
# DEPRECATED - use WAHA_EVENTS_* and WAHA_API_* variables above
|
||||||
# WHATSAPP_DOWNLOAD_MEDIA=true
|
# WHATSAPP_DOWNLOAD_MEDIA=true
|
||||||
# WHATSAPP_FILES_MIMETYPES=image/jpeg,image/png
|
# WHATSAPP_FILES_MIMETYPES=image/jpeg,image/png
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -179,7 +179,7 @@ RUN if [ "$USE_BROWSER" = "chromium" ]; then \
|
|||||||
# Install Chrome
|
# Install Chrome
|
||||||
# Available versions:
|
# Available versions:
|
||||||
# https://www.ubuntuupdates.org/package/google_chrome/stable/main/base/google-chrome-stable
|
# 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"
|
ARG OPUSTAGS_VERSION="1.10.1"
|
||||||
RUN if [ "$USE_BROWSER" = "chrome" ]; then \
|
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 \
|
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:
|
up-webjs:
|
||||||
yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2026-06-26
|
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:
|
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-team/wppconnect@github:wppconnect-team/wppconnect#master
|
||||||
SHARP_IGNORE_GLOBAL_LIBVIPS=1 yarn up @wppconnect/wa-js@github:wppconnect-team/wa-js#main
|
SHARP_IGNORE_GLOBAL_LIBVIPS=1 yarn up @wppconnect/wa-js@github:wppconnect-team/wa-js#main
|
||||||
|
|||||||
@@ -60,7 +60,17 @@ WAHA_MEDIA_STORAGE=LOCAL
|
|||||||
WHATSAPP_FILES_LIFETIME=1800
|
WHATSAPP_FILES_LIFETIME=1800
|
||||||
WHATSAPP_FILES_FOLDER=/app/.media
|
WHATSAPP_FILES_FOLDER=/app/.media
|
||||||
|
|
||||||
|
#
|
||||||
# Media download settings
|
# Media download settings
|
||||||
|
#
|
||||||
|
# Events (webhooks, websockets)
|
||||||
|
# WAHA_EVENTS_DOWNLOAD_MEDIA=true
|
||||||
|
# WAHA_EVENTS_DOWNLOAD_MEDIA_MIMETYPES=image/jpeg,image/png
|
||||||
|
# API default behavior (GET /api/{session}/chats/{chatId}/messages, etc.)
|
||||||
|
# WAHA_API_DOWNLOAD_MEDIA=true
|
||||||
|
# WAHA_API_DOWNLOAD_MEDIA_MIMETYPES=image/jpeg,image/png
|
||||||
|
|
||||||
|
# DEPRECATED - use WAHA_EVENTS_* and WAHA_API_* variables above
|
||||||
# WHATSAPP_DOWNLOAD_MEDIA=true
|
# WHATSAPP_DOWNLOAD_MEDIA=true
|
||||||
# WHATSAPP_FILES_MIMETYPES=image/jpeg,image/png
|
# WHATSAPP_FILES_MIMETYPES=image/jpeg,image/png
|
||||||
|
|
||||||
|
|||||||
+13
-5
@@ -54,6 +54,12 @@
|
|||||||
"@nestjs/swagger": "^7.1.11",
|
"@nestjs/swagger": "^7.1.11",
|
||||||
"@nestjs/terminus": "^10.2.3",
|
"@nestjs/terminus": "^10.2.3",
|
||||||
"@nestjs/websockets": "^11.0.0",
|
"@nestjs/websockets": "^11.0.0",
|
||||||
|
"@opentelemetry/api": "^1.9.1",
|
||||||
|
"@opentelemetry/instrumentation-http": "^0.221.0",
|
||||||
|
"@opentelemetry/instrumentation-pino": "^0.67.0",
|
||||||
|
"@opentelemetry/sdk-node": "^0.221.0",
|
||||||
|
"@opentelemetry/sdk-trace": "^2.10.0",
|
||||||
|
"@prometheus-io/client": "^0.16.1",
|
||||||
"@types/better-sqlite3": "^7.6.10",
|
"@types/better-sqlite3": "^7.6.10",
|
||||||
"@types/lodash": "^4.14.194",
|
"@types/lodash": "^4.14.194",
|
||||||
"@types/mustache": "^4.2.5",
|
"@types/mustache": "^4.2.5",
|
||||||
@@ -66,7 +72,7 @@
|
|||||||
"agentkeepalive": "^4.5.0",
|
"agentkeepalive": "^4.5.0",
|
||||||
"async-lock": "^1.4.1",
|
"async-lock": "^1.4.1",
|
||||||
"audio-decode": "^2.2.2",
|
"audio-decode": "^2.2.2",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.19.0",
|
||||||
"axios-retry": "^4.5.0",
|
"axios-retry": "^4.5.0",
|
||||||
"better-sqlite3": "^12.4.1",
|
"better-sqlite3": "^12.4.1",
|
||||||
"bullmq": "^5.48.1",
|
"bullmq": "^5.48.1",
|
||||||
@@ -105,7 +111,7 @@
|
|||||||
"pino-pretty": "^11.2.1",
|
"pino-pretty": "^11.2.1",
|
||||||
"pretty-bytes": "5.6.0",
|
"pretty-bytes": "5.6.0",
|
||||||
"promise-retry": "^2.0.1",
|
"promise-retry": "^2.0.1",
|
||||||
"puppeteer": "^24.31.0",
|
"puppeteer": "^25.10.0",
|
||||||
"qrcode": "^1.5.1",
|
"qrcode": "^1.5.1",
|
||||||
"qrcode-terminal": "^0.12.0",
|
"qrcode-terminal": "^0.12.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
@@ -115,6 +121,7 @@
|
|||||||
"shell-quote": "^1.8.3",
|
"shell-quote": "^1.8.3",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"swagger-ui-express": "^4.1.4",
|
"swagger-ui-express": "^4.1.4",
|
||||||
|
"tapable": "^2.2.2",
|
||||||
"ulid": "^2.3.0",
|
"ulid": "^2.3.0",
|
||||||
"undici": "^7.16.0",
|
"undici": "^7.16.0",
|
||||||
"uniqid": "^5.4.0",
|
"uniqid": "^5.4.0",
|
||||||
@@ -131,10 +138,11 @@
|
|||||||
"typescript": "5.9.2",
|
"typescript": "5.9.2",
|
||||||
"bufferutil": "^4.0.8",
|
"bufferutil": "^4.0.8",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"puppeteer": "^24.31.0",
|
"puppeteer": "^25.10.0",
|
||||||
"whatwg-url": "13.0.0",
|
"whatwg-url": "13.0.0",
|
||||||
"axios": "^1.9.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": {
|
"devDependencies": {
|
||||||
"@grpc/grpc-js": "^1.14.1",
|
"@grpc/grpc-js": "^1.14.1",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
UsePipes,
|
UsePipes,
|
||||||
ValidationPipe,
|
ValidationPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
import { ApiBody, ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||||
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
|
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
|
||||||
import {
|
import {
|
||||||
GetChatMessagesFilter,
|
GetChatMessagesFilter,
|
||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
MessageReactionRequest,
|
MessageReactionRequest,
|
||||||
MessageReplyRequest,
|
MessageReplyRequest,
|
||||||
MessageStarRequest,
|
MessageStarRequest,
|
||||||
|
MessageStickerRequest,
|
||||||
MessageTextQuery,
|
MessageTextQuery,
|
||||||
MessageTextRequest,
|
MessageTextRequest,
|
||||||
MessageVideoRequest,
|
MessageVideoRequest,
|
||||||
@@ -145,6 +146,46 @@ export class ChattingController {
|
|||||||
return whatsapp.sendVideo(request);
|
return whatsapp.sendVideo(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('/sendSticker')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Send a sticker',
|
||||||
|
description:
|
||||||
|
'The image will not be converted. It must already be in WebP format. PNG and JPEG are not supported.',
|
||||||
|
})
|
||||||
|
@ApiBody({
|
||||||
|
type: MessageStickerRequest,
|
||||||
|
examples: {
|
||||||
|
url: {
|
||||||
|
summary: 'From URL',
|
||||||
|
value: {
|
||||||
|
session: 'default',
|
||||||
|
chatId: '11111111111@c.us',
|
||||||
|
reply_to: null,
|
||||||
|
file: {
|
||||||
|
url: 'https://www.gstatic.com/webp/gallery/1.webp',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
base64webp: {
|
||||||
|
summary: 'From base64 WebP',
|
||||||
|
value: {
|
||||||
|
session: 'default',
|
||||||
|
chatId: '11111111111@c.us',
|
||||||
|
reply_to: null,
|
||||||
|
file: {
|
||||||
|
mimetype: 'image/webp',
|
||||||
|
data: 'your-base64-data-of-webp',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||||
|
async sendSticker(@Body() request: MessageStickerRequest) {
|
||||||
|
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||||
|
return whatsapp.sendSticker(request);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('/send/link-custom-preview')
|
@Post('/send/link-custom-preview')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Send a text message with a CUSTOM link preview.',
|
summary: 'Send a text message with a CUSTOM link preview.',
|
||||||
@@ -255,6 +296,7 @@ export class ChattingController {
|
|||||||
description: 'You can use it as buttons or list replacement',
|
description: 'You can use it as buttons or list replacement',
|
||||||
})
|
})
|
||||||
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
@CheckPolicies(CanSession(Action.Send, FromBody('session')))
|
||||||
|
@UsePipes(new WAHAValidationPipe())
|
||||||
async sendPoll(@Body() request: MessagePollRequest) {
|
async sendPoll(@Body() request: MessagePollRequest) {
|
||||||
const whatsapp = await this.manager.getWorkingSession(request.session);
|
const whatsapp = await this.manager.getWorkingSession(request.session);
|
||||||
return whatsapp.sendPoll(request);
|
return whatsapp.sendPoll(request);
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import {
|
|||||||
CreateGroupRequest,
|
CreateGroupRequest,
|
||||||
DescriptionRequest,
|
DescriptionRequest,
|
||||||
GroupField,
|
GroupField,
|
||||||
|
GroupJoinRequest,
|
||||||
|
GroupJoinRequestResult,
|
||||||
GroupParticipant,
|
GroupParticipant,
|
||||||
GroupsListFields,
|
GroupsListFields,
|
||||||
GroupsPaginationParams,
|
GroupsPaginationParams,
|
||||||
@@ -39,6 +41,8 @@ import {
|
|||||||
JoinGroupResponse,
|
JoinGroupResponse,
|
||||||
ParticipantsRequest,
|
ParticipantsRequest,
|
||||||
SettingsMemberAddMode,
|
SettingsMemberAddMode,
|
||||||
|
SettingsMemberShareHistoryMode,
|
||||||
|
SettingsMembershipApproval,
|
||||||
SettingsSecurityChangeInfo,
|
SettingsSecurityChangeInfo,
|
||||||
SubjectRequest,
|
SubjectRequest,
|
||||||
} from '../structures/groups.dto';
|
} from '../structures/groups.dto';
|
||||||
@@ -343,6 +347,126 @@ export class GroupsController {
|
|||||||
return session.getMemberAddMode(id);
|
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
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Update settings - approve new members',
|
||||||
|
description:
|
||||||
|
'Enables or disables admin approval for users requesting to join the group.',
|
||||||
|
})
|
||||||
|
setMembershipApprovalMode(
|
||||||
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() request: SettingsMembershipApproval,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return session.setMembershipApprovalMode(
|
||||||
|
id,
|
||||||
|
request.newMembersApprovalRequired,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/settings/security/membership-approval')
|
||||||
|
@SessionApiParam
|
||||||
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Get settings - approve new members',
|
||||||
|
description:
|
||||||
|
'Returns whether admin approval is required for users requesting to join the group.',
|
||||||
|
})
|
||||||
|
getMembershipApprovalMode(
|
||||||
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
|
@Param('id') id: string,
|
||||||
|
): Promise<SettingsMembershipApproval> {
|
||||||
|
return session.getMembershipApprovalMode(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/participants/join-requests')
|
||||||
|
@SessionApiParam
|
||||||
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Get pending requests to join the group',
|
||||||
|
})
|
||||||
|
getGroupJoinRequests(
|
||||||
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
|
@Param('id') id: string,
|
||||||
|
): Promise<GroupJoinRequest[]> {
|
||||||
|
return session.getGroupJoinRequests(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/participants/join-requests/approve')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@SessionApiParam
|
||||||
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Approve pending requests to join the group',
|
||||||
|
})
|
||||||
|
approveGroupJoinRequests(
|
||||||
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() request: ParticipantsRequest,
|
||||||
|
): Promise<GroupJoinRequestResult[]> {
|
||||||
|
return session.approveGroupJoinRequests(id, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/participants/join-requests/reject')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@SessionApiParam
|
||||||
|
@GroupIdApiParam
|
||||||
|
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Reject pending requests to join the group',
|
||||||
|
})
|
||||||
|
rejectGroupJoinRequests(
|
||||||
|
@WorkingSessionParam session: WhatsappSession,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() request: ParticipantsRequest,
|
||||||
|
): Promise<GroupJoinRequestResult[]> {
|
||||||
|
return session.rejectGroupJoinRequests(id, request);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id/invite-code')
|
@Get(':id/invite-code')
|
||||||
@SessionApiParam
|
@SessionApiParam
|
||||||
@GroupIdApiParam
|
@GroupIdApiParam
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
SessionExpand,
|
SessionExpand,
|
||||||
SessionInfo,
|
SessionInfo,
|
||||||
SessionInfoQuery,
|
SessionInfoQuery,
|
||||||
|
SessionLogoutRequest,
|
||||||
SessionUpdateRequest,
|
SessionUpdateRequest,
|
||||||
} from '../structures/sessions.dto';
|
} from '../structures/sessions.dto';
|
||||||
import { SessionExamples } from './sessions.examples';
|
import { SessionExamples } from './sessions.examples';
|
||||||
@@ -203,10 +204,14 @@ class SessionsController {
|
|||||||
summary: 'Logout from the session',
|
summary: 'Logout from the session',
|
||||||
description: 'Logout the session, restart a session if it was not STOPPED',
|
description: 'Logout the session, restart a session if it was not STOPPED',
|
||||||
})
|
})
|
||||||
|
@ApiBody({ type: SessionLogoutRequest, required: false })
|
||||||
@CheckPolicies(CanSession(Action.Control, FromParam('session')))
|
@CheckPolicies(CanSession(Action.Control, FromParam('session')))
|
||||||
@UsePipes(new WAHAValidationPipe())
|
@UsePipes(new WAHAValidationPipe())
|
||||||
async logout(@Param('session') name: string): Promise<SessionDTO> {
|
async logout(
|
||||||
return this.sessionService.logoutSession(name);
|
@Param('session') name: string,
|
||||||
|
@Body() request?: SessionLogoutRequest,
|
||||||
|
): Promise<SessionDTO> {
|
||||||
|
return this.sessionService.logoutSession(name, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':session/restart')
|
@Post(':session/restart')
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AppName } from '@waha/apps/app_sdk/apps/name';
|
import { AppName } from '@waha/apps/app_sdk/apps/apps';
|
||||||
|
|
||||||
export const SessionExamples = {
|
export const SessionExamples = {
|
||||||
basic: {
|
basic: {
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export class WebsocketGatewayCore
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(`New client connected: ${request.url} - ${socket.id}`);
|
this.logger.debug(`New client connected: ${request.url} - ${socket.id}`);
|
||||||
const events: WAHAEvents[] = params.events;
|
const events = params.events as WAHAEvents[];
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Client connected to session: '${session}', events: ${events}, ${socket.id}`,
|
`Client connected to session: '${session}', events: ${events}, ${socket.id}`,
|
||||||
);
|
);
|
||||||
@@ -131,8 +131,13 @@ export class WebsocketGatewayCore
|
|||||||
const paramsEvents = query.getAll('events');
|
const paramsEvents = query.getAll('events');
|
||||||
const eventsRaw = paramsEvents.length > 0 ? paramsEvents : ['*'];
|
const eventsRaw = paramsEvents.length > 0 ? paramsEvents : ['*'];
|
||||||
const eventsList = eventsRaw.flatMap((value) => value.split(','));
|
const eventsList = eventsRaw.flatMap((value) => value.split(','));
|
||||||
const events = this.eventUnmask.unmask(eventsList);
|
const result = this.eventUnmask.unmask(eventsList);
|
||||||
return { session, events };
|
if (result.unknown.length > 0) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Ignoring unknown websocket events: ${result.unknown.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { session, events: result.events };
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDisconnect(socket: WebSocket): any {
|
handleDisconnect(socket: WebSocket): any {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export class JobLoggerWrapper implements ILogger {
|
|||||||
this.job
|
this.job
|
||||||
.log(`[${timestamp}] ${level.toUpperCase()}: ${msg}`)
|
.log(`[${timestamp}] ${level.toUpperCase()}: ${msg}`)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
this.logger.error('Error logging message to job', err);
|
this.logger.error(err, 'Error logging message to job');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,9 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
UsePipes,
|
UsePipes,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiParam, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { AppName } from '@waha/apps/app_sdk/apps/apps';
|
||||||
|
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
|
||||||
import {
|
import {
|
||||||
AppsService,
|
AppsService,
|
||||||
IAppsService,
|
IAppsService,
|
||||||
@@ -26,12 +28,13 @@ import {
|
|||||||
CanServer,
|
CanServer,
|
||||||
CanSession,
|
CanSession,
|
||||||
FromBody,
|
FromBody,
|
||||||
|
FromParam,
|
||||||
FromQuery,
|
FromQuery,
|
||||||
} from '@waha/core/auth/policies';
|
} from '@waha/core/auth/policies';
|
||||||
import { Action, session as SessionName } from '@waha/core/auth/casl.types';
|
import { Action, session as SessionName } from '@waha/core/auth/casl.types';
|
||||||
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
|
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
|
||||||
|
|
||||||
import { APPS } from '../apps/definition';
|
import { GetApp } from '../apps/registry';
|
||||||
import { App } from '../dto/app.dto';
|
import { App } from '../dto/app.dto';
|
||||||
import { ListAppsQuery } from '../dto/query.dto';
|
import { ListAppsQuery } from '../dto/query.dto';
|
||||||
|
|
||||||
@@ -44,6 +47,7 @@ export class AppsController {
|
|||||||
@Inject(AppsService)
|
@Inject(AppsService)
|
||||||
private appsService: IAppsService,
|
private appsService: IAppsService,
|
||||||
private manager: SessionManager,
|
private manager: SessionManager,
|
||||||
|
private resolver: UniqueAppResolver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('/')
|
@Get('/')
|
||||||
@@ -63,7 +67,11 @@ export class AppsController {
|
|||||||
async create(@Body() app: App): Promise<App> {
|
async create(@Body() app: App): Promise<App> {
|
||||||
const result = await this.appsService.create(this.manager, app);
|
const result = await this.appsService.create(this.manager, app);
|
||||||
const isRunning = this.manager.isRunning(app.session);
|
const isRunning = this.manager.isRunning(app.session);
|
||||||
if (isRunning && app.enabled && APPS[app.app].restartOnChange) {
|
if (
|
||||||
|
isRunning &&
|
||||||
|
app.enabled &&
|
||||||
|
GetApp(app.app)?.definition.restartOnChange
|
||||||
|
) {
|
||||||
await this.manager.restart(app.session);
|
await this.manager.restart(app.session);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -114,7 +122,7 @@ export class AppsController {
|
|||||||
|
|
||||||
const result = await this.appsService.upsert(this.manager, app);
|
const result = await this.appsService.upsert(this.manager, app);
|
||||||
const isRunning = this.manager.isRunning(result.session);
|
const isRunning = this.manager.isRunning(result.session);
|
||||||
if (isRunning && APPS[result.app].restartOnChange) {
|
if (isRunning && GetApp(result.app)?.definition.restartOnChange) {
|
||||||
await this.manager.restart(result.session);
|
await this.manager.restart(result.session);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -134,8 +142,44 @@ export class AppsController {
|
|||||||
}
|
}
|
||||||
const app = await this.appsService.delete(this.manager, id);
|
const app = await this.appsService.delete(this.manager, id);
|
||||||
const isRunning = this.manager.isRunning(app.session);
|
const isRunning = this.manager.isRunning(app.session);
|
||||||
if (isRunning && APPS[app.app].restartOnChange) {
|
if (isRunning && GetApp(app.app)?.definition.restartOnChange) {
|
||||||
await this.manager.restart(app.session);
|
await this.manager.restart(app.session);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('/:id/purge')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Purge app storage by app ID',
|
||||||
|
description:
|
||||||
|
"Delete the app's stored data (database rows, caches) while keeping the app configured.",
|
||||||
|
})
|
||||||
|
@CheckPolicies(CanServer(Action.Retrieve))
|
||||||
|
@UsePipes(new WAHAValidationPipe())
|
||||||
|
async purge(@Param('id') id: string, @Req() req: any): Promise<App> {
|
||||||
|
const existing = await this.appsService.get(this.manager, id);
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException(`App '${id}' not found`);
|
||||||
|
}
|
||||||
|
if (!req.ability?.can(Action.App, new SessionName(existing.session))) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
return await this.appsService.purge(this.manager, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/:app/:session/purge')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Purge app storage by app name and session',
|
||||||
|
description:
|
||||||
|
"Delete the unique app's stored data for the session. The app must be enabled.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'app', enum: AppName })
|
||||||
|
@CheckPolicies(CanSession(Action.App, FromParam('session')))
|
||||||
|
@UsePipes(new WAHAValidationPipe())
|
||||||
|
async purgeUniqueApp(
|
||||||
|
@Param('app') appName: string,
|
||||||
|
@Param('session') session: string,
|
||||||
|
): Promise<App> {
|
||||||
|
const app = await this.resolver.getEnabledApp(session, appName);
|
||||||
|
return await this.appsService.purge(this.manager, app.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { AppApiTag } from '@waha/apps/app_sdk/apps/openapi';
|
||||||
|
import { AppRuntimeConfig } from '@waha/apps/app_sdk/apps/AppRuntime';
|
||||||
|
import { GetApps } from '@waha/apps/app_sdk/apps/registry';
|
||||||
|
|
||||||
|
export interface ApiTag {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAPI tags for all registered apps - one "🧩 Apps: {Title}" tag per app,
|
||||||
|
* so app-specific endpoints get their own section in Swagger.
|
||||||
|
* Includes disabled apps too (only their openapi metadata is used) - some app
|
||||||
|
* controllers are served even when apps are disabled ('disabledControllers');
|
||||||
|
* their description is prefixed with "DISABLED" to make the state visible.
|
||||||
|
*/
|
||||||
|
export function GetAppsApiTags(): ApiTag[] {
|
||||||
|
return GetApps().map((app) => {
|
||||||
|
const enabled = AppRuntimeConfig.HasApp(app.name);
|
||||||
|
const description = enabled
|
||||||
|
? app.openapi.description
|
||||||
|
: `DISABLED - ${app.openapi.description}`;
|
||||||
|
return {
|
||||||
|
name: AppApiTag(app.openapi),
|
||||||
|
description: description,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import { AppEnv } from '@waha/apps/app_sdk/env';
|
import { AppEnv } from '@waha/apps/app_sdk/env';
|
||||||
import { AppDefinition, APPS } from '@waha/apps/app_sdk/apps/definition';
|
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
|
||||||
import { AppName } from '@waha/apps/app_sdk/apps/name';
|
import { GetApps } from '@waha/apps/app_sdk/apps/registry';
|
||||||
|
|
||||||
class AppRuntimeConfigC {
|
class AppRuntimeConfigC {
|
||||||
private constructor(private apps: AppDefinition[] | null) {}
|
private constructor(private apps: AppModule[] | null) {}
|
||||||
|
|
||||||
static FromEnv(env: typeof AppEnv) {
|
static FromEnv(env: typeof AppEnv) {
|
||||||
if (!env.enabled) {
|
if (!env.enabled) {
|
||||||
return new AppRuntimeConfigC(null);
|
return new AppRuntimeConfigC(null);
|
||||||
}
|
}
|
||||||
let apps = Object.values(APPS);
|
let apps: AppModule[] = GetApps();
|
||||||
// Include
|
// Include
|
||||||
if (env.on && env.on.length > 0) {
|
if (env.on && env.on.length > 0) {
|
||||||
apps = apps.filter((app) => env.on!.includes(app.name));
|
apps = apps.filter((app) => env.on!.includes(app.name));
|
||||||
@@ -30,18 +30,18 @@ class AppRuntimeConfigC {
|
|||||||
}
|
}
|
||||||
|
|
||||||
GetAppsWithMigration() {
|
GetAppsWithMigration() {
|
||||||
return this.GetApps().filter((app) => app.migrations);
|
return this.GetApps().filter((app) => app.definition.migrations);
|
||||||
}
|
}
|
||||||
|
|
||||||
GetAppsRequiringPlainKey() {
|
GetAppsRequiringPlainKey() {
|
||||||
return this.GetApps().filter((app) => app.plainkey);
|
return this.GetApps().filter((app) => app.definition.plainkey);
|
||||||
}
|
}
|
||||||
|
|
||||||
GetAppsRequiringQueue() {
|
GetAppsRequiringQueue() {
|
||||||
return this.GetApps().filter((app) => app.queue);
|
return this.GetApps().filter((app) => app.definition.queue);
|
||||||
}
|
}
|
||||||
|
|
||||||
HasApp(name: AppName) {
|
HasApp(name: string) {
|
||||||
return this.GetApps().some((app) => app.name === name);
|
return this.GetApps().some((app) => app.name === name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { ArgentinePhoneNumbersAppConfig } from '@waha/apps/argentine-phone-numbers/dto';
|
||||||
|
import { Type } from '@nestjs/common';
|
||||||
|
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',
|
||||||
|
mexicanPhoneNumbers = 'mexican-phone-numbers',
|
||||||
|
phoneNumbers = 'phone-numbers',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO classes used to transform and validate App.config, by app name.
|
||||||
|
* Kept separate from the registry so DTOs (imported by core structures) can resolve config classes
|
||||||
|
* 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> {
|
||||||
|
return AppConfigClasses[name] ?? Object;
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { AppName } from '@waha/apps/app_sdk/apps/name';
|
import { Type } from '@nestjs/common';
|
||||||
|
import { AppName } from '@waha/apps/app_sdk/apps/apps';
|
||||||
|
import { AppOpenAPI } from '@waha/apps/app_sdk/apps/openapi';
|
||||||
|
import { GetApp } from '@waha/apps/app_sdk/apps/registry';
|
||||||
|
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
|
||||||
|
|
||||||
export interface AppDefinition {
|
export interface AppDefinition {
|
||||||
// App name
|
|
||||||
name: AppName;
|
|
||||||
// If app requires WAHA_API_KEY_PLAIN to work
|
// If app requires WAHA_API_KEY_PLAIN to work
|
||||||
plainkey: boolean;
|
plainkey: boolean;
|
||||||
// If app requires queue to work
|
// If app requires queue to work
|
||||||
@@ -11,29 +13,51 @@ export interface AppDefinition {
|
|||||||
migrations: boolean;
|
migrations: boolean;
|
||||||
// If adding, updating, or removing this app requires a session restart
|
// If adding, updating, or removing this app requires a session restart
|
||||||
restartOnChange: boolean;
|
restartOnChange: boolean;
|
||||||
|
// If only one instance of this app is allowed per session
|
||||||
|
unique: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// All Apps
|
// NestJS module parts, included when the app is enabled in runtime configuration
|
||||||
export const APPS: Record<AppName, AppDefinition> = {
|
export interface AppNestJS {
|
||||||
[AppName.calls]: {
|
imports: any[];
|
||||||
name: AppName.calls,
|
controllers: any[];
|
||||||
plainkey: false,
|
providers: any[];
|
||||||
queue: false,
|
}
|
||||||
migrations: false,
|
|
||||||
restartOnChange: true,
|
/**
|
||||||
},
|
* Contract for the default export of 'src/apps/<name>/app.module.ts'.
|
||||||
[AppName.chatwoot]: {
|
* Each app self-describes with this and gets listed in the registry ('apps/registry.ts').
|
||||||
name: AppName.chatwoot,
|
*/
|
||||||
plainkey: true,
|
export interface AppModule {
|
||||||
queue: true,
|
// App name
|
||||||
migrations: true,
|
name: AppName;
|
||||||
restartOnChange: true,
|
// OpenAPI metadata (tag title, description) from 'src/apps/<name>/openapi.ts'
|
||||||
},
|
openapi: AppOpenAPI;
|
||||||
[AppName.mcp]: {
|
// Static app metadata (requirements, behavior flags)
|
||||||
name: AppName.mcp,
|
definition: AppDefinition;
|
||||||
plainkey: false,
|
// NestJS module parts
|
||||||
queue: false,
|
nestjs: AppNestJS;
|
||||||
migrations: false,
|
// Service implementing app lifecycle hooks; must also be listed in 'nestjs.providers'
|
||||||
restartOnChange: false,
|
Service: Type<IAppService>;
|
||||||
},
|
}
|
||||||
};
|
|
||||||
|
export function isUniqueApp(name: AppName): boolean {
|
||||||
|
return GetApp(name)?.definition.unique === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the first unique AppName that appears more than once in the list, or null
|
||||||
|
export function findDuplicateUniqueApp(
|
||||||
|
apps: Array<{ app: AppName }>,
|
||||||
|
): AppName | null {
|
||||||
|
const seen = new Set<AppName>();
|
||||||
|
for (const app of apps) {
|
||||||
|
if (!isUniqueApp(app.app)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (seen.has(app.app)) {
|
||||||
|
return app.app;
|
||||||
|
}
|
||||||
|
seen.add(app.app);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export enum AppName {
|
|
||||||
chatwoot = 'chatwoot',
|
|
||||||
calls = 'calls',
|
|
||||||
mcp = 'mcp',
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* OpenAPI metadata for an app, defined inline in the app's AppModule ('openapi' field).
|
||||||
|
* The registry applies the "🧩 Apps: {Title}" tag to every controller in 'nestjs.controllers',
|
||||||
|
* so app controllers do not declare @ApiTags themselves - a new app only touches its own folder.
|
||||||
|
*/
|
||||||
|
export interface AppOpenAPI {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAPI tag for app-specific endpoints - "🧩 Apps: {Title}"
|
||||||
|
export function AppApiTag(openapi: AppOpenAPI): string {
|
||||||
|
return `🧩 Apps: ${openapi.title}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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 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,
|
||||||
|
// so controllers do not declare @ApiTags themselves (they cannot import their own app.module - require cycle).
|
||||||
|
for (const app of APPS) {
|
||||||
|
for (const controller of app.nestjs.controllers) {
|
||||||
|
ApiTags(AppApiTag(app.openapi))(controller);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetApps(): AppModule[] {
|
||||||
|
return APPS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetApp(name: string): AppModule | undefined {
|
||||||
|
return APPS.find((app) => app.name === name);
|
||||||
|
}
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto';
|
|
||||||
import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto';
|
|
||||||
import { McpAppConfig } from '@waha/apps/mcp/dto/config.dto';
|
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
@@ -10,15 +7,13 @@ import {
|
|||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { ApiExtraModels, ApiProperty } from '@nestjs/swagger';
|
import { ApiExtraModels, ApiProperty } from '@nestjs/swagger';
|
||||||
import { AppName } from '@waha/apps/app_sdk/apps/name';
|
import {
|
||||||
|
AppConfigClasses,
|
||||||
|
AppName,
|
||||||
|
GetAppConfigClass,
|
||||||
|
} from '@waha/apps/app_sdk/apps/apps';
|
||||||
|
|
||||||
export type AllowedAppConfig =
|
export class App<T = any> {
|
||||||
| ChatWootAppConfig
|
|
||||||
| CallsAppConfig
|
|
||||||
| McpAppConfig;
|
|
||||||
|
|
||||||
@ApiExtraModels(ChatWootAppConfig, CallsAppConfig, McpAppConfig)
|
|
||||||
export class App<T extends AllowedAppConfig = any> {
|
|
||||||
@IsString()
|
@IsString()
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@@ -41,36 +36,14 @@ export class App<T extends AllowedAppConfig = any> {
|
|||||||
|
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type((options) => {
|
@Type((options) => {
|
||||||
if (options && options.object && options.object.app) {
|
const name = options?.object?.app;
|
||||||
switch (options.object.app) {
|
if (!name) {
|
||||||
case AppName.chatwoot:
|
return Object;
|
||||||
return ChatWootAppConfig;
|
|
||||||
case AppName.calls:
|
|
||||||
return CallsAppConfig;
|
|
||||||
case AppName.mcp:
|
|
||||||
return McpAppConfig;
|
|
||||||
default:
|
|
||||||
return Object;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return Object;
|
return GetAppConfigClass(name);
|
||||||
})
|
})
|
||||||
config: T;
|
config: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ChatWootAppDto extends App<ChatWootAppConfig> {
|
// Swagger models for app configs
|
||||||
@Type(() => ChatWootAppConfig)
|
ApiExtraModels(...Object.values(AppConfigClasses))(App);
|
||||||
config: ChatWootAppConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CallsAppDto extends App<CallsAppConfig> {
|
|
||||||
@Type(() => CallsAppConfig)
|
|
||||||
config: CallsAppConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class McpAppDto extends App<McpAppConfig> {
|
|
||||||
@Type(() => McpAppConfig)
|
|
||||||
config: McpAppConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AppDto = ChatWootAppDto | CallsAppDto | McpAppDto;
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { parseBool } from '@waha/helpers';
|
import { parseBool } from '@waha/helpers';
|
||||||
import { APPS } from '@waha/apps/app_sdk/apps/definition';
|
import { GetApps } from '@waha/apps/app_sdk/apps/registry';
|
||||||
|
|
||||||
// Apps that don't require queue (Redis) - derived from AppDefinition
|
// Apps that don't require queue (Redis) - derived from AppDefinition
|
||||||
const IN_MEMORY_APPS = Object.values(APPS)
|
const IN_MEMORY_APPS = GetApps()
|
||||||
.filter((app) => !app.queue)
|
.filter((app) => !app.definition.queue)
|
||||||
.map((app) => app.name);
|
.map((app) => app.name);
|
||||||
|
|
||||||
function parseCommaSeparatedList(value: string | undefined): string[] {
|
function parseCommaSeparatedList(value: string | undefined): string[] {
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ export class AppsDisabledService implements IAppsService {
|
|||||||
throw new AppsIsDisabledError();
|
throw new AppsIsDisabledError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async purge(manager: SessionManager, appId: string): Promise<App> {
|
||||||
|
throw new AppsIsDisabledError();
|
||||||
|
}
|
||||||
|
|
||||||
|
async purgeBySession(manager: SessionManager, session: string) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
async removeBySession(manager: SessionManager, session: string) {
|
async removeBySession(manager: SessionManager, session: string) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Optional,
|
|
||||||
UnprocessableEntityException,
|
UnprocessableEntityException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { ModuleRef } from '@nestjs/core';
|
||||||
import { migrate } from '@waha/apps/app_sdk/migrations';
|
import { migrate } from '@waha/apps/app_sdk/migrations';
|
||||||
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
|
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
|
||||||
import { IAppsService } from '@waha/apps/app_sdk/services/IAppsService';
|
import { IAppsService } from '@waha/apps/app_sdk/services/IAppsService';
|
||||||
import { ChatWootAppService } from '@waha/apps/chatwoot/services/ChatWootAppService';
|
|
||||||
import { CallsAppService } from '@waha/apps/calls/services/CallsAppService';
|
|
||||||
import { McpAppService } from '@waha/apps/mcp/services/McpAppService';
|
|
||||||
import { DataStore } from '@waha/core/abc/DataStore';
|
import { DataStore } from '@waha/core/abc/DataStore';
|
||||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||||
import { WhatsappSession } from '@waha/core/abc/session.abc';
|
import { WhatsappSession } from '@waha/core/abc/session.abc';
|
||||||
@@ -19,8 +16,12 @@ import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
|
|||||||
|
|
||||||
import { App } from '../dto/app.dto';
|
import { App } from '../dto/app.dto';
|
||||||
import { AppRepository } from '../storage/AppRepository';
|
import { AppRepository } from '../storage/AppRepository';
|
||||||
import { AppName } from '@waha/apps/app_sdk/apps/name';
|
|
||||||
import { AppRuntimeConfig } from '@waha/apps/app_sdk/apps/AppRuntime';
|
import { AppRuntimeConfig } from '@waha/apps/app_sdk/apps/AppRuntime';
|
||||||
|
import {
|
||||||
|
findDuplicateUniqueApp,
|
||||||
|
isUniqueApp,
|
||||||
|
} from '@waha/apps/app_sdk/apps/definition';
|
||||||
|
import { GetApp } from '@waha/apps/app_sdk/apps/registry';
|
||||||
|
|
||||||
export class AppDisableError extends UnprocessableEntityException {
|
export class AppDisableError extends UnprocessableEntityException {
|
||||||
constructor(app: string) {
|
constructor(app: string) {
|
||||||
@@ -30,14 +31,21 @@ export class AppDisableError extends UnprocessableEntityException {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class AppUniquePerSessionError extends UnprocessableEntityException {
|
||||||
|
constructor(app: string, session: string, existingAppId: string) {
|
||||||
|
super(
|
||||||
|
`Only one '${app}' app is allowed per session. ` +
|
||||||
|
`Session '${session}' already has a '${app}' app with ID '${existingAppId}'.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AppsEnabledService implements IAppsService {
|
export class AppsEnabledService implements IAppsService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectPinoLogger('AppsService')
|
@InjectPinoLogger('AppsService')
|
||||||
protected logger: PinoLogger,
|
protected logger: PinoLogger,
|
||||||
@Optional() protected readonly chatwootService: ChatWootAppService,
|
private readonly moduleRef: ModuleRef,
|
||||||
@Optional() protected readonly callsAppService: CallsAppService,
|
|
||||||
@Optional() protected readonly mcpAppService: McpAppService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(manager: SessionManager, session: string): Promise<App[]> {
|
async list(manager: SessionManager, session: string): Promise<App[]> {
|
||||||
@@ -64,31 +72,17 @@ export class AppsEnabledService implements IAppsService {
|
|||||||
throw new Error(`App with ID '${app.id}' already exists.`);
|
throw new Error(`App with ID '${app.id}' already exists.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let existingApps: App[] = [];
|
// Validate only one instance of a unique app per session
|
||||||
if (app.app === AppName.chatwoot || app.app === AppName.calls) {
|
if (isUniqueApp(app.app)) {
|
||||||
existingApps = await repo.getAllBySession(app.session);
|
const existingApps = await repo.getAllBySession(app.session);
|
||||||
}
|
const duplicateApp = existingApps.find(
|
||||||
// Validate only one Chatwoot app per session
|
(existingApp) => existingApp.app === app.app,
|
||||||
if (app.app === AppName.chatwoot) {
|
|
||||||
const existingChatwootApp = existingApps.find(
|
|
||||||
(existingApp) => existingApp.app === AppName.chatwoot,
|
|
||||||
);
|
);
|
||||||
|
if (duplicateApp) {
|
||||||
if (existingChatwootApp) {
|
throw new AppUniquePerSessionError(
|
||||||
throw new Error(
|
app.app,
|
||||||
`Only one Chatwoot app is allowed per session. Session '${app.session}' already has a Chatwoot app with ID '${existingChatwootApp.id}'.`,
|
app.session,
|
||||||
);
|
duplicateApp.id,
|
||||||
}
|
|
||||||
}
|
|
||||||
// Validate only one Calls app per session
|
|
||||||
if (app.app === AppName.calls) {
|
|
||||||
const existingCallsApp = existingApps.find(
|
|
||||||
(existingApp) => existingApp.app === AppName.calls,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingCallsApp) {
|
|
||||||
throw new Error(
|
|
||||||
`Only one Calls app is allowed per session. Session '${app.session}' already has a Calls app with ID '${existingCallsApp.id}'.`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,6 +186,35 @@ export class AppsEnabledService implements IAppsService {
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async purge(manager: SessionManager, appId: string): Promise<App> {
|
||||||
|
const knex = manager.store.getWAHADatabase();
|
||||||
|
const repo = new AppRepository(knex);
|
||||||
|
const app = await repo.getById(appId);
|
||||||
|
if (!app) {
|
||||||
|
throw new NotFoundException(`App '${appId}' not found`);
|
||||||
|
}
|
||||||
|
const service = this.getAppService(app);
|
||||||
|
if (!service) {
|
||||||
|
throw new AppDisableError(app.app);
|
||||||
|
}
|
||||||
|
await service.purge(manager, app);
|
||||||
|
delete app.pk;
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
async purgeBySession(manager: SessionManager, session: string) {
|
||||||
|
const knex = manager.store.getWAHADatabase();
|
||||||
|
const repo = new AppRepository(knex);
|
||||||
|
const apps = await repo.getAllBySession(session);
|
||||||
|
for (const app of apps) {
|
||||||
|
const service = this.getAppService(app);
|
||||||
|
if (!service) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await service.purge(manager, app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async removeBySession(manager: SessionManager, session: string) {
|
async removeBySession(manager: SessionManager, session: string) {
|
||||||
const knex = manager.store.getWAHADatabase();
|
const knex = manager.store.getWAHADatabase();
|
||||||
const repo = new AppRepository(knex);
|
const repo = new AppRepository(knex);
|
||||||
@@ -212,6 +235,10 @@ export class AppsEnabledService implements IAppsService {
|
|||||||
if (!service && !AppRuntimeConfig.HasApp(app.app)) {
|
if (!service && !AppRuntimeConfig.HasApp(app.app)) {
|
||||||
throw new AppDisableError(app.app);
|
throw new AppDisableError(app.app);
|
||||||
}
|
}
|
||||||
|
const plugins = service.plugins(app, session, store);
|
||||||
|
for (const options of plugins) {
|
||||||
|
session.plugins.add(options, app.id);
|
||||||
|
}
|
||||||
service.beforeSessionStart(app, session);
|
service.beforeSessionStart(app, session);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,6 +261,15 @@ export class AppsEnabledService implements IAppsService {
|
|||||||
session: string,
|
session: string,
|
||||||
apps: App[],
|
apps: App[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// Reject duplicate unique apps in the payload before any writes,
|
||||||
|
// otherwise the by-type matching below binds them to the same app
|
||||||
|
const duplicateUniqueApp = findDuplicateUniqueApp(apps);
|
||||||
|
if (duplicateUniqueApp !== null) {
|
||||||
|
throw new UnprocessableEntityException(
|
||||||
|
`Only one '${duplicateUniqueApp}' app is allowed per session - remove duplicate entries from 'apps'.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await this.list(manager, session);
|
const existing = await this.list(manager, session);
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
|
|
||||||
@@ -266,15 +302,15 @@ export class AppsEnabledService implements IAppsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getAppService(app: App): IAppService | null {
|
private getAppService(app: App): IAppService | null {
|
||||||
switch (app.app) {
|
const appModule = GetApp(app.app);
|
||||||
case AppName.chatwoot:
|
if (!appModule) {
|
||||||
return this.chatwootService;
|
throw new Error(`App '${app.app}' not supported`);
|
||||||
case AppName.calls:
|
}
|
||||||
return this.callsAppService;
|
try {
|
||||||
case AppName.mcp:
|
return this.moduleRef.get(appModule.Service, { strict: false });
|
||||||
return this.mcpAppService;
|
} catch {
|
||||||
default:
|
// Provider is not registered - app is disabled via WAHA_APPS_ON / WAHA_APPS_OFF
|
||||||
throw new Error(`App '${app.app}' not supported`);
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { App } from '@waha/apps/app_sdk/dto/app.dto';
|
import { App } from '@waha/apps/app_sdk/dto/app.dto';
|
||||||
|
import { DataStore } from '@waha/core/abc/DataStore';
|
||||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||||
import { WhatsappSession } from '@waha/core/abc/session.abc';
|
import { WhatsappSession } from '@waha/core/abc/session.abc';
|
||||||
|
import { PluginOptions } from '@waha/core/abc/session.plugin';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exact App service
|
* Exact App service
|
||||||
@@ -42,6 +44,12 @@ export interface IAppService {
|
|||||||
|
|
||||||
beforeDeleted(manager: SessionManager, app: App): Promise<void>;
|
beforeDeleted(manager: SessionManager, app: App): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the app's stored data (database rows, caches) while keeping the app configured.
|
||||||
|
* Apps without storage implement it as a no-op.
|
||||||
|
*/
|
||||||
|
purge(manager: SessionManager, app: App): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called for each app before a bulk session deletion removes all app records.
|
* Called for each app before a bulk session deletion removes all app records.
|
||||||
* Use this to clean up external resources tied to the app (e.g. API keys).
|
* Use this to clean up external resources tied to the app (e.g. API keys).
|
||||||
@@ -54,6 +62,17 @@ export interface IAppService {
|
|||||||
*/
|
*/
|
||||||
enrich(manager: SessionManager, app: App): Promise<void>;
|
enrich(manager: SessionManager, app: App): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session plugins contributed by this app. AppsEnabledService registers them with the app id,
|
||||||
|
* and the session manager attaches their hooks and events before session.start().
|
||||||
|
* store - the server data store, for apps whose plugins persist data.
|
||||||
|
*/
|
||||||
|
plugins(
|
||||||
|
app: App,
|
||||||
|
session: WhatsappSession,
|
||||||
|
store?: DataStore,
|
||||||
|
): PluginOptions[];
|
||||||
|
|
||||||
beforeSessionStart(app: App, session: WhatsappSession): void;
|
beforeSessionStart(app: App, session: WhatsappSession): void;
|
||||||
|
|
||||||
afterSessionStart(app: App, session: WhatsappSession): void;
|
afterSessionStart(app: App, session: WhatsappSession): void;
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export interface IAppsService {
|
|||||||
|
|
||||||
delete(manager: SessionManager, appId: string): Promise<App>;
|
delete(manager: SessionManager, appId: string): Promise<App>;
|
||||||
|
|
||||||
|
purge(manager: SessionManager, appId: string): Promise<App>;
|
||||||
|
|
||||||
|
purgeBySession(manager: SessionManager, session: string): Promise<void>;
|
||||||
|
|
||||||
removeBySession(manager: SessionManager, session: string): Promise<void>;
|
removeBySession(manager: SessionManager, session: string): Promise<void>;
|
||||||
|
|
||||||
beforeSessionStart(session: WhatsappSession, store: DataStore): Promise<void>;
|
beforeSessionStart(session: WhatsappSession, store: DataStore): Promise<void>;
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { AppRepository } from '@waha/apps/app_sdk/storage/AppRepository';
|
||||||
|
import { AppDB } from '@waha/apps/app_sdk/storage/types';
|
||||||
|
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||||
|
import { SessionPlugin } from '@waha/core/abc/session.plugin';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the single instance of a unique-per-session app ('definition.unique') by session name,
|
||||||
|
* so app controllers can expose session-keyed routes ('api/apps/{app}/{session}/...') instead of app-id ones.
|
||||||
|
* Uniqueness is guaranteed at write time by AppsEnabledService, so the first enabled row is the instance.
|
||||||
|
* Must not import 'apps/registry.ts' or 'apps/definition.ts' - controllers import this resolver,
|
||||||
|
* and the registry imports app modules (with their controllers), which would create a require cycle.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class UniqueAppResolver {
|
||||||
|
constructor(private readonly manager: SessionManager) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enabled app row for the session; 404 if the app is missing or disabled.
|
||||||
|
* Does not require the session to be running - persistent app data stays accessible for stopped sessions.
|
||||||
|
*/
|
||||||
|
async getEnabledApp(sessionName: string, appName: string): Promise<AppDB> {
|
||||||
|
const knex = this.manager.store.getWAHADatabase();
|
||||||
|
const repository = new AppRepository(knex);
|
||||||
|
const app = await repository.getEnabledBySessionAndApp(
|
||||||
|
sessionName,
|
||||||
|
appName,
|
||||||
|
);
|
||||||
|
if (!app) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`App '${appName}' is not enabled for session '${sessionName}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live plugin instance for the app, or null when the session is not running (best-effort access).
|
||||||
|
*/
|
||||||
|
getPlugin<Plugin extends SessionPlugin<any, any>>(
|
||||||
|
app: AppDB,
|
||||||
|
PluginClass: abstract new (...args: any[]) => Plugin,
|
||||||
|
): Plugin | null {
|
||||||
|
if (!this.manager.isRunning(app.session)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let session;
|
||||||
|
try {
|
||||||
|
session = this.manager.getSession(app.session);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return session.plugins.get(PluginClass, app.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,25 @@ export class AppRepository {
|
|||||||
return this.deserialize(app);
|
return this.deserialize(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the enabled app of the given type for a session.
|
||||||
|
* Intended for unique-per-session apps - returns the first match.
|
||||||
|
*/
|
||||||
|
async getEnabledBySessionAndApp(
|
||||||
|
session: string,
|
||||||
|
appName: string,
|
||||||
|
): Promise<AppDB | null> {
|
||||||
|
const app = await this.knex(this.tableName)
|
||||||
|
.where('session', session)
|
||||||
|
.andWhere('app', appName)
|
||||||
|
.andWhere('enabled', true)
|
||||||
|
.first();
|
||||||
|
if (!app) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.deserialize(app);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets all apps
|
* Gets all apps
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AppsService } from '@waha/apps/app_sdk/services/IAppsService';
|
import { AppsService } from '@waha/apps/app_sdk/services/IAppsService';
|
||||||
import { AppsDisabledService } from '@waha/apps/app_sdk/services/AppsDisabledService';
|
import { AppsDisabledService } from '@waha/apps/app_sdk/services/AppsDisabledService';
|
||||||
|
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
|
||||||
import { AppsController } from '@waha/apps/app_sdk/api/apps.controller';
|
import { AppsController } from '@waha/apps/app_sdk/api/apps.controller';
|
||||||
import { ChatwootLocalesController } from '@waha/apps/chatwoot/api/chatwoot.locales.controller';
|
import { ChatwootLocalesController } from '@waha/apps/chatwoot/api/chatwoot.locales.controller';
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ export const AppsDisabled = {
|
|||||||
provide: AppsService,
|
provide: AppsService,
|
||||||
useClass: AppsDisabledService,
|
useClass: AppsDisabledService,
|
||||||
},
|
},
|
||||||
|
UniqueAppResolver,
|
||||||
],
|
],
|
||||||
imports: [],
|
imports: [],
|
||||||
controllers: [AppsController, ChatwootLocalesController],
|
controllers: [AppsController, ChatwootLocalesController],
|
||||||
|
|||||||
@@ -4,17 +4,20 @@ import { RMutexModule } from '@waha/modules/rmutex';
|
|||||||
import { BullBoardModule } from '@bull-board/nestjs';
|
import { BullBoardModule } from '@bull-board/nestjs';
|
||||||
import { ExpressAdapter } from '@bull-board/express';
|
import { ExpressAdapter } from '@bull-board/express';
|
||||||
import { BullAuthMiddleware } from '@waha/apps/app_sdk/auth';
|
import { BullAuthMiddleware } from '@waha/apps/app_sdk/auth';
|
||||||
import { ChatWootExports } from '@waha/apps/chatwoot/chatwoot.module';
|
|
||||||
import { McpModuleExports } from '@waha/apps/mcp/mcp.module';
|
|
||||||
import { AppsController } from '@waha/apps/app_sdk/api/apps.controller';
|
import { AppsController } from '@waha/apps/app_sdk/api/apps.controller';
|
||||||
import { CallsAppExports } from '@waha/apps/calls/calls.module';
|
|
||||||
import { AppsService } from '@waha/apps/app_sdk/services/IAppsService';
|
import { AppsService } from '@waha/apps/app_sdk/services/IAppsService';
|
||||||
import { AppsEnabledService } from '@waha/apps/app_sdk/services/AppsEnabledService';
|
import { AppsEnabledService } from '@waha/apps/app_sdk/services/AppsEnabledService';
|
||||||
|
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
|
||||||
import { Auth } from '@waha/core/auth/config';
|
import { Auth } from '@waha/core/auth/config';
|
||||||
import { AppRuntimeConfig } from '@waha/apps/app_sdk/apps/AppRuntime';
|
import { AppRuntimeConfig } from '@waha/apps/app_sdk/apps/AppRuntime';
|
||||||
import { AppName } from '@waha/apps/app_sdk/apps/name';
|
import { GetApps } from '@waha/apps/app_sdk/apps/registry';
|
||||||
|
import { HttpPathsRegistration } from '@waha/plugins/http.paths.module';
|
||||||
|
|
||||||
const QUEUES_IMPORTS_REQUIRED = [
|
const QUEUES_IMPORTS_REQUIRED = [
|
||||||
|
HttpPathsRegistration(
|
||||||
|
{ prefix: '/jobs', include: { authBasic: false } },
|
||||||
|
{ prefix: '/jobs/', include: { authBasic: false, accessLog: false } },
|
||||||
|
),
|
||||||
BullModule.forRoot({
|
BullModule.forRoot({
|
||||||
connection: {
|
connection: {
|
||||||
url: process.env.REDIS_URL || 'redis://:redis@localhost:6379',
|
url: process.env.REDIS_URL || 'redis://:redis@localhost:6379',
|
||||||
@@ -71,47 +74,27 @@ const QUEUES_IMPORTS = AppRuntimeConfig.HasAppsRequiringQueue()
|
|||||||
? QUEUES_IMPORTS_REQUIRED
|
? QUEUES_IMPORTS_REQUIRED
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
function getAppModule(name: AppName) {
|
// Apps enabled in the runtime configuration (WAHA_APPS_ON / WAHA_APPS_OFF)
|
||||||
if (!AppRuntimeConfig.HasApp(name)) {
|
const ENABLED_APPS = GetApps().filter((app) =>
|
||||||
return {
|
AppRuntimeConfig.HasApp(app.name),
|
||||||
imports: [],
|
);
|
||||||
controllers: [],
|
|
||||||
providers: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
switch (name) {
|
|
||||||
case AppName.calls:
|
|
||||||
return CallsAppExports;
|
|
||||||
case AppName.chatwoot:
|
|
||||||
return ChatWootExports;
|
|
||||||
case AppName.mcp:
|
|
||||||
return McpModuleExports;
|
|
||||||
default:
|
|
||||||
throw Error(`App module not found for ${name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AppsEnabled = {
|
export const AppsEnabled = {
|
||||||
imports: [
|
imports: [
|
||||||
...QUEUES_IMPORTS,
|
...QUEUES_IMPORTS,
|
||||||
...getAppModule(AppName.mcp).imports,
|
...ENABLED_APPS.flatMap((app) => app.nestjs.imports),
|
||||||
...getAppModule(AppName.chatwoot).imports,
|
|
||||||
...getAppModule(AppName.calls).imports,
|
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
AppsController,
|
AppsController,
|
||||||
...getAppModule(AppName.mcp).controllers,
|
...ENABLED_APPS.flatMap((app) => app.nestjs.controllers),
|
||||||
...getAppModule(AppName.chatwoot).controllers,
|
|
||||||
...getAppModule(AppName.calls).controllers,
|
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: AppsService,
|
provide: AppsService,
|
||||||
useClass: AppsEnabledService,
|
useClass: AppsEnabledService,
|
||||||
},
|
},
|
||||||
...getAppModule(AppName.mcp).providers,
|
UniqueAppResolver,
|
||||||
...getAppModule(AppName.calls).providers,
|
...ENABLED_APPS.flatMap((app) => app.nestjs.providers),
|
||||||
...getAppModule(AppName.chatwoot).providers,
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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/controller';
|
||||||
|
import { BrazilianPhoneNumbersAppService } from '@waha/apps/brazilian-phone-numbers/services/BrazilianPhoneNumbersAppService';
|
||||||
|
|
||||||
|
const BrazilianPhoneNumbersAppModule: AppModule = {
|
||||||
|
name: AppName.brazilianPhoneNumbers,
|
||||||
|
openapi: {
|
||||||
|
title: 'Phone Numbers: Brazil',
|
||||||
|
description: 'Resolve Brazilian phone numbers (with and without 9 digit)',
|
||||||
|
},
|
||||||
|
definition: {
|
||||||
|
plainkey: false,
|
||||||
|
queue: false,
|
||||||
|
migrations: true,
|
||||||
|
restartOnChange: true,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
nestjs: {
|
||||||
|
imports: [],
|
||||||
|
controllers: [BrazilianPhoneNumbersController],
|
||||||
|
providers: [BrazilianPhoneNumbersAppService],
|
||||||
|
},
|
||||||
|
Service: BrazilianPhoneNumbersAppService,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BrazilianPhoneNumbersAppModule;
|
||||||
@@ -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 {}
|
||||||
@@ -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_brazilian_phone_numbers_cache',
|
||||||
|
index: 'brphone',
|
||||||
|
});
|
||||||
|
|
||||||
|
exports.up = migration.up;
|
||||||
|
exports.down = migration.down;
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
import { UnprocessableEntityException } from '@nestjs/common';
|
||||||
|
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 {
|
||||||
|
buildPlugin as buildPhoneNumbersPlugin,
|
||||||
|
resolveChat,
|
||||||
|
stubLookup,
|
||||||
|
} from '@waha/apps/phone-numbers/plugins/testing';
|
||||||
|
|
||||||
|
interface BuildOptions {
|
||||||
|
config?: Partial<PhoneNumbersBaseConfig>;
|
||||||
|
repository?: any;
|
||||||
|
pluginClass?: typeof PhoneNumbersCorePlugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlugin(options: BuildOptions = {}) {
|
||||||
|
return buildPhoneNumbersPlugin({
|
||||||
|
...options,
|
||||||
|
rules: [new BrazilianPhoneNumberRule()],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
stubLookup(session, {
|
||||||
|
numberExists: true,
|
||||||
|
chatId: '558591203123@c.us',
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
const second = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
|
||||||
|
expect(first).toBe('558591203123@c.us');
|
||||||
|
expect(second).toBe('558591203123@c.us');
|
||||||
|
// Second call is served from cache - no extra WhatsApp lookup.
|
||||||
|
expect(session.checkNumberStatus).toHaveBeenCalledTimes(1);
|
||||||
|
expect(session.checkNumberStatus).toHaveBeenCalledWith({
|
||||||
|
phone: '558591203123',
|
||||||
|
session: 'test',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the pn form when the engine answers with both pn and a LID', async () => {
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
stubLookup(session, {
|
||||||
|
numberExists: true,
|
||||||
|
chatId: '77820596330581@lid',
|
||||||
|
pn: '558591203123@c.us',
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
|
||||||
|
expect(resolved).toBe('558591203123@c.us');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caches a LID answer as-is instead of stapling a phone suffix on it', async () => {
|
||||||
|
// Engines answer with a LID only when the account has no phone form. The
|
||||||
|
// LID is routable, but '77820596330581@c.us' - its digits with a phone
|
||||||
|
// suffix - addresses nobody.
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
stubLookup(session, {
|
||||||
|
numberExists: true,
|
||||||
|
chatId: '77820596330581@lid',
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
const second = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
|
||||||
|
expect(first).toBe('77820596330581@lid');
|
||||||
|
expect(second).toBe('77820596330581@lid');
|
||||||
|
expect(session.checkNumberStatus).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses the cache across both forms of the same number', async () => {
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
stubLookup(session, {
|
||||||
|
numberExists: true,
|
||||||
|
chatId: '558591203123@c.us',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrong form first, then the already-correct one: same target, one lookup.
|
||||||
|
const wrongForm = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
const rightForm = await resolveChat(session, '558591203123@c.us');
|
||||||
|
|
||||||
|
expect(wrongForm).toBe('558591203123@c.us');
|
||||||
|
expect(rightForm).toBe('558591203123@c.us');
|
||||||
|
expect(session.checkNumberStatus).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shares one in-flight lookup between concurrent sends (single-flight)', async () => {
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
stubLookup(session, {
|
||||||
|
numberExists: true,
|
||||||
|
chatId: '558591203123@c.us',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [first, second] = await Promise.all([
|
||||||
|
resolveChat(session, '5585991203123@c.us'),
|
||||||
|
resolveChat(session, '558591203123@c.us'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(first).toBe('558591203123@c.us');
|
||||||
|
expect(second).toBe('558591203123@c.us');
|
||||||
|
expect(session.checkNumberStatus).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rewrites a dialed toll-free (0800) to the stored form, no lookup', async () => {
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
stubLookup(session, { numberExists: false });
|
||||||
|
|
||||||
|
// Dialed forms with and without the country code, plus the already-stored
|
||||||
|
// form, all address the same chat id - and none hits the WhatsApp lookup.
|
||||||
|
expect(await resolveChat(session, '08000464636@c.us')).toBe(
|
||||||
|
'558000464636@c.us',
|
||||||
|
);
|
||||||
|
expect(await resolveChat(session, '5508000464636@c.us')).toBe(
|
||||||
|
'558000464636@c.us',
|
||||||
|
);
|
||||||
|
expect(await resolveChat(session, '558000464636@c.us')).toBe(
|
||||||
|
'558000464636@c.us',
|
||||||
|
);
|
||||||
|
expect(session.checkNumberStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the static 9th-digit rule for DDD below the lookup range, no lookup', async () => {
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
stubLookup(session, { numberExists: false });
|
||||||
|
|
||||||
|
const resolved = await resolveChat(session, '551198765432@c.us');
|
||||||
|
|
||||||
|
expect(resolved).toBe('5511998765432@c.us');
|
||||||
|
expect(session.checkNumberStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves numbers of other countries untouched', async () => {
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
stubLookup(session, { numberExists: false });
|
||||||
|
|
||||||
|
const resolved = await resolveChat(session, '12132132130@c.us');
|
||||||
|
|
||||||
|
expect(resolved).toBe('12132132130@c.us');
|
||||||
|
expect(session.checkNumberStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes recursion-sensitive methods through untouched', async () => {
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
stubLookup(session, { numberExists: false });
|
||||||
|
|
||||||
|
const viaCheck = await resolveChat(
|
||||||
|
session,
|
||||||
|
'5585991203123@c.us',
|
||||||
|
'checkNumberStatus',
|
||||||
|
);
|
||||||
|
const viaLidMap = await resolveChat(
|
||||||
|
session,
|
||||||
|
'5585991203123@c.us',
|
||||||
|
'findLIDByPhoneNumber',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(viaCheck).toBe('5585991203123@c.us');
|
||||||
|
expect(viaLidMap).toBe('5585991203123@c.us');
|
||||||
|
expect(session.checkNumberStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves non-send methods locally only - no lookup, cache still honored', async () => {
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
stubLookup(session, {
|
||||||
|
numberExists: true,
|
||||||
|
chatId: '558591203123@c.us',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache miss on a local-only method: input goes through unresolved.
|
||||||
|
const cold = await resolveChat(
|
||||||
|
session,
|
||||||
|
'5585991203123@c.us',
|
||||||
|
'startTyping',
|
||||||
|
);
|
||||||
|
expect(cold).toBe('5585991203123@c.us');
|
||||||
|
expect(session.checkNumberStatus).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// A send resolves and caches; the local-only method now sees the cache.
|
||||||
|
await resolveChat(session, '5585991203123@c.us', 'sendText');
|
||||||
|
const warm = await resolveChat(
|
||||||
|
session,
|
||||||
|
'5585991203123@c.us',
|
||||||
|
'startTyping',
|
||||||
|
);
|
||||||
|
expect(warm).toBe('558591203123@c.us');
|
||||||
|
expect(session.checkNumberStatus).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 422 for a malformed number on send, passes it through otherwise', async () => {
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
stubLookup(session, { numberExists: false });
|
||||||
|
|
||||||
|
await expect(resolveChat(session, '55859912@c.us')).rejects.toThrow(
|
||||||
|
UnprocessableEntityException,
|
||||||
|
);
|
||||||
|
const readOp = await resolveChat(session, '55859912@c.us', 'getPresence');
|
||||||
|
expect(readOp).toBe('55859912@c.us');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('soft mode: sends the best-guess when the number is confirmed not to exist', async () => {
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
stubLookup(session, { numberExists: false });
|
||||||
|
|
||||||
|
const resolved = await resolveChat(session, '558591203123@c.us');
|
||||||
|
|
||||||
|
// DDD 85 keeps the 8-digit heuristic as the best-guess.
|
||||||
|
expect(resolved).toBe('558591203123@c.us');
|
||||||
|
// Both candidates were tried before giving up.
|
||||||
|
expect(session.checkNumberStatus).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strict mode: rejects a confirmed-nonexistent number and caches the negative', async () => {
|
||||||
|
const { session } = buildPlugin({ config: { strict: true } });
|
||||||
|
stubLookup(session, { numberExists: false });
|
||||||
|
|
||||||
|
await expect(resolveChat(session, '5585991203123@c.us')).rejects.toThrow(
|
||||||
|
UnprocessableEntityException,
|
||||||
|
);
|
||||||
|
const lookups = session.checkNumberStatus.mock.calls.length;
|
||||||
|
|
||||||
|
// The negative is cached - the retry rejects without a new lookup...
|
||||||
|
await expect(resolveChat(session, '5585991203123@c.us')).rejects.toThrow(
|
||||||
|
UnprocessableEntityException,
|
||||||
|
);
|
||||||
|
expect(session.checkNumberStatus).toHaveBeenCalledTimes(lookups);
|
||||||
|
// ...and local-only methods still pass the number through.
|
||||||
|
const readOp = await resolveChat(
|
||||||
|
session,
|
||||||
|
'5585991203123@c.us',
|
||||||
|
'getPresence',
|
||||||
|
);
|
||||||
|
expect(readOp).toBe('5585991203123@c.us');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends as-is without caching when the lookup fails', async () => {
|
||||||
|
const { session } = buildPlugin();
|
||||||
|
session.checkNumberStatus = jest.fn(async () => {
|
||||||
|
throw new Error('engine down');
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
|
||||||
|
expect(resolved).toBe('5585991203123@c.us');
|
||||||
|
const lookups = session.checkNumberStatus.mock.calls.length;
|
||||||
|
// Not cached: the next send retries the lookup.
|
||||||
|
await resolveChat(session, '5585991203123@c.us');
|
||||||
|
expect(session.checkNumberStatus.mock.calls.length).toBeGreaterThan(
|
||||||
|
lookups,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lookup: false disables the WhatsApp tier', async () => {
|
||||||
|
const { session } = buildPlugin({ config: { lookup: false } });
|
||||||
|
stubLookup(session, {
|
||||||
|
numberExists: true,
|
||||||
|
chatId: '558591203123@c.us',
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
|
||||||
|
expect(resolved).toBe('5585991203123@c.us');
|
||||||
|
expect(session.checkNumberStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
|
||||||
|
// Strict mode rejects the number on the chat hook, but a mention falls
|
||||||
|
// back to the input instead of failing the whole message.
|
||||||
|
const mention = await session.hooks.wid.mention.promise(
|
||||||
|
'5585991203123@c.us',
|
||||||
|
'sendText',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mention).toBe('5585991203123@c.us');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BrazilianPhoneNumbers - persistent cache tier', () => {
|
||||||
|
function buildRepository() {
|
||||||
|
return {
|
||||||
|
get: jest.fn().mockResolvedValue(null),
|
||||||
|
setMany: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('serves a persisted resolution without any lookup and warms the memory tier', async () => {
|
||||||
|
const repository = buildRepository();
|
||||||
|
repository.get.mockResolvedValue({
|
||||||
|
key: '5585991203123',
|
||||||
|
chatId: '558591203123@c.us',
|
||||||
|
verified: true,
|
||||||
|
resolvedAt: new Date(),
|
||||||
|
});
|
||||||
|
const { session } = buildPlugin({ repository: repository });
|
||||||
|
stubLookup(session, { numberExists: false });
|
||||||
|
|
||||||
|
const first = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
const second = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
|
||||||
|
expect(first).toBe('558591203123@c.us');
|
||||||
|
expect(second).toBe('558591203123@c.us');
|
||||||
|
expect(session.checkNumberStatus).not.toHaveBeenCalled();
|
||||||
|
// Second call hits the memory tier - the database is read once.
|
||||||
|
expect(repository.get).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists verified resolutions under both forms of the number', async () => {
|
||||||
|
const repository = buildRepository();
|
||||||
|
const { session } = buildPlugin({ repository: repository });
|
||||||
|
stubLookup(session, {
|
||||||
|
numberExists: true,
|
||||||
|
chatId: '558591203123@c.us',
|
||||||
|
});
|
||||||
|
|
||||||
|
await resolveChat(session, '5585991203123@c.us');
|
||||||
|
|
||||||
|
expect(repository.setMany).toHaveBeenCalledTimes(1);
|
||||||
|
const [keys, chatId, verified] = repository.setMany.mock.calls[0];
|
||||||
|
expect([...keys].sort()).toEqual(['558591203123', '5585991203123']);
|
||||||
|
expect(chatId).toBe('558591203123@c.us');
|
||||||
|
expect(verified).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not persist best-guesses or static-rule rewrites', async () => {
|
||||||
|
const repository = buildRepository();
|
||||||
|
const { session } = buildPlugin({ repository: repository });
|
||||||
|
stubLookup(session, { numberExists: false });
|
||||||
|
|
||||||
|
// Static rule (DDD 11) and a confirmed-nonexistent best-guess (DDD 85).
|
||||||
|
await resolveChat(session, '551198765432@c.us');
|
||||||
|
await resolveChat(session, '558591203123@c.us');
|
||||||
|
|
||||||
|
expect(repository.setMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves normally when the persistent tier errors out', async () => {
|
||||||
|
const repository = buildRepository();
|
||||||
|
repository.get.mockRejectedValue(new Error('db down'));
|
||||||
|
const { session } = buildPlugin({ repository: repository });
|
||||||
|
stubLookup(session, {
|
||||||
|
numberExists: true,
|
||||||
|
chatId: '558591203123@c.us',
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
|
||||||
|
expect(resolved).toBe('558591203123@c.us');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BrazilianPhoneNumbers GOWS - local LID map tier', () => {
|
||||||
|
it('resolves via the LID map with no WhatsApp lookup', async () => {
|
||||||
|
const { session } = buildPlugin({ pluginClass: PhoneNumbersGowsPlugin });
|
||||||
|
stubLookup(session, { numberExists: false });
|
||||||
|
session.findLIDByPhoneNumber = jest.fn(async (phone: string) => {
|
||||||
|
if (phone === '558591203123') {
|
||||||
|
return { lid: '77820596330581@lid', pn: '558591203123@c.us' };
|
||||||
|
}
|
||||||
|
return { lid: null, pn: null };
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
|
||||||
|
expect(resolved).toBe('558591203123@c.us');
|
||||||
|
expect(session.checkNumberStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls through to the WhatsApp lookup for unknown numbers', async () => {
|
||||||
|
const { session } = buildPlugin({ pluginClass: PhoneNumbersGowsPlugin });
|
||||||
|
stubLookup(session, {
|
||||||
|
numberExists: true,
|
||||||
|
chatId: '5585991203123@c.us',
|
||||||
|
});
|
||||||
|
session.findLIDByPhoneNumber = jest.fn(async () => {
|
||||||
|
return { lid: null, pn: null };
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
|
||||||
|
expect(resolved).toBe('5585991203123@c.us');
|
||||||
|
expect(session.checkNumberStatus).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BrazilianPhoneNumbers NOWEB - local contact store tier', () => {
|
||||||
|
it('resolves via the contact store with no WhatsApp lookup', async () => {
|
||||||
|
const { session } = buildPlugin({ pluginClass: PhoneNumbersNowebPlugin });
|
||||||
|
stubLookup(session, { numberExists: false });
|
||||||
|
session.store = {
|
||||||
|
getContactById: jest.fn(async (jid: string) => {
|
||||||
|
if (jid === '558591203123@s.whatsapp.net') {
|
||||||
|
return { id: '558591203123@s.whatsapp.net' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolved = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
|
||||||
|
expect(resolved).toBe('558591203123@c.us');
|
||||||
|
expect(session.checkNumberStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls through to the WhatsApp lookup when the store has no match', async () => {
|
||||||
|
const { session } = buildPlugin({ pluginClass: PhoneNumbersNowebPlugin });
|
||||||
|
stubLookup(session, {
|
||||||
|
numberExists: true,
|
||||||
|
chatId: '558591203123@c.us',
|
||||||
|
});
|
||||||
|
session.store = {
|
||||||
|
getContactById: jest.fn(async () => null),
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolved = await resolveChat(session, '5585991203123@c.us');
|
||||||
|
|
||||||
|
expect(resolved).toBe('558591203123@c.us');
|
||||||
|
expect(session.checkNumberStatus).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import {
|
||||||
|
extractPhoneDigits,
|
||||||
|
shouldSkipPhoneNormalization,
|
||||||
|
} from '@waha/apps/phone-numbers/utils/phone';
|
||||||
|
import {
|
||||||
|
generateBrazilMobileLookupCandidates,
|
||||||
|
isBrazilCountryCode,
|
||||||
|
isBrazilLandline,
|
||||||
|
isBrazilMobile,
|
||||||
|
isMalformedBrazilPhone,
|
||||||
|
needsBrazilWhatsAppLookup,
|
||||||
|
normalizeBrazilMobileForSendDigits,
|
||||||
|
normalizeBrazilTollFreeDigits,
|
||||||
|
} from './BrazilianPhoneNumberRule';
|
||||||
|
|
||||||
|
describe('BrazilianPhoneNumberRule', () => {
|
||||||
|
it('skips groups and lids', () => {
|
||||||
|
expect(shouldSkipPhoneNormalization('123@g.us')).toBe(true);
|
||||||
|
expect(shouldSkipPhoneNormalization('123@lid')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects BR country code', () => {
|
||||||
|
expect(isBrazilCountryCode('558591203123')).toBe(true);
|
||||||
|
expect(isBrazilCountryCode('5491123456789')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags malformed BR numbers and accepts valid lengths', () => {
|
||||||
|
expect(isMalformedBrazilPhone('55859912')).toBe(true);
|
||||||
|
expect(isMalformedBrazilPhone('558591203123')).toBe(false);
|
||||||
|
expect(isMalformedBrazilPhone('5585991203123')).toBe(false);
|
||||||
|
// not a BR number, not our concern
|
||||||
|
expect(isMalformedBrazilPhone('123')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects BR landline numbers', () => {
|
||||||
|
expect(isBrazilLandline('558540423147')).toBe(true);
|
||||||
|
expect(isBrazilMobile('558540423147')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects BR mobile numbers', () => {
|
||||||
|
expect(isBrazilMobile('558591203123')).toBe(true);
|
||||||
|
expect(isBrazilMobile('5585991203123')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts any digit after the leading 9 on a 9-digit local', () => {
|
||||||
|
// Brazil has no 9-digit landline, so '9' + 8 digits is always mobile.
|
||||||
|
// The old rule required 6-9 right after the 9 and rejected real SP lines.
|
||||||
|
expect(isBrazilMobile('5511953523741')).toBe(true);
|
||||||
|
expect(isBrazilLandline('5511953523741')).toBe(false);
|
||||||
|
expect(isBrazilMobile('5511912345678')).toBe(true);
|
||||||
|
expect(isBrazilMobile('5511902345678')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps 9-digit mobiles out of the lookup range untouched', () => {
|
||||||
|
// DDD 11 is below the lookup range: no candidates, no WhatsApp lookup.
|
||||||
|
expect(needsBrazilWhatsAppLookup('5511953523741')).toBe(false);
|
||||||
|
expect(normalizeBrazilMobileForSendDigits('5511953523741')).toBe(
|
||||||
|
'5511953523741',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects landlines in both DDD ranges', () => {
|
||||||
|
expect(isBrazilLandline('551151923057')).toBe(true);
|
||||||
|
expect(isBrazilLandline('558540428310')).toBe(true);
|
||||||
|
expect(isBrazilMobile('551151923057')).toBe(false);
|
||||||
|
expect(isBrazilMobile('558540428310')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rewrites toll-free (0800) numbers to the stored form', () => {
|
||||||
|
// Dialed '0800' + 7 digits -> stored '55800' + 7 digits (leading 0 dropped,
|
||||||
|
// country code added). Both the bare and the 55-prefixed dialed forms map
|
||||||
|
// to the same stored number the server resolves them to.
|
||||||
|
expect(normalizeBrazilTollFreeDigits('08000464636')).toBe('558000464636');
|
||||||
|
expect(normalizeBrazilTollFreeDigits('5508000464636')).toBe('558000464636');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves non-toll-free and already-stored numbers untouched', () => {
|
||||||
|
// Already-stored form routes through normally, so no rewrite here.
|
||||||
|
expect(normalizeBrazilTollFreeDigits('558000464636')).toBeNull();
|
||||||
|
expect(normalizeBrazilTollFreeDigits('5585991203123')).toBeNull();
|
||||||
|
expect(normalizeBrazilTollFreeDigits('551151923057')).toBeNull();
|
||||||
|
// 0300/0500/0900 share the shape but are not handled here.
|
||||||
|
expect(normalizeBrazilTollFreeDigits('03001234567')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires lookup only for DDD 31-99 mobile numbers', () => {
|
||||||
|
expect(needsBrazilWhatsAppLookup('5511987654321')).toBe(false);
|
||||||
|
expect(needsBrazilWhatsAppLookup('558591203123')).toBe(true);
|
||||||
|
expect(needsBrazilWhatsAppLookup('558540423147')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps send heuristic without 9 for DDD 31-99 until lookup resolves', () => {
|
||||||
|
expect(normalizeBrazilMobileForSendDigits('558591203123')).toBe(
|
||||||
|
'558591203123',
|
||||||
|
);
|
||||||
|
expect(normalizeBrazilMobileForSendDigits('555399034520')).toBe(
|
||||||
|
'555399034520',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes low DDD mobile numbers for send with 9 digits', () => {
|
||||||
|
expect(normalizeBrazilMobileForSendDigits('551198765432')).toBe(
|
||||||
|
'5511998765432',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates with and without 9 candidates', () => {
|
||||||
|
expect(generateBrazilMobileLookupCandidates('558591203123')).toEqual([
|
||||||
|
'558591203123',
|
||||||
|
'5585991203123',
|
||||||
|
]);
|
||||||
|
expect(generateBrazilMobileLookupCandidates('5585991203123')).toEqual([
|
||||||
|
'558591203123',
|
||||||
|
'5585991203123',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts digits from chat ids', () => {
|
||||||
|
expect(extractPhoneDigits('558591203123@c.us')).toBe('558591203123');
|
||||||
|
expect(extractPhoneDigits('+558591203123')).toBe('558591203123');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import {
|
||||||
|
PhoneNumberResolution,
|
||||||
|
PhoneNumberRule,
|
||||||
|
} from '@waha/apps/phone-numbers/rules/PhoneNumberRule';
|
||||||
|
|
||||||
|
const COUNTRY_CODE = '55';
|
||||||
|
const LANDLINE_FIRST_DIGIT = /^[2-5]/;
|
||||||
|
const MOBILE_FIRST_DIGIT = /^[6-9]/;
|
||||||
|
|
||||||
|
export const BR_PHONE_DDD_LOOKUP_MIN = 31;
|
||||||
|
export const BR_PHONE_DDD_LOOKUP_MAX = 99;
|
||||||
|
|
||||||
|
// A Brazil number (country code 55) must be 55 + DDD(2) + local(8 or 9) digits.
|
||||||
|
const BR_PHONE_MIN_LENGTH = 12;
|
||||||
|
const BR_PHONE_MAX_LENGTH = 13;
|
||||||
|
|
||||||
|
export function isBrazilCountryCode(digits: string): boolean {
|
||||||
|
return digits.startsWith(COUNTRY_CODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMalformedBrazilPhone(digits: string): boolean {
|
||||||
|
if (!isBrazilCountryCode(digits)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
digits.length < BR_PHONE_MIN_LENGTH || digits.length > BR_PHONE_MAX_LENGTH
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBrazilPhone(digits: string): boolean {
|
||||||
|
return digits.startsWith(COUNTRY_CODE) && digits.length >= 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrazilDdd(digits: string): number {
|
||||||
|
return parseInt(digits.substring(2, 4), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrazilLocalPart(digits: string): string {
|
||||||
|
return digits.substring(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBrazilLandline(digits: string): boolean {
|
||||||
|
if (!isBrazilPhone(digits)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const local = getBrazilLocalPart(digits);
|
||||||
|
if (local.length !== 8) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return LANDLINE_FIRST_DIGIT.test(local);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBrazilMobile(digits: string): boolean {
|
||||||
|
if (!isBrazilPhone(digits) || isBrazilLandline(digits)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const local = getBrazilLocalPart(digits);
|
||||||
|
// 8-digit local: the legacy form, missing its 9. Only 6-9 tells it apart
|
||||||
|
// from a landline - 2-5 is genuinely ambiguous and treated as a landline.
|
||||||
|
if (local.length === 8) {
|
||||||
|
return MOBILE_FIRST_DIGIT.test(local);
|
||||||
|
}
|
||||||
|
// 9-digit local starting with 9: unambiguously mobile. Brazil has no
|
||||||
|
// 9-digit landline, and the digit after the leading 9 is unrestricted -
|
||||||
|
// e.g. SP 11 9535-23741. Do not test it.
|
||||||
|
if (local.length === 9 && local[0] === '9') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brazilian toll-free (0800) numbers are non-geographic: dialed as '0800' plus
|
||||||
|
// 7 subscriber digits. WhatsApp stores them under country code 55 with the
|
||||||
|
// leading 0 dropped ('08000464636' -> '558000464636'). Callers send the dialed
|
||||||
|
// form, so map it to the stored one. The rewrite is deterministic - no WhatsApp
|
||||||
|
// lookup, unlike the 9th-digit mobile case. Returns null when not a toll-free
|
||||||
|
// number in a dialed form (the stored form '55800...' already routes untouched).
|
||||||
|
const BR_TOLLFREE_DIALED = /^0800\d{7}$/;
|
||||||
|
const BR_TOLLFREE_DIALED_CC = /^550800\d{7}$/;
|
||||||
|
|
||||||
|
export function normalizeBrazilTollFreeDigits(digits: string): string | null {
|
||||||
|
if (BR_TOLLFREE_DIALED.test(digits)) {
|
||||||
|
return `${COUNTRY_CODE}${digits.slice(1)}`;
|
||||||
|
}
|
||||||
|
if (BR_TOLLFREE_DIALED_CC.test(digits)) {
|
||||||
|
return `${COUNTRY_CODE}${digits.slice(3)}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function needsBrazilWhatsAppLookup(digits: string): boolean {
|
||||||
|
if (!isBrazilMobile(digits)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const ddd = getBrazilDdd(digits);
|
||||||
|
return ddd >= BR_PHONE_DDD_LOOKUP_MIN && ddd <= BR_PHONE_DDD_LOOKUP_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBrazilMobileForSendDigits(digits: string): string {
|
||||||
|
if (!isBrazilMobile(digits)) {
|
||||||
|
return digits;
|
||||||
|
}
|
||||||
|
const ddd = getBrazilDdd(digits);
|
||||||
|
if (ddd >= BR_PHONE_DDD_LOOKUP_MIN) {
|
||||||
|
return digits;
|
||||||
|
}
|
||||||
|
const local = getBrazilLocalPart(digits);
|
||||||
|
if (local.length === 8 && MOBILE_FIRST_DIGIT.test(local)) {
|
||||||
|
return `${COUNTRY_CODE}${ddd}9${local}`;
|
||||||
|
}
|
||||||
|
return digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateBrazilMobileLookupCandidates(digits: string): string[] {
|
||||||
|
if (!isBrazilMobile(digits)) {
|
||||||
|
return [digits];
|
||||||
|
}
|
||||||
|
const ddd = digits.substring(2, 4);
|
||||||
|
const local = getBrazilLocalPart(digits);
|
||||||
|
let without9 = digits;
|
||||||
|
let with9 = digits;
|
||||||
|
|
||||||
|
if (local.length === 9 && local[0] === '9') {
|
||||||
|
without9 = `${COUNTRY_CODE}${ddd}${local.substring(1)}`;
|
||||||
|
with9 = `${COUNTRY_CODE}${ddd}${local}`;
|
||||||
|
} else if (local.length === 8) {
|
||||||
|
without9 = `${COUNTRY_CODE}${ddd}${local}`;
|
||||||
|
with9 = `${COUNTRY_CODE}${ddd}9${local}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = [without9, with9];
|
||||||
|
return [...new Set(candidates)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
|
||||||
|
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 extends PhoneNumbersAppServiceBase<BrazilianPhoneNumbersAppConfig> {
|
||||||
|
protected readonly Repository = BrazilianPhoneNumbersCacheRepository;
|
||||||
|
|
||||||
|
constructor(resolver: UniqueAppResolver) {
|
||||||
|
super(resolver);
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
|
||||||
|
import { AppName } from '@waha/apps/app_sdk/apps/apps';
|
||||||
|
import { CallsAppService } from '@waha/apps/calls/services/CallsAppService';
|
||||||
|
|
||||||
|
const CallsAppModule: AppModule = {
|
||||||
|
name: AppName.calls,
|
||||||
|
openapi: {
|
||||||
|
title: 'Calls',
|
||||||
|
description: 'Handle incoming calls - reject, message back',
|
||||||
|
},
|
||||||
|
definition: {
|
||||||
|
plainkey: false,
|
||||||
|
queue: false,
|
||||||
|
migrations: false,
|
||||||
|
restartOnChange: true,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
nestjs: {
|
||||||
|
imports: [],
|
||||||
|
controllers: [],
|
||||||
|
providers: [CallsAppService],
|
||||||
|
},
|
||||||
|
Service: CallsAppService,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CallsAppModule;
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { CallsAppService } from '@waha/apps/calls/services/CallsAppService';
|
|
||||||
|
|
||||||
export const CallsAppExports = {
|
|
||||||
providers: [CallsAppService],
|
|
||||||
imports: [],
|
|
||||||
controllers: [],
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { App } from '@waha/apps/app_sdk/dto/app.dto';
|
||||||
|
import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class CallsAppDto extends App<CallsAppConfig> {
|
||||||
|
@Type(() => CallsAppConfig)
|
||||||
|
config: CallsAppConfig;
|
||||||
|
}
|
||||||
@@ -1,19 +1,14 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { App } from '@waha/apps/app_sdk/dto/app.dto';
|
import { App } from '@waha/apps/app_sdk/dto/app.dto';
|
||||||
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
|
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
|
||||||
|
import { PluginOptions } from '@waha/core/abc/session.plugin';
|
||||||
import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto';
|
import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto';
|
||||||
|
import { CallsPlugin } from '@waha/apps/calls/services/CallsPlugin';
|
||||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||||
import { WhatsappSession } from '@waha/core/abc/session.abc';
|
import { WhatsappSession } from '@waha/core/abc/session.abc';
|
||||||
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
|
|
||||||
import { CallsListener } from '@waha/apps/calls/services/CallsListener';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CallsAppService implements IAppService {
|
export class CallsAppService implements IAppService {
|
||||||
constructor(
|
|
||||||
@InjectPinoLogger('CallsAppService')
|
|
||||||
private readonly logger: PinoLogger,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
validate(app: App<CallsAppConfig>): void {
|
validate(app: App<CallsAppConfig>): void {
|
||||||
// The DTO validation covers structure; no extra validation rules.
|
// The DTO validation covers structure; no extra validation rules.
|
||||||
void app;
|
void app;
|
||||||
@@ -79,6 +74,14 @@ export class CallsAppService implements IAppService {
|
|||||||
void app;
|
void app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async purge(
|
||||||
|
manager: SessionManager,
|
||||||
|
app: App<CallsAppConfig>,
|
||||||
|
): Promise<void> {
|
||||||
|
void manager;
|
||||||
|
void app;
|
||||||
|
}
|
||||||
|
|
||||||
async enrich(
|
async enrich(
|
||||||
manager: SessionManager,
|
manager: SessionManager,
|
||||||
app: App<CallsAppConfig>,
|
app: App<CallsAppConfig>,
|
||||||
@@ -87,9 +90,15 @@ export class CallsAppService implements IAppService {
|
|||||||
void app;
|
void app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
plugins(app: App<CallsAppConfig>, session: WhatsappSession): PluginOptions[] {
|
||||||
|
void session;
|
||||||
|
return [CallsPlugin.with(app.config, null)];
|
||||||
|
}
|
||||||
|
|
||||||
beforeSessionStart(app: App<CallsAppConfig>, session: WhatsappSession): void {
|
beforeSessionStart(app: App<CallsAppConfig>, session: WhatsappSession): void {
|
||||||
const listener = new CallsListener(app, session, this.logger);
|
void app;
|
||||||
listener.attach();
|
void session;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
afterSessionStart(app: App<CallsAppConfig>, session: WhatsappSession): void {
|
afterSessionStart(app: App<CallsAppConfig>, session: WhatsappSession): void {
|
||||||
|
|||||||
@@ -1,62 +1,29 @@
|
|||||||
import { Subscription } from 'rxjs';
|
|
||||||
import {
|
import {
|
||||||
CallsAppChannelConfig,
|
CallsAppChannelConfig,
|
||||||
CallsAppConfig,
|
CallsAppConfig,
|
||||||
} from '@waha/apps/calls/dto/config.dto';
|
} from '@waha/apps/calls/dto/config.dto';
|
||||||
import { Logger } from 'pino';
|
import { SessionPlugin } from '@waha/core/abc/session.plugin';
|
||||||
import { App } from '@waha/apps/app_sdk/dto/app.dto';
|
import { PluginEvent } from '@waha/core/abc/session.plugin.events';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
|
||||||
import { WhatsappSession } from '@waha/core/abc/session.abc';
|
|
||||||
import { WAHAEvents, WAHAPresenceStatus } from '@waha/structures/enums.dto';
|
|
||||||
import { CallData } from '@waha/structures/calls.dto';
|
import { CallData } from '@waha/structures/calls.dto';
|
||||||
import { MessageTextRequest } from '@waha/structures/chatting.dto';
|
import { MessageTextRequest } from '@waha/structures/chatting.dto';
|
||||||
|
import { WAHAEvents, WAHAPresenceStatus } from '@waha/structures/enums.dto';
|
||||||
import { sleep } from '@waha/utils/promiseTimeout';
|
import { sleep } from '@waha/utils/promiseTimeout';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
export class CallsListener {
|
/**
|
||||||
private subscription?: Subscription;
|
* Rejects incoming calls and optionally sends an auto-reply message.
|
||||||
private config: CallsAppConfig;
|
*/
|
||||||
private readonly log: Logger;
|
export class CallsPlugin extends SessionPlugin<CallsAppConfig> {
|
||||||
private readonly session: WhatsappSession;
|
@PluginEvent(WAHAEvents.CALL_RECEIVED)
|
||||||
|
onCallReceived(calls$: Observable<CallData>) {
|
||||||
constructor(
|
calls$.subscribe((call: CallData) => {
|
||||||
app: App<CallsAppConfig>,
|
this.handleCall(call).catch((error) => {
|
||||||
session: WhatsappSession,
|
this.logger.error(
|
||||||
logger: PinoLogger,
|
{ err: error, callId: call?.id },
|
||||||
) {
|
|
||||||
this.session = session;
|
|
||||||
this.config = app.config;
|
|
||||||
this.log = logger.logger.child({
|
|
||||||
app: 'calls',
|
|
||||||
session: app.session,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
attach(): void {
|
|
||||||
this.detach();
|
|
||||||
|
|
||||||
const observable = this.session.getEventObservable(
|
|
||||||
WAHAEvents.CALL_RECEIVED,
|
|
||||||
);
|
|
||||||
if (!observable) {
|
|
||||||
this.log.warn('CALL_RECEIVED event stream is not available, skipping');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.subscription = observable.subscribe((payload) => {
|
|
||||||
this.handleCall(payload as CallData).catch((error) => {
|
|
||||||
this.log.error(
|
|
||||||
{ err: error, callId: (payload as any)?.id },
|
|
||||||
'Failed to handle incoming call',
|
'Failed to handle incoming call',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.log.info('Calls app listener is attached');
|
|
||||||
}
|
|
||||||
|
|
||||||
detach(): void {
|
|
||||||
this.subscription?.unsubscribe();
|
|
||||||
this.subscription = undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private configFor(call: CallData): CallsAppChannelConfig {
|
private configFor(call: CallData): CallsAppChannelConfig {
|
||||||
@@ -65,17 +32,17 @@ export class CallsListener {
|
|||||||
|
|
||||||
private async handleCall(call: CallData): Promise<void> {
|
private async handleCall(call: CallData): Promise<void> {
|
||||||
if (!call.from) {
|
if (!call.from) {
|
||||||
this.log.warn({ call: call?.id }, 'Incoming call has no chat id');
|
this.logger.warn({ call: call?.id }, 'Incoming call has no chat id');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!call.id) {
|
if (!call.id) {
|
||||||
this.log.warn({ from: call.from }, 'Incoming call has no from');
|
this.logger.warn({ from: call.from }, 'Incoming call has no from');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = this.configFor(call);
|
const config = this.configFor(call);
|
||||||
if (!config) {
|
if (!config) {
|
||||||
this.log.warn({ callId: call.id }, 'No calls config found, skipping');
|
this.logger.warn({ callId: call.id }, 'No calls config found, skipping');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +51,7 @@ export class CallsListener {
|
|||||||
const shouldMessage = message.length > 0;
|
const shouldMessage = message.length > 0;
|
||||||
|
|
||||||
if (!shouldReject && !shouldMessage) {
|
if (!shouldReject && !shouldMessage) {
|
||||||
this.log.debug(
|
this.logger.debug(
|
||||||
{ callId: call.id, chatId: call.from },
|
{ callId: call.id, chatId: call.from },
|
||||||
'No actions configured for this call',
|
'No actions configured for this call',
|
||||||
);
|
);
|
||||||
@@ -105,16 +72,19 @@ export class CallsListener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async rejectCall(call: CallData): Promise<void> {
|
private async rejectCall(call: CallData): Promise<void> {
|
||||||
this.log.debug({ from: call.from, id: call.id }, 'Rejecting incoming call');
|
this.logger.debug(
|
||||||
|
{ from: call.from, id: call.id },
|
||||||
|
'Rejecting incoming call',
|
||||||
|
);
|
||||||
await this.session.rejectCall(call.from, call.id);
|
await this.session.rejectCall(call.from, call.id);
|
||||||
this.log.info({ from: call.from, id: call.id }, 'Call rejected');
|
this.logger.info({ from: call.from, id: call.id }, 'Call rejected');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async replyWithTyping(
|
private async replyWithTyping(
|
||||||
chatId: string,
|
chatId: string,
|
||||||
message: string,
|
message: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.log.info(
|
this.logger.info(
|
||||||
{ chatId: chatId },
|
{ chatId: chatId },
|
||||||
'Sending auto-response for rejected call',
|
'Sending auto-response for rejected call',
|
||||||
);
|
);
|
||||||
@@ -132,7 +102,7 @@ export class CallsListener {
|
|||||||
try {
|
try {
|
||||||
await this.session.setPresence(WAHAPresenceStatus.TYPING, chatId);
|
await this.session.setPresence(WAHAPresenceStatus.TYPING, chatId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.log.warn(
|
this.logger.warn(
|
||||||
{ err: error, chatId: chatId },
|
{ err: error, chatId: chatId },
|
||||||
'Failed to set typing presence before reply',
|
'Failed to set typing presence before reply',
|
||||||
);
|
);
|
||||||
@@ -146,7 +116,7 @@ export class CallsListener {
|
|||||||
try {
|
try {
|
||||||
await this.session.setPresence(WAHAPresenceStatus.PAUSED, chatId);
|
await this.session.setPresence(WAHAPresenceStatus.PAUSED, chatId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.log.warn(
|
this.logger.warn(
|
||||||
{ err: error, chatId: chatId },
|
{ err: error, chatId: chatId },
|
||||||
'Failed to clear typing presence after reply',
|
'Failed to clear typing presence after reply',
|
||||||
);
|
);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import { sortBy } from 'lodash';
|
import { sortBy } from 'lodash';
|
||||||
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiSecurity } from '@nestjs/swagger';
|
||||||
import { i18n } from '@waha/apps/chatwoot/i18n';
|
import { i18n } from '@waha/apps/chatwoot/i18n';
|
||||||
|
|
||||||
interface LanguageResponse {
|
interface LanguageResponse {
|
||||||
@@ -10,7 +10,6 @@ interface LanguageResponse {
|
|||||||
|
|
||||||
@Controller('api/apps/chatwoot')
|
@Controller('api/apps/chatwoot')
|
||||||
@ApiSecurity('api_key')
|
@ApiSecurity('api_key')
|
||||||
@ApiTags('🧩 Apps')
|
|
||||||
export class ChatwootLocalesController {
|
export class ChatwootLocalesController {
|
||||||
@Get('locales')
|
@Get('locales')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation } from '@nestjs/swagger';
|
||||||
import { IsCommandsChat } from '@waha/apps/chatwoot/client/ids';
|
import { IsCommandsChat } from '@waha/apps/chatwoot/client/ids';
|
||||||
import { EventName, MessageType } from '@waha/apps/chatwoot/client/types';
|
import { EventName, MessageType } from '@waha/apps/chatwoot/client/types';
|
||||||
import { InboxData } from '@waha/apps/chatwoot/consumers/types';
|
import { InboxData } from '@waha/apps/chatwoot/consumers/types';
|
||||||
@@ -13,9 +13,9 @@ import { ChatWootQueueService } from '@waha/apps/chatwoot/services/ChatWootQueue
|
|||||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||||
import { AppRepository } from '@waha/apps/app_sdk/storage/AppRepository';
|
import { AppRepository } from '@waha/apps/app_sdk/storage/AppRepository';
|
||||||
import { CommandPrefix } from '@waha/apps/chatwoot/cli';
|
import { CommandPrefix } from '@waha/apps/chatwoot/cli';
|
||||||
|
import { IsExternalEcho } from '@waha/apps/chatwoot/api/webhook.guards';
|
||||||
|
|
||||||
@Controller('webhooks/chatwoot/')
|
@Controller('webhooks/chatwoot/')
|
||||||
@ApiTags('🧩 Apps')
|
|
||||||
export class ChatwootWebhookController {
|
export class ChatwootWebhookController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly chatWootQueueService: ChatWootQueueService,
|
private readonly chatWootQueueService: ChatWootQueueService,
|
||||||
@@ -42,8 +42,13 @@ export class ChatwootWebhookController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isCommandsChat = IsCommandsChat(body);
|
const isCommandsChat = IsCommandsChat(body);
|
||||||
// Ignore private notes (most of them)
|
|
||||||
const deleted = body?.content_attributes?.deleted;
|
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) {
|
if (body.private) {
|
||||||
// Ignore any private note in commands chats
|
// Ignore any private note in commands chats
|
||||||
if (isCommandsChat) {
|
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);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
|
||||||
|
import { AppName } from '@waha/apps/app_sdk/apps/apps';
|
||||||
import { RegisterAppQueue } from '@waha/apps/app_sdk/BullUtils';
|
import { RegisterAppQueue } from '@waha/apps/app_sdk/BullUtils';
|
||||||
import {
|
import {
|
||||||
ExponentialRetriesJobOptions,
|
ExponentialRetriesJobOptions,
|
||||||
@@ -37,10 +39,15 @@ import { TaskContactsPullConsumer } from '@waha/apps/chatwoot/consumers/task/con
|
|||||||
import { TaskMessagesPullConsumer } from '@waha/apps/chatwoot/consumers/task/messages.pull';
|
import { TaskMessagesPullConsumer } from '@waha/apps/chatwoot/consumers/task/messages.pull';
|
||||||
import { BullModule } from '@nestjs/bullmq';
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
import { QueueManager } from '@waha/apps/chatwoot/services/QueueManager';
|
import { QueueManager } from '@waha/apps/chatwoot/services/QueueManager';
|
||||||
|
import { HttpPathsRegistration } from '@waha/plugins/http.paths.module';
|
||||||
|
|
||||||
const CONTROLLERS = [ChatwootWebhookController, ChatwootLocalesController];
|
const CONTROLLERS = [ChatwootWebhookController, ChatwootLocalesController];
|
||||||
|
|
||||||
const IMPORTS = lodash.flatten([
|
const IMPORTS = lodash.flatten([
|
||||||
|
HttpPathsRegistration({
|
||||||
|
prefix: '/webhooks/',
|
||||||
|
include: { authBasic: false },
|
||||||
|
}),
|
||||||
BullModule.registerFlowProducer({
|
BullModule.registerFlowProducer({
|
||||||
name: FlowProducerName.MESSAGES_PULL_FLOW,
|
name: FlowProducerName.MESSAGES_PULL_FLOW,
|
||||||
}),
|
}),
|
||||||
@@ -160,8 +167,25 @@ const PROVIDERS = [
|
|||||||
QueueManager,
|
QueueManager,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ChatWootExports = {
|
const ChatWootAppModule: AppModule = {
|
||||||
providers: PROVIDERS,
|
name: AppName.chatwoot,
|
||||||
imports: IMPORTS,
|
openapi: {
|
||||||
controllers: CONTROLLERS,
|
title: 'Chatwoot',
|
||||||
|
description: 'Chatwoot integration',
|
||||||
|
},
|
||||||
|
definition: {
|
||||||
|
plainkey: true,
|
||||||
|
queue: true,
|
||||||
|
migrations: true,
|
||||||
|
restartOnChange: true,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
nestjs: {
|
||||||
|
imports: IMPORTS,
|
||||||
|
controllers: CONTROLLERS,
|
||||||
|
providers: PROVIDERS,
|
||||||
|
},
|
||||||
|
Service: ChatWootAppService,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default ChatWootAppModule;
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { ContactsPullOptions } from '@waha/apps/chatwoot/consumers/task/contacts.pull';
|
import { ContactsPullOptions } from '@waha/apps/chatwoot/consumers/task/contacts.pull';
|
||||||
import { JobsOptions } from 'bullmq';
|
import { JobsOptions } from 'bullmq';
|
||||||
import { JobDataTimeout } from '@waha/apps/app_sdk/AppConsumer';
|
import { JobDataTimeout } from '@waha/apps/app_sdk/AppConsumer';
|
||||||
|
import { NameUpdateMode } from '@waha/apps/chatwoot/client/ContactService';
|
||||||
|
|
||||||
export function AddContactsCommand(program: Command, ctx: CommandContext) {
|
export function AddContactsCommand(program: Command, ctx: CommandContext) {
|
||||||
const l = ctx.l;
|
const l = ctx.l;
|
||||||
@@ -39,6 +40,14 @@ export function AddContactsCommand(program: Command, ctx: CommandContext) {
|
|||||||
.choices(['if-missing', 'update'])
|
.choices(['if-missing', 'update'])
|
||||||
.preset('if-missing'),
|
.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('-g, --groups', l.r('cli.cmd.contacts.pull.option.groups'))
|
||||||
.option('-l, --lids', l.r('cli.cmd.contacts.pull.option.lids'))
|
.option('-l, --lids', l.r('cli.cmd.contacts.pull.option.lids'))
|
||||||
.option(
|
.option(
|
||||||
@@ -92,6 +101,7 @@ export function AddContactsCommand(program: Command, ctx: CommandContext) {
|
|||||||
batch: opts.batch,
|
batch: opts.batch,
|
||||||
progress: opts.progress,
|
progress: opts.progress,
|
||||||
avatar: opts.avatar,
|
avatar: opts.avatar,
|
||||||
|
updateNames: opts.updateNames,
|
||||||
attributes: opts.attributes,
|
attributes: opts.attributes,
|
||||||
contacts: {
|
contacts: {
|
||||||
lids: opts.lids,
|
lids: opts.lids,
|
||||||
|
|||||||
@@ -24,6 +24,36 @@ import { AttributeKey } from '@waha/apps/chatwoot/const';
|
|||||||
export interface ContactInfo {
|
export interface ContactInfo {
|
||||||
ChatId(): string;
|
ChatId(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reuse an already fetched WhatsApp contact instead of requesting it again
|
||||||
|
*/
|
||||||
|
SetFetchedContact(contact: any): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linked id - @lid, null when unknown
|
||||||
|
*/
|
||||||
|
LidId(): Promise<string | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phone number jid - @c.us, null when unknown
|
||||||
|
*/
|
||||||
|
JidId(): Promise<string | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolved E.164 phone number, null when not applicable
|
||||||
|
*/
|
||||||
|
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>;
|
AvatarUrl(): Promise<string | null>;
|
||||||
|
|
||||||
Attributes(): Promise<any>;
|
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', () => {
|
describe('sanitizeName', () => {
|
||||||
it('should return the same name', () => {
|
it('should return the same name', () => {
|
||||||
@@ -21,3 +24,36 @@ describe('sanitizeName', () => {
|
|||||||
expect(sanitizeName(name)).toBe(name);
|
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,
|
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) {
|
export function sanitizeName(name: string) {
|
||||||
// 255 chars max
|
// 255 chars max
|
||||||
const limit = 255;
|
const limit = 255;
|
||||||
@@ -40,6 +77,18 @@ export function sanitizeName(name: string) {
|
|||||||
return clean.slice(0, 255).trim();
|
return clean.slice(0, 255).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SearchClauseEqualTo(key: string, values: string[]) {
|
||||||
|
// equal_to with multiple values acts as IN
|
||||||
|
return {
|
||||||
|
attribute_key: key,
|
||||||
|
filter_operator: 'equal_to',
|
||||||
|
values: values,
|
||||||
|
attribute_model: 'standard',
|
||||||
|
custom_attribute_type: '',
|
||||||
|
query_operator: 'OR',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export class ContactService {
|
export class ContactService {
|
||||||
constructor(
|
constructor(
|
||||||
private config: ChatWootAPIConfig,
|
private config: ChatWootAPIConfig,
|
||||||
@@ -52,8 +101,11 @@ export class ContactService {
|
|||||||
contactInfo: ContactInfo,
|
contactInfo: ContactInfo,
|
||||||
): Promise<[ContactResponse, boolean]> {
|
): Promise<[ContactResponse, boolean]> {
|
||||||
const chatId = contactInfo.ChatId();
|
const chatId = contactInfo.ChatId();
|
||||||
let contact = await this.searchByAnyID(chatId);
|
const lid = await contactInfo.LidId();
|
||||||
|
const jid = await contactInfo.JidId();
|
||||||
|
let contact = await this.search(chatId, lid, jid);
|
||||||
if (contact) {
|
if (contact) {
|
||||||
|
await this.upsertPhoneNumber(contact, contactInfo);
|
||||||
return [contact, false];
|
return [contact, false];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,73 +114,94 @@ export class ContactService {
|
|||||||
return [contact, true];
|
return [contact, true];
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchByAnyID(chatId: string): Promise<ContactResponse | null> {
|
async search(
|
||||||
const payload: any[] = [
|
chatId: string,
|
||||||
{
|
lid: string | null,
|
||||||
attribute_key: AttributeKey.WA_CHAT_ID,
|
jid: string | null,
|
||||||
filter_operator: 'equal_to',
|
): Promise<ContactResponse | null> {
|
||||||
values: [chatId],
|
// The chat id attribute holds the latest used address, so match any known form there
|
||||||
attribute_model: 'standard',
|
const chatIds = lodash.uniq(lodash.compact([chatId, jid, lid]));
|
||||||
custom_attribute_type: '',
|
if (chatIds.length == 0) {
|
||||||
query_operator: 'OR',
|
return null;
|
||||||
},
|
|
||||||
{
|
|
||||||
attribute_key: AttributeKey.WA_JID,
|
|
||||||
filter_operator: 'equal_to',
|
|
||||||
values: [chatId],
|
|
||||||
attribute_model: 'standard',
|
|
||||||
custom_attribute_type: '',
|
|
||||||
query_operator: 'OR',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
attribute_key: AttributeKey.WA_LID,
|
|
||||||
filter_operator: 'equal_to',
|
|
||||||
values: [chatId],
|
|
||||||
attribute_model: 'standard',
|
|
||||||
custom_attribute_type: '',
|
|
||||||
query_operator: 'OR',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
attribute_key: 'identifier',
|
|
||||||
filter_operator: 'equal_to',
|
|
||||||
values: [chatId],
|
|
||||||
attribute_model: 'standard',
|
|
||||||
custom_attribute_type: '',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (isJidCusFormat(chatId)) {
|
|
||||||
// Search by phone
|
|
||||||
const phoneNumberE164 = E164Parser.fromJid(chatId);
|
|
||||||
const phone_number = phoneNumberE164.replace('+', '');
|
|
||||||
payload[payload.length - 1].query_operator = 'OR';
|
|
||||||
payload.push({
|
|
||||||
attribute_key: 'phone_number',
|
|
||||||
filter_operator: 'equal_to',
|
|
||||||
values: [phone_number],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const payload: any[] = [
|
||||||
|
SearchClauseEqualTo(AttributeKey.WA_CHAT_ID, chatIds),
|
||||||
|
SearchClauseEqualTo('identifier', chatIds),
|
||||||
|
];
|
||||||
|
if (jid) {
|
||||||
|
payload.push(SearchClauseEqualTo(AttributeKey.WA_JID, [jid]));
|
||||||
|
}
|
||||||
|
if (lid) {
|
||||||
|
payload.push(SearchClauseEqualTo(AttributeKey.WA_LID, [lid]));
|
||||||
|
}
|
||||||
|
if (jid && isJidCusFormat(jid)) {
|
||||||
|
// Search by phone - both with and without the leading '+'
|
||||||
|
const phoneNumberE164 = E164Parser.fromJid(jid);
|
||||||
|
let phones = [phoneNumberE164, phoneNumberE164.replace('+', '')];
|
||||||
|
payload.push(SearchClauseEqualTo('phone_number', phones));
|
||||||
|
}
|
||||||
|
// The terminal clause must have no query_operator
|
||||||
|
delete payload[payload.length - 1].query_operator;
|
||||||
|
|
||||||
const response: any = await this.accountAPI.contacts.filter({
|
const response: any = await this.accountAPI.contacts.filter({
|
||||||
accountId: this.config.accountId,
|
accountId: this.config.accountId,
|
||||||
payload: payload as any,
|
payload: payload as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
const contacts = response.payload;
|
const contacts = response.payload;
|
||||||
if (contacts.length == 0) {
|
const candidates: ContactResponse[] = [];
|
||||||
|
for (const contact of contacts) {
|
||||||
|
const inboxes = lodash.filter(contact.contact_inboxes, {
|
||||||
|
inbox: { id: this.config.inboxId },
|
||||||
|
});
|
||||||
|
if (inboxes.length == 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
candidates.push({
|
||||||
|
data: contact,
|
||||||
|
sourceId: inboxes[0].source_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (candidates.length == 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const contact = contacts[0];
|
// Prefer a contact with a phone number over a phone-less duplicate
|
||||||
const inboxes = lodash.filter(contact.contact_inboxes, {
|
const withPhone = candidates.find((candidate) =>
|
||||||
inbox: { id: this.config.inboxId },
|
Boolean(candidate.data.phone_number),
|
||||||
});
|
);
|
||||||
if (inboxes.length == 0) {
|
return withPhone ?? candidates[0];
|
||||||
return null;
|
}
|
||||||
|
|
||||||
|
private async upsertPhoneNumber(
|
||||||
|
contact: ContactResponse,
|
||||||
|
contactInfo: ContactInfo,
|
||||||
|
): Promise<void> {
|
||||||
|
if (contact.data.phone_number) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const phoneNumberE164 = await contactInfo.PhoneNumberE164();
|
||||||
|
if (!phoneNumberE164) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.accountAPI.contacts.update({
|
||||||
|
id: contact.data.id,
|
||||||
|
accountId: this.config.accountId,
|
||||||
|
data: { phone_number: phoneNumberE164 },
|
||||||
|
});
|
||||||
|
contact.data.phone_number = phoneNumberE164;
|
||||||
|
this.logger.info(
|
||||||
|
`Set phone_number for contact.id: ${
|
||||||
|
contact.data.id
|
||||||
|
}, chat.id: ${contactInfo.ChatId()}`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Chatwoot returns 422 when another contact already owns the phone number
|
||||||
|
this.logger.warn(
|
||||||
|
`Error updating phone_number for contact.id: ${contact.data.id} - ${err}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
data: contact,
|
|
||||||
sourceId: inboxes[0].source_id,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async upsertCustomAttributes(
|
public async upsertCustomAttributes(
|
||||||
@@ -149,6 +222,37 @@ export class ContactService {
|
|||||||
return true;
|
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(
|
public async create(
|
||||||
chatId: string,
|
chatId: string,
|
||||||
payload: public_contact_create_update_payload,
|
payload: public_contact_create_update_payload,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import ChatwootClient from '@figuro/chatwoot-sdk';
|
import ChatwootClient from '@figuro/chatwoot-sdk';
|
||||||
import type { conversation_message_create } from '@figuro/chatwoot-sdk/dist/models/conversation_message_create';
|
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 {
|
export class Conversation {
|
||||||
public onError: (e: any) => void;
|
public onError: (e: any) => void;
|
||||||
@@ -43,6 +44,20 @@ export class Conversation {
|
|||||||
return this.send(data);
|
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) {
|
public async activity(text: string) {
|
||||||
const data: conversation_message_create = {
|
const data: conversation_message_create = {
|
||||||
content: text,
|
content: text,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { AttributeKey, INBOX_CONTACT_CHAT_ID } from '@waha/apps/chatwoot/const';
|
import { AttributeKey, INBOX_CONTACT_CHAT_ID } from '@waha/apps/chatwoot/const';
|
||||||
import * as lodash from 'lodash';
|
import * as lodash from 'lodash';
|
||||||
import { WhatsAppMessage } from '@waha/apps/chatwoot/storage';
|
import { WhatsAppMessage } from '@waha/apps/chatwoot/storage';
|
||||||
import { buildMessageId } from '@waha/core/engines/noweb/session.noweb.core';
|
import { buildMessageId } from '@waha/core/engines/noweb/utils';
|
||||||
import { isLidUser, normalizeJid } from '@waha/core/utils/jids';
|
import { isLidUser, normalizeJid } from '@waha/core/utils/jids';
|
||||||
|
|
||||||
export function GetJID(contact: any): string | null {
|
export function GetJID(contact: any): string | null {
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ export enum MessageType {
|
|||||||
ACTIVITY = 'activity',
|
ACTIVITY = 'activity',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum MessageStatus {
|
||||||
|
SENT = 'sent',
|
||||||
|
DELIVERED = 'delivered',
|
||||||
|
READ = 'read',
|
||||||
|
FAILED = 'failed',
|
||||||
|
}
|
||||||
|
|
||||||
export enum CustomAttributeType {
|
export enum CustomAttributeType {
|
||||||
TEXT = 0,
|
TEXT = 0,
|
||||||
NUMBER = 1,
|
NUMBER = 1,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
} from '@waha/apps/chatwoot/dto/config.dto';
|
} from '@waha/apps/chatwoot/dto/config.dto';
|
||||||
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
||||||
import { isJidGroup } from '@waha/core/utils/jids';
|
import { isJidGroup } from '@waha/core/utils/jids';
|
||||||
|
import { MessageStatusService } from '@waha/apps/chatwoot/services/MessageStatusService';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const mime = require('mime-types');
|
const mime = require('mime-types');
|
||||||
@@ -65,6 +66,7 @@ export class ChatWootInboxMessageCreatedConsumer extends ChatWootInboxMessageCon
|
|||||||
session,
|
session,
|
||||||
container.ChatWootConfig(),
|
container.ChatWootConfig(),
|
||||||
container.Locale(),
|
container.Locale(),
|
||||||
|
container.MessageStatusService(),
|
||||||
);
|
);
|
||||||
return await handler.handle(body);
|
return await handler.handle(body);
|
||||||
}
|
}
|
||||||
@@ -77,6 +79,7 @@ export class MessageHandler {
|
|||||||
private session: WAHASessionAPI,
|
private session: WAHASessionAPI,
|
||||||
private config: ChatWootConfig,
|
private config: ChatWootConfig,
|
||||||
private l: Locale,
|
private l: Locale,
|
||||||
|
private statusService: MessageStatusService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handle(body: any) {
|
async handle(body: any) {
|
||||||
@@ -114,6 +117,7 @@ export class MessageHandler {
|
|||||||
// Send text (Part 1 if present)
|
// Send text (Part 1 if present)
|
||||||
const attachments = message.attachments || [];
|
const attachments = message.attachments || [];
|
||||||
const sendText = content && attachments.length !== 1;
|
const sendText = content && attachments.length !== 1;
|
||||||
|
const parts = attachments.length + (sendText ? 1 : 0);
|
||||||
if (sendText) {
|
if (sendText) {
|
||||||
part += 1; // Text is the first possible part
|
part += 1; // Text is the first possible part
|
||||||
const exists = await this.getMapping(message, part);
|
const exists = await this.getMapping(message, part);
|
||||||
@@ -129,7 +133,7 @@ export class MessageHandler {
|
|||||||
});
|
});
|
||||||
const msg = await this.sendTextMessage(chatId, text, replyTo, mentions);
|
const msg = await this.sendTextMessage(chatId, text, replyTo, mentions);
|
||||||
results.push(msg);
|
results.push(msg);
|
||||||
await this.saveMapping(message, msg, part);
|
await this.saveMapping(message, msg, part, parts);
|
||||||
this.logger.info(`Text message sent: ${msg.id}`);
|
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}`,
|
`File message sent: ${msg.id} - ${file.data_url} - ${file.file_type}`,
|
||||||
);
|
);
|
||||||
results.push(msg);
|
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;
|
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(
|
private async saveMapping(
|
||||||
chatwootMessage: any,
|
chatwootMessage: any,
|
||||||
whatsappMessage: any,
|
whatsappMessage: any,
|
||||||
part: number,
|
part: number,
|
||||||
|
parts: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const chatwoot: Omit<ChatwootMessage, 'id'> = {
|
const chatwoot: Omit<ChatwootMessage, 'id'> = {
|
||||||
timestamp: new Date(chatwootMessage.created_at),
|
timestamp: new Date(chatwootMessage.created_at),
|
||||||
conversation_id: chatwootMessage.conversation.id,
|
conversation_id: chatwootMessage.conversation.id,
|
||||||
message_id: chatwootMessage.id,
|
message_id: chatwootMessage.id,
|
||||||
|
parts: parts,
|
||||||
};
|
};
|
||||||
const whatsapp = EngineHelper.WhatsAppMessageKeys(whatsappMessage);
|
const whatsapp = EngineHelper.WhatsAppMessageKeys(whatsappMessage);
|
||||||
await this.mappingService.map(chatwoot, whatsapp, part);
|
await this.mappingService.map(chatwoot, whatsapp, part);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export class ChatWootInboxMessageUpdatedConsumer extends ChatWootInboxMessageCon
|
|||||||
session,
|
session,
|
||||||
container.ChatWootConfig(),
|
container.ChatWootConfig(),
|
||||||
container.Locale(),
|
container.Locale(),
|
||||||
|
container.MessageStatusService(),
|
||||||
);
|
);
|
||||||
return await handler.handle(body);
|
return await handler.handle(body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,5 +44,7 @@ export class MessageCleanupConsumer extends ChatWootScheduledConsumer {
|
|||||||
.MessageMappingService()
|
.MessageMappingService()
|
||||||
.cleanup(removeAfter);
|
.cleanup(removeAfter);
|
||||||
logger.info(`Removed ${removed} mappings for messages`);
|
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 {
|
import {
|
||||||
AvatarUpdateMode,
|
AvatarUpdateMode,
|
||||||
ContactService,
|
ContactService,
|
||||||
|
IsRawName,
|
||||||
|
NameUpdateMode,
|
||||||
} from '@waha/apps/chatwoot/client/ContactService';
|
} from '@waha/apps/chatwoot/client/ContactService';
|
||||||
import { Conversation } from '@waha/apps/chatwoot/client/Conversation';
|
import { Conversation } from '@waha/apps/chatwoot/client/Conversation';
|
||||||
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
import { Locale } from '@waha/apps/chatwoot/i18n/locale';
|
||||||
@@ -34,6 +36,7 @@ export interface ContactsPullOptions {
|
|||||||
batch: number;
|
batch: number;
|
||||||
progress: number | null;
|
progress: number | null;
|
||||||
avatar: null | 'if-missing' | 'update';
|
avatar: null | 'if-missing' | 'update';
|
||||||
|
updateNames: NameUpdateMode;
|
||||||
attributes: boolean;
|
attributes: boolean;
|
||||||
contacts: {
|
contacts: {
|
||||||
groups: boolean;
|
groups: boolean;
|
||||||
@@ -88,6 +91,7 @@ export class TaskContactsPullConsumer extends ChatWootTaskConsumer {
|
|||||||
interface Progress {
|
interface Progress {
|
||||||
created: number;
|
created: number;
|
||||||
updated: number;
|
updated: number;
|
||||||
|
renamed: number;
|
||||||
skipped: number;
|
skipped: number;
|
||||||
errors: number;
|
errors: number;
|
||||||
avatar: {
|
avatar: {
|
||||||
@@ -104,6 +108,7 @@ function total(progress: Progress) {
|
|||||||
const NullProgress: Progress = {
|
const NullProgress: Progress = {
|
||||||
created: 0,
|
created: 0,
|
||||||
updated: 0,
|
updated: 0,
|
||||||
|
renamed: 0,
|
||||||
skipped: 0,
|
skipped: 0,
|
||||||
errors: 0,
|
errors: 0,
|
||||||
avatar: {
|
avatar: {
|
||||||
@@ -201,12 +206,13 @@ class ContactsPullHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(`Pulling ${chatId}...`);
|
this.logger.debug(`Pulling ${chatId}...`);
|
||||||
const result = await this.pullOneContact(options, chatId);
|
const result = await this.pullOneContact(options, contact);
|
||||||
progress.created += result.created;
|
progress.created += result.created;
|
||||||
progress.updated += result.updated;
|
progress.updated += result.updated;
|
||||||
|
progress.renamed += result.renamed;
|
||||||
progress.avatar.updated += result.avatar.updated;
|
progress.avatar.updated += result.avatar.updated;
|
||||||
this.logger.info(
|
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) {
|
} catch (e) {
|
||||||
this.logger.error(`Error pulling contact ${contact.id}: ${e}`);
|
this.logger.error(`Error pulling contact ${contact.id}: ${e}`);
|
||||||
@@ -220,11 +226,47 @@ class ContactsPullHandler {
|
|||||||
await this.activity.completed(progress);
|
await this.activity.completed(progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async pullOneContact(options: ContactsPullOptions, chatId: string) {
|
private async pullOneContact(options: ContactsPullOptions, contact: any) {
|
||||||
// Contact
|
const contactInfo = WhatsAppContactInfo(this.session, contact.id, this.l);
|
||||||
const contactInfo = WhatsAppContactInfo(this.session, chatId, this.l);
|
contactInfo.SetFetchedContact(contact);
|
||||||
let [cwContact, created] =
|
let [cwContact, created] =
|
||||||
await this.contactService.findOrCreateContact(contactInfo);
|
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
|
// Attributes
|
||||||
if (options.attributes) {
|
if (options.attributes) {
|
||||||
const attributes = await contactInfo.Attributes();
|
const attributes = await contactInfo.Attributes();
|
||||||
@@ -233,6 +275,7 @@ class ContactsPullHandler {
|
|||||||
attributes,
|
attributes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avatar
|
// Avatar
|
||||||
let avatarUpdated = false;
|
let avatarUpdated = false;
|
||||||
switch (options.avatar) {
|
switch (options.avatar) {
|
||||||
@@ -251,9 +294,11 @@ class ContactsPullHandler {
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
created: created ? 1 : 0,
|
created: created ? 1 : 0,
|
||||||
updated: created ? 0 : 1,
|
updated: created ? 0 : 1,
|
||||||
|
renamed: renamed ? 1 : 0,
|
||||||
avatar: {
|
avatar: {
|
||||||
updated: avatarUpdated ? 1 : 0,
|
updated: avatarUpdated ? 1 : 0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ class MessagesPullHandler {
|
|||||||
this.session,
|
this.session,
|
||||||
container.Locale(),
|
container.Locale(),
|
||||||
container.WAHASelf(),
|
container.WAHASelf(),
|
||||||
|
container.OutgoingMode(),
|
||||||
);
|
);
|
||||||
handler.force = options.force;
|
handler.force = options.force;
|
||||||
|
|
||||||
|
|||||||
@@ -36,10 +36,11 @@ import { EngineHelper } from '@waha/apps/chatwoot/waha';
|
|||||||
import { EnsureSeconds } from '@waha/utils/timehelper';
|
import { EnsureSeconds } from '@waha/utils/timehelper';
|
||||||
import { CHATWOOT_MESSAGE_CALENDAR_THRESHOLD_SECONDS } from '@waha/apps/chatwoot/env';
|
import { CHATWOOT_MESSAGE_CALENDAR_THRESHOLD_SECONDS } from '@waha/apps/chatwoot/env';
|
||||||
import {
|
import {
|
||||||
ChatWootAppConfig,
|
|
||||||
ChatWootConfig,
|
ChatWootConfig,
|
||||||
|
ChatWootOutgoingMode,
|
||||||
} from '@waha/apps/chatwoot/dto/config.dto';
|
} from '@waha/apps/chatwoot/dto/config.dto';
|
||||||
import { clearContent } from '@waha/apps/chatwoot/consumers/utils';
|
import { clearContent } from '@waha/apps/chatwoot/consumers/utils';
|
||||||
|
import { SetExternalEcho } from '@waha/apps/chatwoot/api/webhook.guards';
|
||||||
|
|
||||||
export function ListenEventsForChatWoot(config: ChatWootConfig) {
|
export function ListenEventsForChatWoot(config: ChatWootConfig) {
|
||||||
const events = [
|
const events = [
|
||||||
@@ -52,7 +53,8 @@ export function ListenEventsForChatWoot(config: ChatWootConfig) {
|
|||||||
WAHAEvents.CALL_ACCEPTED,
|
WAHAEvents.CALL_ACCEPTED,
|
||||||
WAHAEvents.CALL_REJECTED,
|
WAHAEvents.CALL_REJECTED,
|
||||||
];
|
];
|
||||||
if (config.conversations.markAsRead) {
|
const conversations = config.conversations;
|
||||||
|
if (conversations.markAsRead || conversations.syncMessageStatus) {
|
||||||
events.push(WAHAEvents.MESSAGE_ACK);
|
events.push(WAHAEvents.MESSAGE_ACK);
|
||||||
}
|
}
|
||||||
return events;
|
return events;
|
||||||
@@ -235,6 +237,7 @@ export abstract class MessageBaseHandler<
|
|||||||
protected session: WAHASessionAPI,
|
protected session: WAHASessionAPI,
|
||||||
protected l: Locale,
|
protected l: Locale,
|
||||||
protected waha: WAHASelf,
|
protected waha: WAHASelf,
|
||||||
|
protected outgoingMode: ChatWootOutgoingMode,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
protected abstract getMessage(
|
protected abstract getMessage(
|
||||||
@@ -402,10 +405,35 @@ export abstract class MessageBaseHandler<
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const private_ = message.private ?? payload.fromMe;
|
// Send the message as a private note or as a regular message?
|
||||||
const type = private_ ? MessageType.OUTGOING : MessageType.INCOMING;
|
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);
|
content = this.finalizeContent(content, payload);
|
||||||
return {
|
const body: conversation_message_create = {
|
||||||
content: content,
|
content: content,
|
||||||
message_type: type,
|
message_type: type,
|
||||||
private: private_,
|
private: private_,
|
||||||
@@ -414,6 +442,10 @@ export abstract class MessageBaseHandler<
|
|||||||
in_reply_to: replyTo,
|
in_reply_to: replyTo,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
if (payload.fromMe) {
|
||||||
|
SetExternalEcho(body);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getReplyToChatWootMessageID(
|
async getReplyToChatWootMessageID(
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export class WAHACallAcceptedConsumer extends ChatWootWAHABaseConsumer {
|
|||||||
new WAHASessionAPI(event.session, container.WAHASelf()),
|
new WAHASessionAPI(event.session, container.WAHASelf()),
|
||||||
locale,
|
locale,
|
||||||
container.WAHASelf(),
|
container.WAHASelf(),
|
||||||
|
container.OutgoingMode(),
|
||||||
);
|
);
|
||||||
const msg = BuildCallMessagePayload(
|
const msg = BuildCallMessagePayload(
|
||||||
event.payload,
|
event.payload,
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export class WAHACallReceivedConsumer extends ChatWootWAHABaseConsumer {
|
|||||||
new WAHASessionAPI(event.session, container.WAHASelf()),
|
new WAHASessionAPI(event.session, container.WAHASelf()),
|
||||||
locale,
|
locale,
|
||||||
container.WAHASelf(),
|
container.WAHASelf(),
|
||||||
|
container.OutgoingMode(),
|
||||||
);
|
);
|
||||||
const msg = BuildCallMessagePayload(
|
const msg = BuildCallMessagePayload(
|
||||||
event.payload,
|
event.payload,
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export class WAHACallRejectedConsumer extends ChatWootWAHABaseConsumer {
|
|||||||
new WAHASessionAPI(event.session, container.WAHASelf()),
|
new WAHASessionAPI(event.session, container.WAHASelf()),
|
||||||
locale,
|
locale,
|
||||||
container.WAHASelf(),
|
container.WAHASelf(),
|
||||||
|
container.OutgoingMode(),
|
||||||
);
|
);
|
||||||
const msg = BuildCallMessagePayload(
|
const msg = BuildCallMessagePayload(
|
||||||
event.payload,
|
event.payload,
|
||||||
|
|||||||
@@ -14,12 +14,23 @@ import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
|
|||||||
import { SessionManager } from '@waha/core/abc/manager.abc';
|
import { SessionManager } from '@waha/core/abc/manager.abc';
|
||||||
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
|
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
|
||||||
import { WAHAEvents } from '@waha/structures/enums.dto';
|
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 { Job } from 'bullmq';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
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 { 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 })
|
@Processor(QueueName.WAHA_MESSAGE_ACK, { concurrency: JOB_CONCURRENCY })
|
||||||
export class WAHAMessageAckConsumer extends ChatWootWAHABaseConsumer {
|
export class WAHAMessageAckConsumer extends ChatWootWAHABaseConsumer {
|
||||||
@@ -32,7 +43,7 @@ export class WAHAMessageAckConsumer extends ChatWootWAHABaseConsumer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ShouldProcess(event: any): boolean {
|
ShouldProcess(event: any): boolean {
|
||||||
return ShouldMarkAsReadInChatWoot(event);
|
return ShouldProcessAckInChatWoot(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
GetChatId(event: WAHAWebhookMessageAck): string {
|
GetChatId(event: WAHAWebhookMessageAck): string {
|
||||||
@@ -46,13 +57,20 @@ export class WAHAMessageAckConsumer extends ChatWootWAHABaseConsumer {
|
|||||||
const container = await this.DIContainer(job, job.data.app);
|
const container = await this.DIContainer(job, job.data.app);
|
||||||
const event = job.data.event as WAHAWebhookMessageAck;
|
const event = job.data.event as WAHAWebhookMessageAck;
|
||||||
const session = new WAHASessionAPI(event.session, container.WAHASelf());
|
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(
|
const handler = new MessageAckHandler(
|
||||||
|
config,
|
||||||
container.ContactConversationService(),
|
container.ContactConversationService(),
|
||||||
container.MessageMappingService(),
|
container.MessageMappingService(),
|
||||||
container.Logger(),
|
container.Logger(),
|
||||||
info,
|
info,
|
||||||
session,
|
session,
|
||||||
container.Locale(),
|
container.Locale(),
|
||||||
|
container.MessageStatusService(),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await handler.handle(event);
|
await handler.handle(event);
|
||||||
@@ -64,26 +82,72 @@ export class WAHAMessageAckConsumer extends ChatWootWAHABaseConsumer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MessageAckHandlerConfig {
|
||||||
|
markAsRead: boolean;
|
||||||
|
syncMessageStatus: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
class MessageAckHandler {
|
class MessageAckHandler {
|
||||||
constructor(
|
constructor(
|
||||||
|
private readonly config: MessageAckHandlerConfig,
|
||||||
private readonly contactConversationService: ContactConversationService,
|
private readonly contactConversationService: ContactConversationService,
|
||||||
protected mappingService: MessageMappingService,
|
protected mappingService: MessageMappingService,
|
||||||
private readonly logger: ILogger,
|
private readonly logger: ILogger,
|
||||||
private readonly info: IMessageInfo,
|
private readonly info: IMessageInfo,
|
||||||
private readonly session: WAHASessionAPI,
|
private readonly session: WAHASessionAPI,
|
||||||
private readonly locale: Locale,
|
private readonly locale: Locale,
|
||||||
|
private readonly statusService: MessageStatusService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handle(event: WAHAWebhookMessageAck): Promise<void> {
|
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 key = parseMessageIdSerialized(payload.id);
|
||||||
const chatwoot = await this.mappingService.getChatWootMessage({
|
return this.mappingService.getChatWootMessage({
|
||||||
chat_id: null,
|
chat_id: null,
|
||||||
message_id: key.id,
|
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) {
|
if (!chatwoot) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import type { WAHAEngine, WAHAEvents } from '@waha/structures/enums.dto';
|
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 {
|
interface TestParam {
|
||||||
name: string;
|
name: string;
|
||||||
expected: boolean;
|
ShouldMarkAsReadInChatWoot: boolean;
|
||||||
|
ShouldProcessAckInChatWoot: boolean;
|
||||||
GOWS: any;
|
GOWS: any;
|
||||||
NOWEB: any;
|
NOWEB: any;
|
||||||
WEBJS: any;
|
WEBJS: any;
|
||||||
@@ -14,10 +18,11 @@ interface TestParam {
|
|||||||
* Participant - 11111111111 (lid - 011111111111111)
|
* Participant - 11111111111 (lid - 011111111111111)
|
||||||
* Group - 222222222222222222
|
* Group - 222222222222222222
|
||||||
*/
|
*/
|
||||||
const ShouldMarkAsReadCases: TestParam[] = [
|
const TestCases: TestParam[] = [
|
||||||
{
|
{
|
||||||
name: 'Participant read My message (READ ack)',
|
name: 'Participant read My message (READ ack)',
|
||||||
expected: true,
|
ShouldMarkAsReadInChatWoot: true,
|
||||||
|
ShouldProcessAckInChatWoot: true,
|
||||||
GOWS: {
|
GOWS: {
|
||||||
id: 'evt_00000000000000000000000000',
|
id: 'evt_00000000000000000000000000',
|
||||||
session: 'default',
|
session: 'default',
|
||||||
@@ -131,7 +136,8 @@ const ShouldMarkAsReadCases: TestParam[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Participant received My message (DEVICE ack)',
|
name: 'Participant received My message (DEVICE ack)',
|
||||||
expected: false,
|
ShouldMarkAsReadInChatWoot: false,
|
||||||
|
ShouldProcessAckInChatWoot: true,
|
||||||
GOWS: {
|
GOWS: {
|
||||||
id: 'evt_00000000000000000000000000',
|
id: 'evt_00000000000000000000000000',
|
||||||
session: 'default',
|
session: 'default',
|
||||||
@@ -243,7 +249,8 @@ const ShouldMarkAsReadCases: TestParam[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Me read Participant message on another device (READ ack)',
|
name: 'Me read Participant message on another device (READ ack)',
|
||||||
expected: false,
|
ShouldMarkAsReadInChatWoot: false,
|
||||||
|
ShouldProcessAckInChatWoot: false,
|
||||||
GOWS: {
|
GOWS: {
|
||||||
id: 'evt_00000000000000000000000000',
|
id: 'evt_00000000000000000000000000',
|
||||||
session: 'default',
|
session: 'default',
|
||||||
@@ -315,7 +322,8 @@ const ShouldMarkAsReadCases: TestParam[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Participant read My Group Message (READ ack)',
|
name: 'Participant read My Group Message (READ ack)',
|
||||||
expected: false,
|
ShouldMarkAsReadInChatWoot: false,
|
||||||
|
ShouldProcessAckInChatWoot: false,
|
||||||
GOWS: {
|
GOWS: {
|
||||||
id: 'evt_00000000000000000000000000',
|
id: 'evt_00000000000000000000000000',
|
||||||
session: 'default',
|
session: 'default',
|
||||||
@@ -446,15 +454,16 @@ const ShouldMarkAsReadCases: TestParam[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const engines = ['GOWS', 'NOWEB', 'WEBJS'];
|
||||||
|
|
||||||
describe('ShouldMarkAsReadInChatWoot', () => {
|
describe('ShouldMarkAsReadInChatWoot', () => {
|
||||||
const engines = ['GOWS', 'NOWEB', 'WEBJS'];
|
|
||||||
for (const engine of engines) {
|
for (const engine of engines) {
|
||||||
describe(engine, () => {
|
describe(engine, () => {
|
||||||
for (const param of ShouldMarkAsReadCases) {
|
for (const param of TestCases) {
|
||||||
const name = `[${param.expected}] ${param.name}`;
|
const name = `[${param.ShouldMarkAsReadInChatWoot}] ${param.name}`;
|
||||||
const event = param[engine];
|
const event = param[engine];
|
||||||
const testfn = event ? test : test.skip;
|
const testfn = event ? test : test.skip;
|
||||||
const expected = param.expected;
|
const expected = param.ShouldMarkAsReadInChatWoot;
|
||||||
testfn(name, () => {
|
testfn(name, () => {
|
||||||
// Test
|
// Test
|
||||||
const result = ShouldMarkAsReadInChatWoot(event);
|
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 type { WAHAWebhookMessageAck } from '@waha/structures/webhooks.dto';
|
||||||
import { isJidCusFormat } from '@waha/utils/wa';
|
import { isJidCusFormat } from '@waha/utils/wa';
|
||||||
import { WAMessageAck } from '@waha/structures/enums.dto';
|
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';
|
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,
|
event: WAHAWebhookMessageAck,
|
||||||
): boolean {
|
): boolean {
|
||||||
// Mark as seen only if it's DM
|
|
||||||
// Ignore groups and other multiple participants chats
|
// Ignore groups and other multiple participants chats
|
||||||
const chatId = EngineHelper.ChatID(event.payload);
|
const chatId = EngineHelper.ChatID(event.payload);
|
||||||
if (!isJidCusFormat(chatId) && !isLidUser(chatId)) {
|
if (!isJidCusFormat(chatId) && !isLidUser(chatId)) {
|
||||||
@@ -15,18 +23,27 @@ export function ShouldMarkAsReadInChatWoot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = event.payload;
|
const payload = event.payload;
|
||||||
// Only READ and PLAYED
|
if (!ACKS_TO_PROCESS.has(payload.ack)) {
|
||||||
const read = payload.ack === WAMessageAck.READ;
|
|
||||||
const played = payload.ack == WAMessageAck.PLAYED;
|
|
||||||
if (!read && !played) {
|
|
||||||
return false;
|
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) {
|
if (!payload.fromMe) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark ChatWoot conversation as read when recipient reads our message
|
|
||||||
return true;
|
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,
|
session,
|
||||||
container.Locale(),
|
container.Locale(),
|
||||||
container.WAHASelf(),
|
container.WAHASelf(),
|
||||||
|
container.OutgoingMode(),
|
||||||
);
|
);
|
||||||
return await handler.handle(event.payload);
|
return await handler.handle(event.payload);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export class WAHAMessageEditedConsumer extends ChatWootWAHABaseConsumer {
|
|||||||
session,
|
session,
|
||||||
container.Locale(),
|
container.Locale(),
|
||||||
container.WAHASelf(),
|
container.WAHASelf(),
|
||||||
|
container.OutgoingMode(),
|
||||||
);
|
);
|
||||||
return await handler.handle(event.payload);
|
return await handler.handle(event.payload);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export class WAHAMessageReactionConsumer extends ChatWootWAHABaseConsumer {
|
|||||||
session,
|
session,
|
||||||
container.Locale(),
|
container.Locale(),
|
||||||
container.WAHASelf(),
|
container.WAHASelf(),
|
||||||
|
container.OutgoingMode(),
|
||||||
);
|
);
|
||||||
return await handler.handle(event.payload);
|
return await handler.handle(event.payload);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,30 @@ export class InboxContactInfo implements ContactInfo {
|
|||||||
return INBOX_CONTACT_CHAT_ID;
|
return INBOX_CONTACT_CHAT_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SetFetchedContact(): void {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async LidId(): Promise<string | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async JidId(): Promise<string | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async PhoneNumberE164(): Promise<string | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async SavedName(): Promise<string | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async PushName(): Promise<string | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async AvatarUrl() {
|
async AvatarUrl() {
|
||||||
return this.l.key(TKey.APP_INBOX_CONTACT_AVATAR_URL).render();
|
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
|
* Base WhatsApp contact info class
|
||||||
*/
|
*/
|
||||||
abstract class ChatContactInfo implements ContactInfo {
|
abstract class ChatContactInfo implements ContactInfo {
|
||||||
|
// Already fetched WhatsApp contact, saves a request when known
|
||||||
|
protected contact: any = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected session: WAHASessionAPI,
|
protected session: WAHASessionAPI,
|
||||||
protected chatId: string,
|
protected chatId: string,
|
||||||
@@ -32,6 +35,30 @@ abstract class ChatContactInfo implements ContactInfo {
|
|||||||
return this.chatId;
|
return this.chatId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SetFetchedContact(contact: any) {
|
||||||
|
this.contact = contact;
|
||||||
|
}
|
||||||
|
|
||||||
|
async LidId(): Promise<string | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async JidId(): Promise<string | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async PhoneNumberE164(): Promise<string | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async SavedName(): Promise<string | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async PushName(): Promise<string | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
abstract AvatarUrl(): Promise<string | null>;
|
abstract AvatarUrl(): Promise<string | null>;
|
||||||
|
|
||||||
abstract Attributes(): Promise<any>;
|
abstract Attributes(): Promise<any>;
|
||||||
@@ -53,6 +80,19 @@ class JidContactInfo extends ChatContactInfo {
|
|||||||
return await this.session.findLIDByPN(this.chatId);
|
return await this.session.findLIDByPN(this.chatId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async LidId(): Promise<string | null> {
|
||||||
|
const lid = await this.fetchLid().catch(() => null);
|
||||||
|
return lid || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async JidId(): Promise<string | null> {
|
||||||
|
return this.chatId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async PhoneNumberE164(): Promise<string | null> {
|
||||||
|
return E164Parser.fromJid(this.chatId);
|
||||||
|
}
|
||||||
|
|
||||||
@CacheAsync()
|
@CacheAsync()
|
||||||
async Attributes() {
|
async Attributes() {
|
||||||
const attributes = {
|
const attributes = {
|
||||||
@@ -66,8 +106,26 @@ class JidContactInfo extends ChatContactInfo {
|
|||||||
return attributes;
|
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> {
|
async PublicContactCreate(): Promise<Contact> {
|
||||||
const contact: any = await this.session.getContact(this.chatId);
|
const contact: any = await this.fetchContact();
|
||||||
const name =
|
const name =
|
||||||
contact?.name || contact?.pushName || contact?.pushname || this.chatId;
|
contact?.name || contact?.pushName || contact?.pushname || this.chatId;
|
||||||
const phoneNumberE164 = E164Parser.fromJid(this.chatId);
|
const phoneNumberE164 = E164Parser.fromJid(this.chatId);
|
||||||
@@ -107,7 +165,52 @@ class LidContactInfo extends ChatContactInfo {
|
|||||||
if (!pn) {
|
if (!pn) {
|
||||||
return null;
|
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> {
|
||||||
|
return this.chatId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async JidId(): Promise<string | null> {
|
||||||
|
const jid = await this.jid();
|
||||||
|
return jid?.ChatId() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async PhoneNumberE164(): Promise<string | null> {
|
||||||
|
const jid = await this.jid();
|
||||||
|
if (!jid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
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> {
|
async AvatarUrl(): Promise<string | null> {
|
||||||
@@ -135,10 +238,13 @@ class LidContactInfo extends ChatContactInfo {
|
|||||||
if (jid) {
|
if (jid) {
|
||||||
result = await jid.PublicContactCreate();
|
result = await jid.PublicContactCreate();
|
||||||
} else {
|
} else {
|
||||||
|
const contact: any = await this.fetchContact();
|
||||||
|
const name =
|
||||||
|
contact?.name || contact?.pushName || contact?.pushname || this.chatId;
|
||||||
result = {
|
result = {
|
||||||
inbox_id: 0,
|
inbox_id: 0,
|
||||||
identifier: this.chatId,
|
identifier: this.chatId,
|
||||||
name: this.chatId,
|
name: name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
result.custom_attributes = await this.Attributes();
|
result.custom_attributes = await this.Attributes();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ChatWootInboxAPI } from '@waha/apps/chatwoot/client/interfaces';
|
|||||||
import {
|
import {
|
||||||
ChatWootAppConfig,
|
ChatWootAppConfig,
|
||||||
ChatWootConfig,
|
ChatWootConfig,
|
||||||
|
ChatWootOutgoingMode,
|
||||||
DEFAULT_LOCALE,
|
DEFAULT_LOCALE,
|
||||||
LinkPreview,
|
LinkPreview,
|
||||||
} from '@waha/apps/chatwoot/dto/config.dto';
|
} from '@waha/apps/chatwoot/dto/config.dto';
|
||||||
@@ -20,8 +21,10 @@ import {
|
|||||||
ChatwootMessageRepository,
|
ChatwootMessageRepository,
|
||||||
MessageMappingRepository,
|
MessageMappingRepository,
|
||||||
MessageMappingService,
|
MessageMappingService,
|
||||||
|
WhatsAppAckRepository,
|
||||||
WhatsAppMessageRepository,
|
WhatsAppMessageRepository,
|
||||||
} from '@waha/apps/chatwoot/storage';
|
} from '@waha/apps/chatwoot/storage';
|
||||||
|
import { MessageStatusService } from '@waha/apps/chatwoot/services/MessageStatusService';
|
||||||
import { Job } from 'bullmq';
|
import { Job } from 'bullmq';
|
||||||
import { Knex } from 'knex';
|
import { Knex } from 'knex';
|
||||||
import { i18n } from '@waha/apps/chatwoot/i18n';
|
import { i18n } from '@waha/apps/chatwoot/i18n';
|
||||||
@@ -119,6 +122,10 @@ export class DIContainer {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public OutgoingMode(): ChatWootOutgoingMode {
|
||||||
|
return this.ChatWootConfig().conversations.outgoing;
|
||||||
|
}
|
||||||
|
|
||||||
@CacheSync()
|
@CacheSync()
|
||||||
public ConversationSelector() {
|
public ConversationSelector() {
|
||||||
const config = this.ChatWootConfig();
|
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 {
|
public ChatWootErrorReporter(job: Job): ChatWootErrorReporter {
|
||||||
return new ChatWootErrorReporter(this.Logger(), job, this.Locale());
|
return new ChatWootErrorReporter(this.Logger(), job, this.Locale());
|
||||||
}
|
}
|
||||||
@@ -229,6 +250,8 @@ export function ChatWootConfigDefaults(
|
|||||||
sort: ConversationSort.created_newest,
|
sort: ConversationSort.created_newest,
|
||||||
status: null,
|
status: null,
|
||||||
markAsRead: true,
|
markAsRead: true,
|
||||||
|
syncMessageStatus: false,
|
||||||
|
outgoing: ChatWootOutgoingMode.PRIVATE_NOTE,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return lodash.defaultsDeep({}, config, defaults);
|
return lodash.defaultsDeep({}, config, defaults);
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { App } from '@waha/apps/app_sdk/dto/app.dto';
|
||||||
|
import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class ChatWootAppDto extends App<ChatWootAppConfig> {
|
||||||
|
@Type(() => ChatWootAppConfig)
|
||||||
|
config: ChatWootAppConfig;
|
||||||
|
}
|
||||||
@@ -30,6 +30,11 @@ export enum LinkPreview {
|
|||||||
HQ = 'HG',
|
HQ = 'HG',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ChatWootOutgoingMode {
|
||||||
|
PRIVATE_NOTE = 'private-note',
|
||||||
|
MESSAGE = 'message',
|
||||||
|
}
|
||||||
|
|
||||||
export class ChatWootConversationsConfig {
|
export class ChatWootConversationsConfig {
|
||||||
@IsEnum(ConversationSort)
|
@IsEnum(ConversationSort)
|
||||||
sort: ConversationSort;
|
sort: ConversationSort;
|
||||||
@@ -46,6 +51,26 @@ export class ChatWootConversationsConfig {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
markAsRead?: boolean = true;
|
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 {
|
export interface ChatWootConfig {
|
||||||
|
|||||||
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
|
|||||||
cli.cmd.contacts.pull.option.avatar: |-
|
cli.cmd.contacts.pull.option.avatar: |-
|
||||||
اختر كيفية سحب الصور الرمزية: `skip` أو `if-missing` أو `update`.
|
اختر كيفية سحب الصور الرمزية: `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: |-
|
cli.cmd.contacts.pull.option.groups: |-
|
||||||
قم بتضمين جهات اتصال مجموعات واتساب في المزامنة.
|
قم بتضمين جهات اتصال مجموعات واتساب في المزامنة.
|
||||||
|
|
||||||
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
|
|||||||
**الخيارات العامة:**
|
**الخيارات العامة:**
|
||||||
|
|
||||||
task.contacts.status: |-
|
task.contacts.status: |-
|
||||||
👤 سحب جهات الاتصال: **{{state}}**
|
👤 جهات الاتصال: **{{state}}**
|
||||||
- ✅ الناجحة: {{{progress.ok}}} جهة اتصال
|
- 🆕 تم الإنشاء: {{{progress.created}}} جهة اتصال
|
||||||
- ⚪ تم تخطيها: {{{progress.skipped}}} جهة اتصال
|
- 🔄 تم التحديث: {{{progress.updated}}} جهة اتصال
|
||||||
- ⚠️ الأخطاء: {{{progress.errors}}} جهة اتصال
|
- 🏷️ تمت إعادة التسمية: {{{progress.renamed}}} جهة اتصال
|
||||||
|
- ⚪ تم التخطي: {{{progress.skipped}}} جهة اتصال
|
||||||
|
- 🔴 أخطاء: {{{progress.errors}}} جهة اتصال
|
||||||
|
- 🖼️ تم تحديث الصورة الرمزية: {{{progress.avatar.updated}}} جهة اتصال
|
||||||
|
|
||||||
{{#job}}
|
|
||||||
⌛ الجدول الزمني:
|
|
||||||
- في قائمة الانتظار: {{{timestamp}}}
|
|
||||||
{{#processedOn}}
|
|
||||||
- وقت البدء: {{{processedOn}}}
|
|
||||||
{{/processedOn}}
|
|
||||||
{{#finishedOn}}
|
|
||||||
- وقت الانتهاء: {{{finishedOn}}}
|
|
||||||
{{/finishedOn}}
|
|
||||||
{{/job}}
|
|
||||||
|
|
||||||
{{#error}}
|
|
||||||
التفاصيل:
|
التفاصيل:
|
||||||
[{{{details.text}}}]({{{details.url}}})
|
[{{{details.text}}}]({{{details.url}}})
|
||||||
{{/error}}
|
|
||||||
|
|
||||||
task.contacts.started: |-
|
task.contacts.started: |-
|
||||||
جارٍ سحب جهات الاتصال...
|
جارٍ سحب جهات الاتصال...
|
||||||
|
|
||||||
task.contacts.progress: |-
|
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: |-
|
app.help.reminder: |-
|
||||||
أرسل **help** للحصول على قائمة الأوامر المتاحة.
|
أرسل **help** للحصول على قائمة الأوامر المتاحة.
|
||||||
@@ -638,10 +631,8 @@ cli.cmd.contacts.pull.option.progress:
|
|||||||
الإبلاغ عن التقدم كل عدد N من جهات الاتصال. القيمة 0 لتعطيل التقارير.
|
الإبلاغ عن التقدم كل عدد N من جهات الاتصال. القيمة 0 لتعطيل التقارير.
|
||||||
cli.cmd.contacts.pull.option.lids:
|
cli.cmd.contacts.pull.option.lids:
|
||||||
تضمين جهات اتصال واتساب LID (مجهولة) في المزامنة.
|
تضمين جهات اتصال واتساب LID (مجهولة) في المزامنة.
|
||||||
task.contacts.completed:
|
task.contacts.completed: |-
|
||||||
👤✅ تم إنهاء جهات الاتصال → 🆕 {{progress.created}}، 🔄 {{progress.updated}}،
|
👤✅ تم إنهاء جهات الاتصال → 🆕 {{progress.created}}، 🔄 {{progress.updated}}، 🏷️ {{progress.renamed}}، ⚪ {{progress.skipped}}، 🔴 {{progress.errors}}، 🖼️ {{progress.avatar.updated}}
|
||||||
⚪ {{progress.skipped}}، 🔴 {{progress.errors}}، 🖼️
|
|
||||||
{{progress.avatar.updated}}
|
|
||||||
task.messages.started: ⏳ جارٍ سحب الرسائل 💬 {{{chat}}} ⏱️ {{{period}}}...
|
task.messages.started: ⏳ جارٍ سحب الرسائل 💬 {{{chat}}} ⏱️ {{{period}}}...
|
||||||
task.messages.details:
|
task.messages.details:
|
||||||
أرسل "{{{prefix}}}messages status" للاطلاع على المزيد من التفاصيل.
|
أرسل "{{{prefix}}}messages status" للاطلاع على المزيد من التفاصيل.
|
||||||
|
|||||||
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
|
|||||||
cli.cmd.contacts.pull.option.avatar: |-
|
cli.cmd.contacts.pull.option.avatar: |-
|
||||||
কনট্যাক্টের অ্যাভাটার কীভাবে টানবেন নির্ধারণ করুন: `skip`, `if-missing`, অথবা `update`।
|
কনট্যাক্টের অ্যাভাটার কীভাবে টানবেন নির্ধারণ করুন: `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: |-
|
cli.cmd.contacts.pull.option.groups: |-
|
||||||
সিঙ্কে WhatsApp গ্রুপ কনট্যাক্ট অন্তর্ভুক্ত করুন।
|
সিঙ্কে WhatsApp গ্রুপ কনট্যাক্ট অন্তর্ভুক্ত করুন।
|
||||||
|
|
||||||
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
|
|||||||
**গ্লোবাল বিকল্পসমূহ:**
|
**গ্লোবাল বিকল্পসমূহ:**
|
||||||
|
|
||||||
task.contacts.status: |-
|
task.contacts.status: |-
|
||||||
👤 কনট্যাক্ট টানা: **{{state}}**
|
👤 কন্টাক্ট: **{{state}}**
|
||||||
- ✅ সফল: {{{progress.ok}}} কনট্যাক্ট
|
- 🆕 তৈরি: {{{progress.created}}} কন্টাক্ট
|
||||||
- ⚪ এড়িয়ে গেছে: {{{progress.skipped}}} কনট্যাক্ট
|
- 🔄 আপডেট: {{{progress.updated}}} কন্টাক্ট
|
||||||
- ⚠️ ত্রুটি: {{{progress.errors}}} কনট্যাক্ট
|
- 🏷️ নাম পরিবর্তিত: {{{progress.renamed}}} কন্টাক্ট
|
||||||
|
- ⚪ বাদ: {{{progress.skipped}}} কন্টাক্ট
|
||||||
|
- 🔴 ত্রুটি: {{{progress.errors}}} কন্টাক্ট
|
||||||
|
- 🖼️ অ্যাভাটার আপডেট: {{{progress.avatar.updated}}} কন্টাক্ট
|
||||||
|
|
||||||
{{#job}}
|
|
||||||
⌛ সময়রেখা:
|
|
||||||
- কিউ হয়েছে: {{{timestamp}}}
|
|
||||||
{{#processedOn}}
|
|
||||||
- শুরু হয়েছে: {{{processedOn}}}
|
|
||||||
{{/processedOn}}
|
|
||||||
{{#finishedOn}}
|
|
||||||
- শেষ হয়েছে: {{{finishedOn}}}
|
|
||||||
{{/finishedOn}}
|
|
||||||
{{/job}}
|
|
||||||
|
|
||||||
{{#error}}
|
|
||||||
বিস্তারিত:
|
বিস্তারিত:
|
||||||
[{{{details.text}}}]({{{details.url}}})
|
[{{{details.text}}}]({{{details.url}}})
|
||||||
{{/error}}
|
|
||||||
|
|
||||||
task.contacts.started: |-
|
task.contacts.started: |-
|
||||||
কনট্যাক্ট টানা হচ্ছে...
|
কনট্যাক্ট টানা হচ্ছে...
|
||||||
|
|
||||||
task.contacts.progress: |-
|
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: |-
|
app.help.reminder: |-
|
||||||
উপলব্ধ কমান্ডগুলির তালিকা পেতে **help** পাঠান।
|
উপলব্ধ কমান্ডগুলির তালিকা পেতে **help** পাঠান।
|
||||||
@@ -643,9 +636,8 @@ cli.cmd.contacts.pull.option.progress:
|
|||||||
প্রতি N কন্টাক্টে অগ্রগতি জানান। রিপোর্ট বন্ধ করতে 0 সেট করুন।
|
প্রতি N কন্টাক্টে অগ্রগতি জানান। রিপোর্ট বন্ধ করতে 0 সেট করুন।
|
||||||
cli.cmd.contacts.pull.option.lids:
|
cli.cmd.contacts.pull.option.lids:
|
||||||
সিঙ্কে WhatsApp LID (অনামা) কন্টাক্ট অন্তর্ভুক্ত করুন।
|
সিঙ্কে WhatsApp LID (অনামা) কন্টাক্ট অন্তর্ভুক্ত করুন।
|
||||||
task.contacts.completed:
|
task.contacts.completed: |-
|
||||||
👤✅ কন্টাক্ট সম্পন্ন → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪
|
👤✅ কন্টাক্ট সম্পন্ন → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
|
||||||
{{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
|
|
||||||
task.messages.started: ⏳ বার্তা টানা হচ্ছে 💬 {{{chat}}} ⏱️ {{{period}}}...
|
task.messages.started: ⏳ বার্তা টানা হচ্ছে 💬 {{{chat}}} ⏱️ {{{period}}}...
|
||||||
task.messages.details: আরও জানতে "{{{prefix}}}messages status" পাঠান।
|
task.messages.details: আরও জানতে "{{{prefix}}}messages status" পাঠান।
|
||||||
task.messages.progress:
|
task.messages.progress:
|
||||||
|
|||||||
@@ -197,6 +197,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
|
|||||||
cli.cmd.contacts.pull.option.avatar: |-
|
cli.cmd.contacts.pull.option.avatar: |-
|
||||||
Legen Sie fest, wie Avatare geladen werden: `skip`, `if-missing` oder `update`.
|
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: |-
|
cli.cmd.contacts.pull.option.groups: |-
|
||||||
WhatsApp-Gruppenkontakte in die Synchronisierung einbeziehen.
|
WhatsApp-Gruppenkontakte in die Synchronisierung einbeziehen.
|
||||||
|
|
||||||
@@ -248,32 +251,22 @@ cli.help.globalOptions.title: |-
|
|||||||
**Globale Optionen:**
|
**Globale Optionen:**
|
||||||
|
|
||||||
task.contacts.status: |-
|
task.contacts.status: |-
|
||||||
👤 Kontakte abrufen: **{{state}}**
|
👤 Kontakte: **{{state}}**
|
||||||
- ✅ Erfolgreich: {{{progress.ok}}} Kontakte
|
- 🆕 Erstellt: {{{progress.created}}} Kontakte
|
||||||
|
- 🔄 Aktualisiert: {{{progress.updated}}} Kontakte
|
||||||
|
- 🏷️ Umbenannt: {{{progress.renamed}}} Kontakte
|
||||||
- ⚪ Übersprungen: {{{progress.skipped}}} 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:
|
||||||
[{{{details.text}}}]({{{details.url}}})
|
[{{{details.text}}}]({{{details.url}}})
|
||||||
{{/error}}
|
|
||||||
|
|
||||||
task.contacts.started: |-
|
task.contacts.started: |-
|
||||||
Kontakte werden abgerufen...
|
Kontakte werden abgerufen...
|
||||||
|
|
||||||
task.contacts.progress: |-
|
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: |-
|
app.help.reminder: |-
|
||||||
Senden Sie **help**, um die Liste der verfügbaren Befehle zu erhalten.
|
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.
|
Meldet den Fortschritt nach N Kontakten. 0 deaktiviert die Meldungen.
|
||||||
cli.cmd.contacts.pull.option.lids:
|
cli.cmd.contacts.pull.option.lids:
|
||||||
Bezieht WhatsApp-LID-Kontakte (anonyme Kontakte) in die Synchronisierung ein.
|
Bezieht WhatsApp-LID-Kontakte (anonyme Kontakte) in die Synchronisierung ein.
|
||||||
task.contacts.completed:
|
task.contacts.completed: |-
|
||||||
👤✅ Kontakte abgeschlossen → 🆕 {{progress.created}}, 🔄
|
👤✅ Kontakte abgeschlossen → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
|
||||||
{{progress.updated}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️
|
|
||||||
{{progress.avatar.updated}}
|
|
||||||
task.messages.started:
|
task.messages.started:
|
||||||
⏳ Nachrichten werden abgerufen 💬 {{{chat}}} ⏱️ {{{period}}}...
|
⏳ Nachrichten werden abgerufen 💬 {{{chat}}} ⏱️ {{{period}}}...
|
||||||
task.messages.details:
|
task.messages.details:
|
||||||
|
|||||||
@@ -547,6 +547,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
|
|||||||
cli.cmd.contacts.pull.option.avatar: |-
|
cli.cmd.contacts.pull.option.avatar: |-
|
||||||
Choose how to pull avatars for contacts.
|
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: |-
|
cli.cmd.contacts.pull.option.groups: |-
|
||||||
Include WhatsApp group contacts in the sync.
|
Include WhatsApp group contacts in the sync.
|
||||||
|
|
||||||
@@ -604,15 +607,16 @@ task.contacts.started: |-
|
|||||||
👤⏳ Pulling contacts... Send "contacts status" for more details.
|
👤⏳ Pulling contacts... Send "contacts status" for more details.
|
||||||
|
|
||||||
task.contacts.progress: |-
|
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: |-
|
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: |-
|
task.contacts.status: |-
|
||||||
👤 Contacts: **{{state}}**
|
👤 Contacts: **{{state}}**
|
||||||
- 🆕 Created: {{{progress.created}}} contacts
|
- 🆕 Created: {{{progress.created}}} contacts
|
||||||
- 🔄 Updated: {{{progress.updated}}} contacts
|
- 🔄 Updated: {{{progress.updated}}} contacts
|
||||||
|
- 🏷️ Renamed: {{{progress.renamed}}} contacts
|
||||||
- ⚪ Skipped: {{{progress.skipped}}} contacts
|
- ⚪ Skipped: {{{progress.skipped}}} contacts
|
||||||
- 🔴 Errors: {{{progress.errors}}} contacts
|
- 🔴 Errors: {{{progress.errors}}} contacts
|
||||||
- 🖼️ Avatar Updated: {{{progress.avatar.updated}}} contacts
|
- 🖼️ Avatar Updated: {{{progress.avatar.updated}}} contacts
|
||||||
|
|||||||
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
|
|||||||
cli.cmd.contacts.pull.option.avatar: |-
|
cli.cmd.contacts.pull.option.avatar: |-
|
||||||
Elige cómo obtener los avatares de los contactos: `skip`, `if-missing` o `update`.
|
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: |-
|
cli.cmd.contacts.pull.option.groups: |-
|
||||||
Incluir contactos de grupos de WhatsApp en la sincronización.
|
Incluir contactos de grupos de WhatsApp en la sincronización.
|
||||||
|
|
||||||
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
|
|||||||
**Opciones globales:**
|
**Opciones globales:**
|
||||||
|
|
||||||
task.contacts.status: |-
|
task.contacts.status: |-
|
||||||
👤 Extracción de contactos: **{{state}}**
|
👤 Contactos: **{{state}}**
|
||||||
- ✅ Correctos: {{{progress.ok}}} contactos
|
- 🆕 Creados: {{{progress.created}}} contactos
|
||||||
|
- 🔄 Actualizados: {{{progress.updated}}} contactos
|
||||||
|
- 🏷️ Renombrados: {{{progress.renamed}}} contactos
|
||||||
- ⚪ Omitidos: {{{progress.skipped}}} 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:
|
Detalles:
|
||||||
[{{{details.text}}}]({{{details.url}}})
|
[{{{details.text}}}]({{{details.url}}})
|
||||||
{{/error}}
|
|
||||||
|
|
||||||
task.contacts.started: |-
|
task.contacts.started: |-
|
||||||
Extrayendo contactos...
|
Extrayendo contactos...
|
||||||
|
|
||||||
task.contacts.progress: |-
|
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: |-
|
app.help.reminder: |-
|
||||||
Envía **help** para obtener la lista de comandos disponibles.
|
Envía **help** para obtener la lista de comandos disponibles.
|
||||||
@@ -660,10 +653,8 @@ cli.cmd.contacts.pull.option.progress:
|
|||||||
informes de progreso.
|
informes de progreso.
|
||||||
cli.cmd.contacts.pull.option.lids:
|
cli.cmd.contacts.pull.option.lids:
|
||||||
Incluye contactos LID de WhatsApp (contactos anónimos) en la sincronización.
|
Incluye contactos LID de WhatsApp (contactos anónimos) en la sincronización.
|
||||||
task.contacts.completed:
|
task.contacts.completed: |-
|
||||||
👤✅ Contactos completados → 🆕 {{progress.created}}, 🔄 {{progress.updated}},
|
👤✅ Contactos completados → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
|
||||||
⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️
|
|
||||||
{{progress.avatar.updated}}
|
|
||||||
task.messages.started: ⏳ Extrayendo mensajes 💬 {{{chat}}} ⏱️ {{{period}}}...
|
task.messages.started: ⏳ Extrayendo mensajes 💬 {{{chat}}} ⏱️ {{{period}}}...
|
||||||
task.messages.details:
|
task.messages.details:
|
||||||
Envía "{{{prefix}}}messages status" para ver más detalles.
|
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: |-
|
cli.cmd.contacts.pull.option.avatar: |-
|
||||||
نحوه دریافت آواتار مخاطبان را انتخاب کنید: `skip`، `if-missing` یا `update`.
|
نحوه دریافت آواتار مخاطبان را انتخاب کنید: `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: |-
|
cli.cmd.contacts.pull.option.groups: |-
|
||||||
مخاطبان گروههای واتساپ را در همگامسازی قرار دهید.
|
مخاطبان گروههای واتساپ را در همگامسازی قرار دهید.
|
||||||
|
|
||||||
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
|
|||||||
**گزینههای عمومی:**
|
**گزینههای عمومی:**
|
||||||
|
|
||||||
task.contacts.status: |-
|
task.contacts.status: |-
|
||||||
👤 دریافت مخاطبان: **{{state}}**
|
👤 مخاطبان: **{{state}}**
|
||||||
- ✅ موفق: {{{progress.ok}}} مخاطب
|
- 🆕 ایجاد شده: {{{progress.created}}} مخاطب
|
||||||
- ⚪ نادیدهگرفتهشده: {{{progress.skipped}}} مخاطب
|
- 🔄 بهروزرسانی شده: {{{progress.updated}}} مخاطب
|
||||||
- ⚠️ خطا: {{{progress.errors}}} مخاطب
|
- 🏷️ تغییر نام داده شده: {{{progress.renamed}}} مخاطب
|
||||||
|
- ⚪ رد شده: {{{progress.skipped}}} مخاطب
|
||||||
|
- 🔴 خطاها: {{{progress.errors}}} مخاطب
|
||||||
|
- 🖼️ آواتار بهروزرسانی شده: {{{progress.avatar.updated}}} مخاطب
|
||||||
|
|
||||||
{{#job}}
|
|
||||||
⌛ جدول زمانی:
|
|
||||||
- در صف: {{{timestamp}}}
|
|
||||||
{{#processedOn}}
|
|
||||||
- زمان شروع: {{{processedOn}}}
|
|
||||||
{{/processedOn}}
|
|
||||||
{{#finishedOn}}
|
|
||||||
- زمان پایان: {{{finishedOn}}}
|
|
||||||
{{/finishedOn}}
|
|
||||||
{{/job}}
|
|
||||||
|
|
||||||
{{#error}}
|
|
||||||
جزئیات:
|
جزئیات:
|
||||||
[{{{details.text}}}]({{{details.url}}})
|
[{{{details.text}}}]({{{details.url}}})
|
||||||
{{/error}}
|
|
||||||
|
|
||||||
task.contacts.started: |-
|
task.contacts.started: |-
|
||||||
در حال دریافت مخاطبان...
|
در حال دریافت مخاطبان...
|
||||||
|
|
||||||
task.contacts.progress: |-
|
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: |-
|
app.help.reminder: |-
|
||||||
برای دریافت لیست دستورات موجود، **help** را ارسال کنید.
|
برای دریافت لیست دستورات موجود، **help** را ارسال کنید.
|
||||||
@@ -648,9 +641,8 @@ cli.cmd.contacts.pull.option.progress:
|
|||||||
پیشرفت را هر N مخاطب اعلام میکند. مقدار 0 گزارشها را غیرفعال میکند.
|
پیشرفت را هر N مخاطب اعلام میکند. مقدار 0 گزارشها را غیرفعال میکند.
|
||||||
cli.cmd.contacts.pull.option.lids:
|
cli.cmd.contacts.pull.option.lids:
|
||||||
مخاطبین ناشناس واتساپ (LID) را در همگامسازی وارد میکند.
|
مخاطبین ناشناس واتساپ (LID) را در همگامسازی وارد میکند.
|
||||||
task.contacts.completed:
|
task.contacts.completed: |-
|
||||||
👤✅ مخاطبین تکمیل شدند → 🆕 {{progress.created}}، 🔄 {{progress.updated}}، ⚪
|
👤✅ مخاطبین تکمیل شدند → 🆕 {{progress.created}}، 🔄 {{progress.updated}}، 🏷️ {{progress.renamed}}، ⚪ {{progress.skipped}}، 🔴 {{progress.errors}}، 🖼️ {{progress.avatar.updated}}
|
||||||
{{progress.skipped}}، 🔴 {{progress.errors}}، 🖼️ {{progress.avatar.updated}}
|
|
||||||
task.messages.started: ⏳ در حال دریافت پیامها 💬 {{{chat}}} ⏱️ {{{period}}}...
|
task.messages.started: ⏳ در حال دریافت پیامها 💬 {{{chat}}} ⏱️ {{{period}}}...
|
||||||
task.messages.details:
|
task.messages.details:
|
||||||
برای جزئیات بیشتر "{{{prefix}}}messages status" را ارسال کنید.
|
برای جزئیات بیشتر "{{{prefix}}}messages status" را ارسال کنید.
|
||||||
|
|||||||
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
|
|||||||
cli.cmd.contacts.pull.option.avatar: |-
|
cli.cmd.contacts.pull.option.avatar: |-
|
||||||
Choisissez comment charger les avatars des contacts : `skip`, `if-missing` ou `update`.
|
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: |-
|
cli.cmd.contacts.pull.option.groups: |-
|
||||||
Inclure les contacts de groupes WhatsApp dans la synchronisation.
|
Inclure les contacts de groupes WhatsApp dans la synchronisation.
|
||||||
|
|
||||||
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
|
|||||||
**Options globales :**
|
**Options globales :**
|
||||||
|
|
||||||
task.contacts.status: |-
|
task.contacts.status: |-
|
||||||
👤 Import de contacts : **{{state}}**
|
👤 Contacts : **{{state}}**
|
||||||
- ✅ Réussis : {{{progress.ok}}} contacts
|
- 🆕 Créés : {{{progress.created}}} contacts
|
||||||
|
- 🔄 Mis à jour : {{{progress.updated}}} contacts
|
||||||
|
- 🏷️ Renommés : {{{progress.renamed}}} contacts
|
||||||
- ⚪ Ignorés : {{{progress.skipped}}} 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 :
|
Détails :
|
||||||
[{{{details.text}}}]({{{details.url}}})
|
[{{{details.text}}}]({{{details.url}}})
|
||||||
{{/error}}
|
|
||||||
|
|
||||||
task.contacts.started: |-
|
task.contacts.started: |-
|
||||||
Importation des contacts...
|
Importation des contacts...
|
||||||
|
|
||||||
task.contacts.progress: |-
|
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: |-
|
app.help.reminder: |-
|
||||||
Envoyez **help** pour obtenir la liste des commandes disponibles.
|
Envoyez **help** pour obtenir la liste des commandes disponibles.
|
||||||
@@ -660,9 +653,8 @@ cli.cmd.contacts.pull.option.progress:
|
|||||||
rapports.
|
rapports.
|
||||||
cli.cmd.contacts.pull.option.lids:
|
cli.cmd.contacts.pull.option.lids:
|
||||||
Inclut les contacts LID WhatsApp (contacts anonymes) dans la synchronisation.
|
Inclut les contacts LID WhatsApp (contacts anonymes) dans la synchronisation.
|
||||||
task.contacts.completed:
|
task.contacts.completed: |-
|
||||||
👤✅ Contacts terminés → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪
|
👤✅ Contacts terminés → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
|
||||||
{{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
|
|
||||||
task.messages.started:
|
task.messages.started:
|
||||||
⏳ Récupération des messages 💬 {{{chat}}} ⏱️ {{{period}}}...
|
⏳ Récupération des messages 💬 {{{chat}}} ⏱️ {{{period}}}...
|
||||||
task.messages.details:
|
task.messages.details:
|
||||||
|
|||||||
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
|
|||||||
cli.cmd.contacts.pull.option.avatar: |-
|
cli.cmd.contacts.pull.option.avatar: |-
|
||||||
בחר כיצד למשוך תמונות פרופיל: `skip`, `if-missing` או `update`.
|
בחר כיצד למשוך תמונות פרופיל: `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: |-
|
cli.cmd.contacts.pull.option.groups: |-
|
||||||
כלול בסנכרון אנשי קשר של קבוצות WhatsApp.
|
כלול בסנכרון אנשי קשר של קבוצות WhatsApp.
|
||||||
|
|
||||||
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
|
|||||||
**אפשרויות גלובליות:**
|
**אפשרויות גלובליות:**
|
||||||
|
|
||||||
task.contacts.status: |-
|
task.contacts.status: |-
|
||||||
👤 משיכת אנשי קשר: **{{state}}**
|
👤 אנשי קשר: **{{state}}**
|
||||||
- ✅ הצלחות: {{{progress.ok}}} אנשי קשר
|
- 🆕 נוצרו: {{{progress.created}}} אנשי קשר
|
||||||
- ⚪ שדלגו עליהם: {{{progress.skipped}}} אנשי קשר
|
- 🔄 עודכנו: {{{progress.updated}}} אנשי קשר
|
||||||
- ⚠️ שגיאות: {{{progress.errors}}} אנשי קשר
|
- 🏷️ שונה שמם: {{{progress.renamed}}} אנשי קשר
|
||||||
|
- ⚪ דולגו: {{{progress.skipped}}} אנשי קשר
|
||||||
|
- 🔴 שגיאות: {{{progress.errors}}} אנשי קשר
|
||||||
|
- 🖼️ תמונת פרופיל עודכנה: {{{progress.avatar.updated}}} אנשי קשר
|
||||||
|
|
||||||
{{#job}}
|
|
||||||
⌛ ציר זמן:
|
|
||||||
- נכנסה לתור: {{{timestamp}}}
|
|
||||||
{{#processedOn}}
|
|
||||||
- התחלה: {{{processedOn}}}
|
|
||||||
{{/processedOn}}
|
|
||||||
{{#finishedOn}}
|
|
||||||
- סיום: {{{finishedOn}}}
|
|
||||||
{{/finishedOn}}
|
|
||||||
{{/job}}
|
|
||||||
|
|
||||||
{{#error}}
|
|
||||||
פרטים:
|
פרטים:
|
||||||
[{{{details.text}}}]({{{details.url}}})
|
[{{{details.text}}}]({{{details.url}}})
|
||||||
{{/error}}
|
|
||||||
|
|
||||||
task.contacts.started: |-
|
task.contacts.started: |-
|
||||||
אנשי קשר נמשכים...
|
אנשי קשר נמשכים...
|
||||||
|
|
||||||
task.contacts.progress: |-
|
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: |-
|
app.help.reminder: |-
|
||||||
שלח **help** כדי לקבל את רשימת הפקודות הזמינות.
|
שלח **help** כדי לקבל את רשימת הפקודות הזמינות.
|
||||||
@@ -633,9 +626,8 @@ cli.cmd.contacts.pull.option.progress:
|
|||||||
דיווח על התקדמות כל N אנשי קשר. ערך 0 מבטל את הדוחות.
|
דיווח על התקדמות כל N אנשי קשר. ערך 0 מבטל את הדוחות.
|
||||||
cli.cmd.contacts.pull.option.lids:
|
cli.cmd.contacts.pull.option.lids:
|
||||||
הכללת אנשי קשר אנונימיים של WhatsApp (LID) בסנכרון.
|
הכללת אנשי קשר אנונימיים של WhatsApp (LID) בסנכרון.
|
||||||
task.contacts.completed:
|
task.contacts.completed: |-
|
||||||
👤✅ השלמת אנשי קשר → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪
|
👤✅ השלמת אנשי קשר → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
|
||||||
{{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
|
|
||||||
task.messages.started: ⏳ מושכים הודעות 💬 {{{chat}}} ⏱️ {{{period}}}...
|
task.messages.started: ⏳ מושכים הודעות 💬 {{{chat}}} ⏱️ {{{period}}}...
|
||||||
task.messages.details: שלחו "{{{prefix}}}messages status" לעוד פרטים.
|
task.messages.details: שלחו "{{{prefix}}}messages status" לעוד פרטים.
|
||||||
task.messages.progress:
|
task.messages.progress:
|
||||||
|
|||||||
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
|
|||||||
cli.cmd.contacts.pull.option.avatar: |-
|
cli.cmd.contacts.pull.option.avatar: |-
|
||||||
संपर्क अवतार कैसे खींचें चुनें: `skip`, `if-missing`, या `update`।
|
संपर्क अवतार कैसे खींचें चुनें: `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: |-
|
cli.cmd.contacts.pull.option.groups: |-
|
||||||
सिंक में WhatsApp समूह संपर्क शामिल करें।
|
सिंक में WhatsApp समूह संपर्क शामिल करें।
|
||||||
|
|
||||||
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
|
|||||||
**ग्लोबल विकल्प:**
|
**ग्लोबल विकल्प:**
|
||||||
|
|
||||||
task.contacts.status: |-
|
task.contacts.status: |-
|
||||||
👤 संपर्क खींचना: **{{state}}**
|
👤 संपर्क: **{{state}}**
|
||||||
- ✅ सफल: {{{progress.ok}}} संपर्क
|
- 🆕 बनाए गए: {{{progress.created}}} संपर्क
|
||||||
|
- 🔄 अपडेट किए गए: {{{progress.updated}}} संपर्क
|
||||||
|
- 🏷️ नाम बदले गए: {{{progress.renamed}}} संपर्क
|
||||||
- ⚪ छोड़े गए: {{{progress.skipped}}} संपर्क
|
- ⚪ छोड़े गए: {{{progress.skipped}}} संपर्क
|
||||||
- ⚠️ त्रुटियाँ: {{{progress.errors}}} संपर्क
|
- 🔴 त्रुटियाँ: {{{progress.errors}}} संपर्क
|
||||||
|
- 🖼️ अवतार अपडेट: {{{progress.avatar.updated}}} संपर्क
|
||||||
|
|
||||||
{{#job}}
|
|
||||||
⌛ समयरेखा:
|
|
||||||
- कतार में: {{{timestamp}}}
|
|
||||||
{{#processedOn}}
|
|
||||||
- शुरू हुआ: {{{processedOn}}}
|
|
||||||
{{/processedOn}}
|
|
||||||
{{#finishedOn}}
|
|
||||||
- समाप्त: {{{finishedOn}}}
|
|
||||||
{{/finishedOn}}
|
|
||||||
{{/job}}
|
|
||||||
|
|
||||||
{{#error}}
|
|
||||||
विवरण:
|
विवरण:
|
||||||
[{{{details.text}}}]({{{details.url}}})
|
[{{{details.text}}}]({{{details.url}}})
|
||||||
{{/error}}
|
|
||||||
|
|
||||||
task.contacts.started: |-
|
task.contacts.started: |-
|
||||||
संपर्क खींचे जा रहे हैं...
|
संपर्क खींचे जा रहे हैं...
|
||||||
|
|
||||||
task.contacts.progress: |-
|
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: |-
|
app.help.reminder: |-
|
||||||
उपलब्ध कमांड की सूची प्राप्त करने के लिए **help** भेजें।
|
उपलब्ध कमांड की सूची प्राप्त करने के लिए **help** भेजें।
|
||||||
@@ -645,9 +638,8 @@ cli.cmd.contacts.pull.option.progress:
|
|||||||
हर N संपर्क पर प्रगति रिपोर्ट करें। रिपोर्ट बंद करने के लिए 0 सेट करें।
|
हर N संपर्क पर प्रगति रिपोर्ट करें। रिपोर्ट बंद करने के लिए 0 सेट करें।
|
||||||
cli.cmd.contacts.pull.option.lids:
|
cli.cmd.contacts.pull.option.lids:
|
||||||
सिंक में WhatsApp LID (अनाम) संपर्क शामिल करें।
|
सिंक में WhatsApp LID (अनाम) संपर्क शामिल करें।
|
||||||
task.contacts.completed:
|
task.contacts.completed: |-
|
||||||
👤✅ संपर्क पूरे → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪
|
👤✅ संपर्क पूरे → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
|
||||||
{{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
|
|
||||||
task.messages.started:
|
task.messages.started:
|
||||||
⏳ संदेश खींचे जा रहे हैं 💬 {{{chat}}} ⏱️ {{{period}}}...
|
⏳ संदेश खींचे जा रहे हैं 💬 {{{chat}}} ⏱️ {{{period}}}...
|
||||||
task.messages.details: और जानकारी के लिए "{{{prefix}}}messages status" भेजें।
|
task.messages.details: और जानकारी के लिए "{{{prefix}}}messages status" भेजें।
|
||||||
|
|||||||
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
|
|||||||
cli.cmd.contacts.pull.option.avatar: |-
|
cli.cmd.contacts.pull.option.avatar: |-
|
||||||
Pilih cara mengambil avatar kontak: `skip`, `if-missing`, atau `update`.
|
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: |-
|
cli.cmd.contacts.pull.option.groups: |-
|
||||||
Sertakan kontak grup WhatsApp dalam sinkronisasi.
|
Sertakan kontak grup WhatsApp dalam sinkronisasi.
|
||||||
|
|
||||||
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
|
|||||||
**Opsi global:**
|
**Opsi global:**
|
||||||
|
|
||||||
task.contacts.status: |-
|
task.contacts.status: |-
|
||||||
👤 Penarikan kontak: **{{state}}**
|
👤 Kontak: **{{state}}**
|
||||||
- ✅ Berhasil: {{{progress.ok}}} kontak
|
- 🆕 Dibuat: {{{progress.created}}} kontak
|
||||||
|
- 🔄 Diperbarui: {{{progress.updated}}} kontak
|
||||||
|
- 🏷️ Diganti nama: {{{progress.renamed}}} kontak
|
||||||
- ⚪ Dilewati: {{{progress.skipped}}} 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:
|
Detail:
|
||||||
[{{{details.text}}}]({{{details.url}}})
|
[{{{details.text}}}]({{{details.url}}})
|
||||||
{{/error}}
|
|
||||||
|
|
||||||
task.contacts.started: |-
|
task.contacts.started: |-
|
||||||
Menarik kontak...
|
Menarik kontak...
|
||||||
|
|
||||||
task.contacts.progress: |-
|
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: |-
|
app.help.reminder: |-
|
||||||
Kirim **help** untuk mendapatkan daftar perintah yang tersedia.
|
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.
|
Laporkan progres setiap N kontak. Setel ke 0 untuk menonaktifkan laporan.
|
||||||
cli.cmd.contacts.pull.option.lids:
|
cli.cmd.contacts.pull.option.lids:
|
||||||
Sertakan kontak WhatsApp LID (kontak anonim) dalam sinkronisasi.
|
Sertakan kontak WhatsApp LID (kontak anonim) dalam sinkronisasi.
|
||||||
task.contacts.completed:
|
task.contacts.completed: |-
|
||||||
👤✅ Kontak selesai → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪
|
👤✅ Kontak selesai → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
|
||||||
{{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
|
|
||||||
task.messages.started: ⏳ Menarik pesan 💬 {{{chat}}} ⏱️ {{{period}}}...
|
task.messages.started: ⏳ Menarik pesan 💬 {{{chat}}} ⏱️ {{{period}}}...
|
||||||
task.messages.details:
|
task.messages.details:
|
||||||
Kirim "{{{prefix}}}messages status" untuk melihat rincian.
|
Kirim "{{{prefix}}}messages status" untuk melihat rincian.
|
||||||
|
|||||||
@@ -547,6 +547,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
|
|||||||
cli.cmd.contacts.pull.option.avatar: |-
|
cli.cmd.contacts.pull.option.avatar: |-
|
||||||
Scegli come recuperare gli avatar dei contatti.
|
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: |-
|
cli.cmd.contacts.pull.option.groups: |-
|
||||||
Includi i contatti dei gruppi WhatsApp.
|
Includi i contatti dei gruppi WhatsApp.
|
||||||
|
|
||||||
@@ -604,15 +607,16 @@ task.contacts.started: |-
|
|||||||
👤⏳ Recupero contatti... Invia "contacts status" per maggiori dettagli.
|
👤⏳ Recupero contatti... Invia "contacts status" per maggiori dettagli.
|
||||||
|
|
||||||
task.contacts.progress: |-
|
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: |-
|
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: |-
|
task.contacts.status: |-
|
||||||
👤 Contatti: **{{state}}**
|
👤 Contatti: **{{state}}**
|
||||||
- 🆕 Creati: {{{progress.created}}} contatti
|
- 🆕 Creati: {{{progress.created}}} contatti
|
||||||
- 🔄 Aggiornati: {{{progress.updated}}} contatti
|
- 🔄 Aggiornati: {{{progress.updated}}} contatti
|
||||||
|
- 🏷️ Rinominati: {{{progress.renamed}}} contatti
|
||||||
- ⚪ Saltati: {{{progress.skipped}}} contatti
|
- ⚪ Saltati: {{{progress.skipped}}} contatti
|
||||||
- 🔴 Errori: {{{progress.errors}}} contatti
|
- 🔴 Errori: {{{progress.errors}}} contatti
|
||||||
- 🖼️ Avatar Aggiornati: {{{progress.avatar.updated}}} contatti
|
- 🖼️ Avatar Aggiornati: {{{progress.avatar.updated}}} contatti
|
||||||
|
|||||||
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
|
|||||||
cli.cmd.contacts.pull.option.avatar: |-
|
cli.cmd.contacts.pull.option.avatar: |-
|
||||||
ਚੁਣੋ ਕਿ ਅਵਤਾਰ ਕਿਵੇਂ ਖਿੱਚਣੇ ਹਨ: `skip`, `if-missing`, ਜਾਂ `update`।
|
ਚੁਣੋ ਕਿ ਅਵਤਾਰ ਕਿਵੇਂ ਖਿੱਚਣੇ ਹਨ: `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: |-
|
cli.cmd.contacts.pull.option.groups: |-
|
||||||
ਸਿੰਕ ਵਿੱਚ WhatsApp ਗਰੁੱਪ ਸੰਪਰਕ ਸ਼ਾਮਲ ਕਰੋ।
|
ਸਿੰਕ ਵਿੱਚ WhatsApp ਗਰੁੱਪ ਸੰਪਰਕ ਸ਼ਾਮਲ ਕਰੋ।
|
||||||
|
|
||||||
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
|
|||||||
**ਗਲੋਬਲ ਚੋਣਾਂ:**
|
**ਗਲੋਬਲ ਚੋਣਾਂ:**
|
||||||
|
|
||||||
task.contacts.status: |-
|
task.contacts.status: |-
|
||||||
👤 ਸੰਪਰਕ ਖਿੱਚਣਾ: **{{state}}**
|
👤 ਸੰਪਰਕ: **{{state}}**
|
||||||
- ✅ ਸਫਲ: {{{progress.ok}}} ਸੰਪਰਕ
|
- 🆕 ਬਣਾਏ: {{{progress.created}}} ਸੰਪਰਕ
|
||||||
- ⚪ ਛੱਡੇ ਗਏ: {{{progress.skipped}}} ਸੰਪਰਕ
|
- 🔄 ਅੱਪਡੇਟ ਕੀਤੇ: {{{progress.updated}}} ਸੰਪਰਕ
|
||||||
- ⚠️ ਗਲਤੀਆਂ: {{{progress.errors}}} ਸੰਪਰਕ
|
- 🏷️ ਨਾਮ ਬਦਲੇ: {{{progress.renamed}}} ਸੰਪਰਕ
|
||||||
|
- ⚪ ਛੱਡੇ: {{{progress.skipped}}} ਸੰਪਰਕ
|
||||||
|
- 🔴 ਗਲਤੀਆਂ: {{{progress.errors}}} ਸੰਪਰਕ
|
||||||
|
- 🖼️ ਅਵਤਾਰ ਅੱਪਡੇਟ: {{{progress.avatar.updated}}} ਸੰਪਰਕ
|
||||||
|
|
||||||
{{#job}}
|
|
||||||
⌛ ਸਮਾਂ-ਰੇਖਾ:
|
|
||||||
- ਕਤਾਰ ਵਿੱਚ: {{{timestamp}}}
|
|
||||||
{{#processedOn}}
|
|
||||||
- ਸ਼ੁਰੂ: {{{processedOn}}}
|
|
||||||
{{/processedOn}}
|
|
||||||
{{#finishedOn}}
|
|
||||||
- ਸਮਾਪਤ: {{{finishedOn}}}
|
|
||||||
{{/finishedOn}}
|
|
||||||
{{/job}}
|
|
||||||
|
|
||||||
{{#error}}
|
|
||||||
ਵੇਰਵੇ:
|
ਵੇਰਵੇ:
|
||||||
[{{{details.text}}}]({{{details.url}}})
|
[{{{details.text}}}]({{{details.url}}})
|
||||||
{{/error}}
|
|
||||||
|
|
||||||
task.contacts.started: |-
|
task.contacts.started: |-
|
||||||
ਸੰਪਰਕ ਖਿੱਚੇ ਜਾ ਰਹੇ ਹਨ...
|
ਸੰਪਰਕ ਖਿੱਚੇ ਜਾ ਰਹੇ ਹਨ...
|
||||||
|
|
||||||
task.contacts.progress: |-
|
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: |-
|
app.help.reminder: |-
|
||||||
ਉਪਲਬਧ ਕਮਾਂਡਾਂ ਦੀ ਸੂਚੀ ਪ੍ਰਾਪਤ ਕਰਨ ਲਈ **help** ਭੇਜੋ।
|
ਉਪਲਬਧ ਕਮਾਂਡਾਂ ਦੀ ਸੂਚੀ ਪ੍ਰਾਪਤ ਕਰਨ ਲਈ **help** ਭੇਜੋ।
|
||||||
@@ -644,9 +637,8 @@ cli.cmd.contacts.pull.option.progress:
|
|||||||
ਹਰ N ਸੰਪਰਕ ’ਤੇ ਪ੍ਰਗਤੀ ਦੱਸੋ। ਰਿਪੋਰਟਾਂ ਬੰਦ ਕਰਨ ਲਈ 0 ਸੈੱਟ ਕਰੋ।
|
ਹਰ N ਸੰਪਰਕ ’ਤੇ ਪ੍ਰਗਤੀ ਦੱਸੋ। ਰਿਪੋਰਟਾਂ ਬੰਦ ਕਰਨ ਲਈ 0 ਸੈੱਟ ਕਰੋ।
|
||||||
cli.cmd.contacts.pull.option.lids:
|
cli.cmd.contacts.pull.option.lids:
|
||||||
ਸਿੰਕ ਵਿੱਚ WhatsApp LID (ਅਣਪਛਾਤੇ) ਸੰਪਰਕ ਸ਼ਾਮਲ ਕਰੋ।
|
ਸਿੰਕ ਵਿੱਚ WhatsApp LID (ਅਣਪਛਾਤੇ) ਸੰਪਰਕ ਸ਼ਾਮਲ ਕਰੋ।
|
||||||
task.contacts.completed:
|
task.contacts.completed: |-
|
||||||
👤✅ ਸੰਪਰਕ ਮੁਕੰਮਲ → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪
|
👤✅ ਸੰਪਰਕ ਮੁਕੰਮਲ → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
|
||||||
{{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
|
|
||||||
task.messages.started:
|
task.messages.started:
|
||||||
⏳ ਸੁਨੇਹੇ ਖਿੱਚੇ ਜਾ ਰਹੇ ਹਨ 💬 {{{chat}}} ⏱️ {{{period}}}...
|
⏳ ਸੁਨੇਹੇ ਖਿੱਚੇ ਜਾ ਰਹੇ ਹਨ 💬 {{{chat}}} ⏱️ {{{period}}}...
|
||||||
task.messages.details: ਵਧੇਰੇ ਵੇਰਵਿਆਂ ਲਈ "{{{prefix}}}messages status" ਭੇਜੋ।
|
task.messages.details: ਵਧੇਰੇ ਵੇਰਵਿਆਂ ਲਈ "{{{prefix}}}messages status" ਭੇਜੋ।
|
||||||
|
|||||||
@@ -195,6 +195,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
|
|||||||
cli.cmd.contacts.pull.option.avatar: |-
|
cli.cmd.contacts.pull.option.avatar: |-
|
||||||
Escolha como buscar os avatares dos contatos: `skip`, `if-missing` ou `update`.
|
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: |-
|
cli.cmd.contacts.pull.option.groups: |-
|
||||||
Incluir contatos de grupo do WhatsApp na sincronização.
|
Incluir contatos de grupo do WhatsApp na sincronização.
|
||||||
|
|
||||||
@@ -246,32 +249,22 @@ cli.help.globalOptions.title: |-
|
|||||||
**Opções globais:**
|
**Opções globais:**
|
||||||
|
|
||||||
task.contacts.status: |-
|
task.contacts.status: |-
|
||||||
👤 Busca de contatos: **{{state}}**
|
👤 Contatos: **{{state}}**
|
||||||
- ✅ Sucesso: {{{progress.ok}}} contatos
|
- 🆕 Criados: {{{progress.created}}} contatos
|
||||||
|
- 🔄 Atualizados: {{{progress.updated}}} contatos
|
||||||
|
- 🏷️ Renomeados: {{{progress.renamed}}} contatos
|
||||||
- ⚪ Ignorados: {{{progress.skipped}}} 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:
|
Detalhes:
|
||||||
[{{{details.text}}}]({{{details.url}}})
|
[{{{details.text}}}]({{{details.url}}})
|
||||||
{{/error}}
|
|
||||||
|
|
||||||
task.contacts.started: |-
|
task.contacts.started: |-
|
||||||
Buscando contatos...
|
Buscando contatos...
|
||||||
|
|
||||||
task.contacts.progress: |-
|
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: |-
|
app.help.reminder: |-
|
||||||
Envie **help** para obter a lista de comandos disponíveis.
|
Envie **help** para obter a lista de comandos disponíveis.
|
||||||
@@ -655,10 +648,8 @@ cli.cmd.contacts.pull.option.progress:
|
|||||||
de progresso.
|
de progresso.
|
||||||
cli.cmd.contacts.pull.option.lids:
|
cli.cmd.contacts.pull.option.lids:
|
||||||
Inclui contatos LID do WhatsApp (contatos anônimos) na sincronização.
|
Inclui contatos LID do WhatsApp (contatos anônimos) na sincronização.
|
||||||
task.contacts.completed:
|
task.contacts.completed: |-
|
||||||
👤✅ Contatos concluídos → 🆕 {{progress.created}}, 🔄 {{progress.updated}},
|
👤✅ Contatos concluídos → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
|
||||||
⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️
|
|
||||||
{{progress.avatar.updated}}
|
|
||||||
task.messages.started: ⏳ Buscando mensagens 💬 {{{chat}}} ⏱️ {{{period}}}...
|
task.messages.started: ⏳ Buscando mensagens 💬 {{{chat}}} ⏱️ {{{period}}}...
|
||||||
task.messages.details:
|
task.messages.details:
|
||||||
Envie "{{{prefix}}}messages status" para ver mais detalhes.
|
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: |-
|
cli.cmd.contacts.pull.option.avatar: |-
|
||||||
Выберите, как загружать аватары контактов: `skip`, `if-missing` или `update`.
|
Выберите, как загружать аватары контактов: `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: |-
|
cli.cmd.contacts.pull.option.groups: |-
|
||||||
Включить контакты групп WhatsApp в синхронизацию.
|
Включить контакты групп WhatsApp в синхронизацию.
|
||||||
|
|
||||||
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
|
|||||||
**Глобальные опции:**
|
**Глобальные опции:**
|
||||||
|
|
||||||
task.contacts.status: |-
|
task.contacts.status: |-
|
||||||
👤 Загрузка контактов: **{{state}}**
|
👤 Контакты: **{{state}}**
|
||||||
- ✅ Успешно: {{{progress.ok}}} контактов
|
- 🆕 Создано: {{{progress.created}}} контактов
|
||||||
|
- 🔄 Обновлено: {{{progress.updated}}} контактов
|
||||||
|
- 🏷️ Переименовано: {{{progress.renamed}}} контактов
|
||||||
- ⚪ Пропущено: {{{progress.skipped}}} контактов
|
- ⚪ Пропущено: {{{progress.skipped}}} контактов
|
||||||
- ⚠️ Ошибки: {{{progress.errors}}} контактов
|
- 🔴 Ошибки: {{{progress.errors}}} контактов
|
||||||
|
- 🖼️ Аватар обновлён: {{{progress.avatar.updated}}} контактов
|
||||||
|
|
||||||
{{#job}}
|
|
||||||
⌛ Хронология:
|
|
||||||
- В очереди: {{{timestamp}}}
|
|
||||||
{{#processedOn}}
|
|
||||||
- Начато: {{{processedOn}}}
|
|
||||||
{{/processedOn}}
|
|
||||||
{{#finishedOn}}
|
|
||||||
- Завершено: {{{finishedOn}}}
|
|
||||||
{{/finishedOn}}
|
|
||||||
{{/job}}
|
|
||||||
|
|
||||||
{{#error}}
|
|
||||||
Подробности:
|
Подробности:
|
||||||
[{{{details.text}}}]({{{details.url}}})
|
[{{{details.text}}}]({{{details.url}}})
|
||||||
{{/error}}
|
|
||||||
|
|
||||||
task.contacts.started: |-
|
task.contacts.started: |-
|
||||||
Идёт загрузка контактов...
|
Идёт загрузка контактов...
|
||||||
|
|
||||||
task.contacts.progress: |-
|
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: |-
|
app.help.reminder: |-
|
||||||
Отправьте **help**, чтобы получить список доступных команд.
|
Отправьте **help**, чтобы получить список доступных команд.
|
||||||
@@ -647,9 +640,8 @@ cli.cmd.contacts.pull.option.progress:
|
|||||||
Сообщать о прогрессе каждые N контактов. Значение 0 отключает отчёты.
|
Сообщать о прогрессе каждые N контактов. Значение 0 отключает отчёты.
|
||||||
cli.cmd.contacts.pull.option.lids:
|
cli.cmd.contacts.pull.option.lids:
|
||||||
Включать контакты WhatsApp LID (анонимные контакты) в синхронизацию.
|
Включать контакты WhatsApp LID (анонимные контакты) в синхронизацию.
|
||||||
task.contacts.completed:
|
task.contacts.completed: |-
|
||||||
👤✅ Контакты завершены → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪
|
👤✅ Контакты завершены → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
|
||||||
{{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
|
|
||||||
task.messages.started: ⏳ Загружаем сообщения 💬 {{{chat}}} ⏱️ {{{period}}}...
|
task.messages.started: ⏳ Загружаем сообщения 💬 {{{chat}}} ⏱️ {{{period}}}...
|
||||||
task.messages.details:
|
task.messages.details:
|
||||||
Отправьте "{{{prefix}}}messages status", чтобы увидеть подробности.
|
Отправьте "{{{prefix}}}messages status", чтобы увидеть подробности.
|
||||||
|
|||||||
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
|
|||||||
cli.cmd.contacts.pull.option.avatar: |-
|
cli.cmd.contacts.pull.option.avatar: |-
|
||||||
Kişi avatarlarının nasıl çekileceğini seçin: `skip`, `if-missing` veya `update`.
|
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: |-
|
cli.cmd.contacts.pull.option.groups: |-
|
||||||
WhatsApp grup kişilerini senkronizasyona dahil et.
|
WhatsApp grup kişilerini senkronizasyona dahil et.
|
||||||
|
|
||||||
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
|
|||||||
**Genel seçenekler:**
|
**Genel seçenekler:**
|
||||||
|
|
||||||
task.contacts.status: |-
|
task.contacts.status: |-
|
||||||
👤 Kişi çekme: **{{state}}**
|
👤 Kişiler: **{{state}}**
|
||||||
- ✅ Başarılı: {{{progress.ok}}} kişi
|
- 🆕 Oluşturuldu: {{{progress.created}}} kişi
|
||||||
- ⚪ Atlanan: {{{progress.skipped}}} kişi
|
- 🔄 Güncellendi: {{{progress.updated}}} kişi
|
||||||
- ⚠️ Hatalar: {{{progress.errors}}} 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:
|
Ayrıntılar:
|
||||||
[{{{details.text}}}]({{{details.url}}})
|
[{{{details.text}}}]({{{details.url}}})
|
||||||
{{/error}}
|
|
||||||
|
|
||||||
task.contacts.started: |-
|
task.contacts.started: |-
|
||||||
Kişiler çekiliyor...
|
Kişiler çekiliyor...
|
||||||
|
|
||||||
task.contacts.progress: |-
|
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: |-
|
app.help.reminder: |-
|
||||||
Kullanılabilir komutların listesini almak için **help** gönderin.
|
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.
|
Her N kişide ilerlemeyi bildirir. 0 değeri raporları kapatır.
|
||||||
cli.cmd.contacts.pull.option.lids:
|
cli.cmd.contacts.pull.option.lids:
|
||||||
Senkronizasyona WhatsApp LID (anonim) kişilerini dahil eder.
|
Senkronizasyona WhatsApp LID (anonim) kişilerini dahil eder.
|
||||||
task.contacts.completed:
|
task.contacts.completed: |-
|
||||||
👤✅ Kişiler tamamlandı → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪
|
👤✅ Kişiler tamamlandı → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
|
||||||
{{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
|
|
||||||
task.messages.started: ⏳ Mesajlar çekiliyor 💬 {{{chat}}} ⏱️ {{{period}}}...
|
task.messages.started: ⏳ Mesajlar çekiliyor 💬 {{{chat}}} ⏱️ {{{period}}}...
|
||||||
task.messages.details:
|
task.messages.details:
|
||||||
Daha fazla ayrıntı için "{{{prefix}}}messages status" gönderin.
|
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: |-
|
cli.cmd.contacts.pull.option.avatar: |-
|
||||||
Виберіть, як завантажувати аватари контактів: `skip`, `if-missing` чи `update`.
|
Виберіть, як завантажувати аватари контактів: `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: |-
|
cli.cmd.contacts.pull.option.groups: |-
|
||||||
Додати контакти груп WhatsApp до синхронізації.
|
Додати контакти груп WhatsApp до синхронізації.
|
||||||
|
|
||||||
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
|
|||||||
**Глобальні опції:**
|
**Глобальні опції:**
|
||||||
|
|
||||||
task.contacts.status: |-
|
task.contacts.status: |-
|
||||||
👤 Отримання контактів: **{{state}}**
|
👤 Контакти: **{{state}}**
|
||||||
- ✅ Успішно: {{{progress.ok}}} контактів
|
- 🆕 Створено: {{{progress.created}}} контактів
|
||||||
|
- 🔄 Оновлено: {{{progress.updated}}} контактів
|
||||||
|
- 🏷️ Перейменовано: {{{progress.renamed}}} контактів
|
||||||
- ⚪ Пропущено: {{{progress.skipped}}} контактів
|
- ⚪ Пропущено: {{{progress.skipped}}} контактів
|
||||||
- ⚠️ Помилки: {{{progress.errors}}} контактів
|
- 🔴 Помилки: {{{progress.errors}}} контактів
|
||||||
|
- 🖼️ Аватар оновлено: {{{progress.avatar.updated}}} контактів
|
||||||
|
|
||||||
{{#job}}
|
|
||||||
⌛ Хронологія:
|
|
||||||
- У черзі: {{{timestamp}}}
|
|
||||||
{{#processedOn}}
|
|
||||||
- Початок: {{{processedOn}}}
|
|
||||||
{{/processedOn}}
|
|
||||||
{{#finishedOn}}
|
|
||||||
- Завершення: {{{finishedOn}}}
|
|
||||||
{{/finishedOn}}
|
|
||||||
{{/job}}
|
|
||||||
|
|
||||||
{{#error}}
|
|
||||||
Деталі:
|
Деталі:
|
||||||
[{{{details.text}}}]({{{details.url}}})
|
[{{{details.text}}}]({{{details.url}}})
|
||||||
{{/error}}
|
|
||||||
|
|
||||||
task.contacts.started: |-
|
task.contacts.started: |-
|
||||||
Отримання контактів...
|
Отримання контактів...
|
||||||
|
|
||||||
task.contacts.progress: |-
|
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: |-
|
app.help.reminder: |-
|
||||||
Надішліть **help**, щоб отримати список доступних команд.
|
Надішліть **help**, щоб отримати список доступних команд.
|
||||||
@@ -645,9 +638,8 @@ cli.cmd.contacts.pull.option.progress:
|
|||||||
Повідомляти про прогрес кожні N контактів. Значення 0 вимикає звіти.
|
Повідомляти про прогрес кожні N контактів. Значення 0 вимикає звіти.
|
||||||
cli.cmd.contacts.pull.option.lids:
|
cli.cmd.contacts.pull.option.lids:
|
||||||
Включати анонімні контакти WhatsApp LID у синхронізацію.
|
Включати анонімні контакти WhatsApp LID у синхронізацію.
|
||||||
task.contacts.completed:
|
task.contacts.completed: |-
|
||||||
👤✅ Контакти завершено → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪
|
👤✅ Контакти завершено → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, 🏷️ {{progress.renamed}}, ⚪ {{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
|
||||||
{{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
|
|
||||||
task.messages.started:
|
task.messages.started:
|
||||||
⏳ Отримуємо повідомлення 💬 {{{chat}}} ⏱️ {{{period}}}...
|
⏳ Отримуємо повідомлення 💬 {{{chat}}} ⏱️ {{{period}}}...
|
||||||
task.messages.details:
|
task.messages.details:
|
||||||
|
|||||||
@@ -194,6 +194,9 @@ cli.cmd.contacts.pull.option.delay-batch: |-
|
|||||||
cli.cmd.contacts.pull.option.avatar: |-
|
cli.cmd.contacts.pull.option.avatar: |-
|
||||||
یہ منتخب کریں کہ رابطوں کے اوتار کس طرح نکالے جائیں: `skip`, `if-missing`, یا `update`.
|
یہ منتخب کریں کہ رابطوں کے اوتار کس طرح نکالے جائیں: `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: |-
|
cli.cmd.contacts.pull.option.groups: |-
|
||||||
واٹس ایپ گروپ روابط کو ہم وقت سازی میں شامل کریں۔
|
واٹس ایپ گروپ روابط کو ہم وقت سازی میں شامل کریں۔
|
||||||
|
|
||||||
@@ -245,32 +248,22 @@ cli.help.globalOptions.title: |-
|
|||||||
**عالمی اختیارات:**
|
**عالمی اختیارات:**
|
||||||
|
|
||||||
task.contacts.status: |-
|
task.contacts.status: |-
|
||||||
👤 رابطے نکالنا: **{{state}}**
|
👤 رابطے: **{{state}}**
|
||||||
- ✅ کامیابی: {{{progress.ok}}} روابط
|
- 🆕 بنائے گئے: {{{progress.created}}} رابطے
|
||||||
- ⚪ چھوڑے گئے: {{{progress.skipped}}} روابط
|
- 🔄 اپ ڈیٹ: {{{progress.updated}}} رابطے
|
||||||
- ⚠️ غلطیاں: {{{progress.errors}}} روابط
|
- 🏷️ نام تبدیل: {{{progress.renamed}}} رابطے
|
||||||
|
- ⚪ چھوڑے گئے: {{{progress.skipped}}} رابطے
|
||||||
|
- 🔴 خرابیاں: {{{progress.errors}}} رابطے
|
||||||
|
- 🖼️ اوتار اپ ڈیٹ: {{{progress.avatar.updated}}} رابطے
|
||||||
|
|
||||||
{{#job}}
|
|
||||||
⌛ ٹائم لائن:
|
|
||||||
- قطار میں: {{{timestamp}}}
|
|
||||||
{{#processedOn}}
|
|
||||||
- آغاز: {{{processedOn}}}
|
|
||||||
{{/processedOn}}
|
|
||||||
{{#finishedOn}}
|
|
||||||
- اختتام: {{{finishedOn}}}
|
|
||||||
{{/finishedOn}}
|
|
||||||
{{/job}}
|
|
||||||
|
|
||||||
{{#error}}
|
|
||||||
تفصیلات:
|
تفصیلات:
|
||||||
[{{{details.text}}}]({{{details.url}}})
|
[{{{details.text}}}]({{{details.url}}})
|
||||||
{{/error}}
|
|
||||||
|
|
||||||
task.contacts.started: |-
|
task.contacts.started: |-
|
||||||
روابط نکالے جا رہے ہیں...
|
روابط نکالے جا رہے ہیں...
|
||||||
|
|
||||||
task.contacts.progress: |-
|
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: |-
|
app.help.reminder: |-
|
||||||
دستیاب کمانڈز کی فہرست حاصل کرنے کے لیے **help** بھیجیں۔
|
دستیاب کمانڈز کی فہرست حاصل کرنے کے لیے **help** بھیجیں۔
|
||||||
@@ -644,9 +637,8 @@ cli.cmd.contacts.pull.option.progress:
|
|||||||
ہر N رابطوں پر پیش رفت بتائیں۔ رپورٹ بند کرنے کے لیے 0 مقرر کریں۔
|
ہر N رابطوں پر پیش رفت بتائیں۔ رپورٹ بند کرنے کے لیے 0 مقرر کریں۔
|
||||||
cli.cmd.contacts.pull.option.lids:
|
cli.cmd.contacts.pull.option.lids:
|
||||||
WhatsApp LID (گمنام) رابطوں کو ہم آہنگی میں شامل کریں۔
|
WhatsApp LID (گمنام) رابطوں کو ہم آہنگی میں شامل کریں۔
|
||||||
task.contacts.completed:
|
task.contacts.completed: |-
|
||||||
👤✅ رابطے مکمل → 🆕 {{progress.created}}, 🔄 {{progress.updated}}, ⚪
|
👤✅ رابطے مکمل → 🆕 {{progress.created}}، 🔄 {{progress.updated}}، 🏷️ {{progress.renamed}}، ⚪ {{progress.skipped}}، 🔴 {{progress.errors}}، 🖼️ {{progress.avatar.updated}}
|
||||||
{{progress.skipped}}, 🔴 {{progress.errors}}, 🖼️ {{progress.avatar.updated}}
|
|
||||||
task.messages.started:
|
task.messages.started:
|
||||||
⏳ پیغامات کھینچے جا رہے ہیں 💬 {{{chat}}} ⏱️ {{{period}}}...
|
⏳ پیغامات کھینچے جا رہے ہیں 💬 {{{chat}}} ⏱️ {{{period}}}...
|
||||||
task.messages.details: مزید تفصیلات کے لیے "{{{prefix}}}messages status" بھیجیں۔
|
task.messages.details: مزید تفصیلات کے لیے "{{{prefix}}}messages status" بھیجیں۔
|
||||||
|
|||||||
Loaded 100 of 268 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user