Compare commits

..
45 Commits
Author SHA1 Message Date
devlikepro 8ab642b20d fix(ChatWoot): duplicate contact created for same LID without phone number - fix #2246
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-09-01 15:36:04 +07:00
devlikeproandJulián Valdés 93fa55b7b7 fix(ChatWoot): name LID contacts by push name instead of raw @lid id - closes #1493
Co-authored-by: Julián Valdés <julianvaldes24@gmail.com>
2026-09-01 14:56:33 +07:00
devlikepro 2881458baf chore: waha-dashboard and waha-swagger modules 2026-08-31 18:12:17 +07:00
devlikepro 2595ccfe7b chore: http.paths - access log, auth, metrics 2026-08-31 17:40:01 +07:00
devlikepro b7c4eac93b feat: Prometheus support - GET /metrics - closes #2245 fix #2245 2026-08-31 17:23:56 +07:00
devlikepro 89543ab371 chore: EventWildUnmask support wildcard - group.* 2026-08-31 17:05:52 +07:00
devlikepro 86aba9ea94 chore: EventWildUnmask return unknown 2026-08-31 17:05:52 +07:00
devlikepro 6d25680d43 fix(GOWS): Messages carrying senderKeyDistributionMessage never decrypt — silent group message loss since @lid migration - fix #2242 2026-08-31 12:38:19 +07:00
devlikepro 068658df23 feat: modules docs 2026-08-31 12:36:22 +07:00
devlikepro 69306942ee feat: waha-webhook 2026-08-31 12:32:48 +07:00
devlikepro e968b49349 feat: waha-session-runtime-info 2026-08-31 12:21:17 +07:00
devlikepro 6dc19229ae feat: waha-message-source 2026-08-31 12:17:16 +07:00
devlikepro 63d56b8467 feat: waha-wid-jid + waha-wid-suffix plugins 2026-08-31 12:14:58 +07:00
devlikepro 53687647e9 feat: plugins + modules 2026-08-31 12:07:46 +07:00
devlikepro fdf0589b68 up(WEBJS): handle reply_to in channels - fix #2233 2026-08-31 11:38:22 +07:00
devlikepro f32dace3f8 up(WEBJS): handle duplicated messages - fix #2235 2026-08-31 11:22:58 +07:00
devlikepro ed056cb1f6 fix(WEBJS): handle duplicated messages - fix #2235 2026-08-31 11:20:05 +07:00
devlikepro 0e9b02d92c up(WEBJS)
- Handle reply_to in channels - fix #2233
2026-08-31 10:50:08 +07:00
devlikepro fc556dad77 up(NOWEB)
- Handle reply_to in channels - fix #2233
2026-08-31 10:49:51 +07:00
devlikepro 5780bc09f9 up(WEBJS)
- fix delete message in channels - fix #2236
2026-08-31 10:44:22 +07:00
devlikepro d79940e494 up(dashboard) 2026-08-31 10:44:22 +07:00
devlikepro 1df6ec68db feat: /api/sendSticker
Co-authored-by: nickxs1 <lucasendlichgomes@hotmail.com>

fix #1287
closes #2240
2026-08-31 10:44:22 +07:00
devlikepro a06a72901a fix: Save zip if lottie processing failed
mention #2239
2026-08-31 10:44:22 +07:00
devlikepro 7e859b4ca9 up(WPP) 2026-08-31 10:44:22 +07:00
devlikepro 9490a05586 feat: app purge API 2026-08-31 10:44:22 +07:00
devlikepro b244115600 chore: remove CoreMediaConverter 2026-08-31 10:44:22 +07:00
devlikepro 287d52fe1a feat: Plugin.attach 2026-08-31 10:44:22 +07:00
devlikepro 21efcf07b1 fix: apps openapi tags, brazilian phone numbers app cache 2026-08-31 10:44:22 +07:00
devlikepro aaa0eb12d5 fix: gows .engine 2026-08-31 10:44:22 +07:00
devlikepro 1c5ece5a86 fix: app config import 2026-08-31 10:44:22 +07:00
devlikepro 061601826e fix: circular dependencies - noweb - chatwoot 2026-08-31 10:44:22 +07:00
devlikepro af51c0ae65 feat: app.modules.ts 2026-08-31 10:44:22 +07:00
devlikepro 16b50c672b feat: unique flag for apps - one instance per session 2026-08-31 10:44:22 +07:00
devlikepro dbda9c0457 feat: session plugins registry 2026-08-31 10:44:22 +07:00
devlikeproandbergpinheiro ff30e5ca90 feat: BrazilianPhoneNumberApp + Plugin
Co-authored-by: bergpinheiro
  <24882737+bergpinheiro@users.noreply.github.com>

closes #2180
2026-08-31 10:44:22 +07:00
devlikepro 2770c649c6 feat: IsDuration nestjs field validation 2026-08-31 10:44:22 +07:00
devlikepro 61f1f71343 feat: hooks, plugins, apps
- MaintainOnlineStatusPlugin (Activity)
- MessageSourceCachePlugin
- WebhookPlugin
- Wid*Plugin
2026-08-31 10:44:22 +07:00
devlikeproandElimeshi1 99e4dfcfb5 feat: granular control for media - api, events, mimetypes
closes #2211

Co-authored-by: Elimeshi1 <eliyaume@edu.hac.ac.il>
2026-08-31 10:44:22 +07:00
devlikeproandicecubex300 cc12380e48 feat(groups): membership approval - settings, pending requests API and group.v2.participants.join-request event
Closes #2200

Co-authored-by: icecubex300 <83597812+icecubex300@users.noreply.github.com>
2026-08-31 10:44:22 +07:00
devlikepro b5e223a333 up: axios 1.19.0 2026-08-31 10:44:22 +07:00
devlikeproandAnderson Lemes f6db89c141 fix(proxy): honor HTTP(S)_PROXY when downloading media
Closes #2224

Co-authored-by: Anderson Lemes <andersonlemes@outlook.pt>
2026-08-31 10:44:22 +07:00
devlikepro d2545c2de6 feat: use traceparent from opentelemetry
fix #2176 closes #2179
2026-08-31 10:44:22 +07:00
devlikepro 12a774c864 up(GOWS)
- Fix maps mirrored fromMe messages to the wrong contact/conversation - fix #2241
- Unable to retrieve WhatsApp groups - fix #2234
2026-08-31 10:44:22 +07:00
devlikepro 186847a455 up(WPP) 2026-08-19 15:51:46 +07:00
devlikepro 8c44b70109 version: 2026.8.2 2026-08-14 19:15:57 +07:00
174 changed files with 8996 additions and 1433 deletions

No files matched your search

+24
View File
@@ -56,6 +56,20 @@ WAHA_LOG_LEVEL=info
# Don't print QR codes in logs
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 =====
# =========================
@@ -64,7 +78,17 @@ WAHA_MEDIA_STORAGE=LOCAL
WHATSAPP_FILES_LIFETIME=0
WHATSAPP_FILES_FOLDER=/app/.media
#
# 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_FILES_MIMETYPES=image/jpeg,image/png
+10
View File
@@ -60,7 +60,17 @@ WAHA_MEDIA_STORAGE=LOCAL
WHATSAPP_FILES_LIFETIME=1800
WHATSAPP_FILES_FOLDER=/app/.media
#
# 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_FILES_MIMETYPES=image/jpeg,image/png
+9 -2
View File
@@ -54,6 +54,12 @@
"@nestjs/swagger": "^7.1.11",
"@nestjs/terminus": "^10.2.3",
"@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/lodash": "^4.14.194",
"@types/mustache": "^4.2.5",
@@ -66,7 +72,7 @@
"agentkeepalive": "^4.5.0",
"async-lock": "^1.4.1",
"audio-decode": "^2.2.2",
"axios": "^1.9.0",
"axios": "^1.19.0",
"axios-retry": "^4.5.0",
"better-sqlite3": "^12.4.1",
"bullmq": "^5.48.1",
@@ -115,6 +121,7 @@
"shell-quote": "^1.8.3",
"sqlite3": "^5.1.7",
"swagger-ui-express": "^4.1.4",
"tapable": "^2.2.2",
"ulid": "^2.3.0",
"undici": "^7.16.0",
"uniqid": "^5.4.0",
@@ -133,7 +140,7 @@
"ws": "^8.18.0",
"puppeteer": "^24.31.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"
},
"devDependencies": {
+42 -1
View File
@@ -9,7 +9,7 @@ import {
UsePipes,
ValidationPipe,
} 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 {
GetChatMessagesFilter,
@@ -37,6 +37,7 @@ import {
MessageReactionRequest,
MessageReplyRequest,
MessageStarRequest,
MessageStickerRequest,
MessageTextQuery,
MessageTextRequest,
MessageVideoRequest,
@@ -145,6 +146,46 @@ export class ChattingController {
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')
@ApiOperation({
summary: 'Send a text message with a CUSTOM link preview.',
+85
View File
@@ -32,6 +32,8 @@ import {
CreateGroupRequest,
DescriptionRequest,
GroupField,
GroupJoinRequest,
GroupJoinRequestResult,
GroupParticipant,
GroupsListFields,
GroupsPaginationParams,
@@ -39,6 +41,7 @@ import {
JoinGroupResponse,
ParticipantsRequest,
SettingsMemberAddMode,
SettingsMembershipApproval,
SettingsSecurityChangeInfo,
SubjectRequest,
} from '../structures/groups.dto';
@@ -343,6 +346,88 @@ export class GroupsController {
return session.getMemberAddMode(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')
@SessionApiParam
@GroupIdApiParam
+7 -2
View File
@@ -41,6 +41,7 @@ import {
SessionExpand,
SessionInfo,
SessionInfoQuery,
SessionLogoutRequest,
SessionUpdateRequest,
} from '../structures/sessions.dto';
import { SessionExamples } from './sessions.examples';
@@ -203,10 +204,14 @@ class SessionsController {
summary: 'Logout from the session',
description: 'Logout the session, restart a session if it was not STOPPED',
})
@ApiBody({ type: SessionLogoutRequest, required: false })
@CheckPolicies(CanSession(Action.Control, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async logout(@Param('session') name: string): Promise<SessionDTO> {
return this.sessionService.logoutSession(name);
async logout(
@Param('session') name: string,
@Body() request?: SessionLogoutRequest,
): Promise<SessionDTO> {
return this.sessionService.logoutSession(name, request);
}
@Post(':session/restart')
+1 -1
View File
@@ -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 = {
basic: {
+8 -3
View File
@@ -97,7 +97,7 @@ export class WebsocketGatewayCore
}
this.logger.debug(`New client connected: ${request.url} - ${socket.id}`);
const events: WAHAEvents[] = params.events;
const events = params.events as WAHAEvents[];
this.logger.debug(
`Client connected to session: '${session}', events: ${events}, ${socket.id}`,
);
@@ -131,8 +131,13 @@ export class WebsocketGatewayCore
const paramsEvents = query.getAll('events');
const eventsRaw = paramsEvents.length > 0 ? paramsEvents : ['*'];
const eventsList = eventsRaw.flatMap((value) => value.split(','));
const events = this.eventUnmask.unmask(eventsList);
return { session, events };
const result = this.eventUnmask.unmask(eventsList);
if (result.unknown.length > 0) {
this.logger.warn(
`Ignoring unknown websocket events: ${result.unknown.join(', ')}`,
);
}
return { session, events: result.events };
}
handleDisconnect(socket: WebSocket): any {
+49 -5
View File
@@ -14,7 +14,9 @@ import {
UseGuards,
UsePipes,
} 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 {
AppsService,
IAppsService,
@@ -26,12 +28,13 @@ import {
CanServer,
CanSession,
FromBody,
FromParam,
FromQuery,
} from '@waha/core/auth/policies';
import { Action, session as SessionName } from '@waha/core/auth/casl.types';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import { APPS } from '../apps/definition';
import { GetApp } from '../apps/registry';
import { App } from '../dto/app.dto';
import { ListAppsQuery } from '../dto/query.dto';
@@ -44,6 +47,7 @@ export class AppsController {
@Inject(AppsService)
private appsService: IAppsService,
private manager: SessionManager,
private resolver: UniqueAppResolver,
) {}
@Get('/')
@@ -63,7 +67,11 @@ export class AppsController {
async create(@Body() app: App): Promise<App> {
const result = await this.appsService.create(this.manager, app);
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);
}
return result;
@@ -114,7 +122,7 @@ export class AppsController {
const result = await this.appsService.upsert(this.manager, app);
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);
}
return result;
@@ -134,8 +142,44 @@ export class AppsController {
}
const app = await this.appsService.delete(this.manager, id);
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);
}
}
@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);
}
}
+28
View File
@@ -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,
};
});
}
+8 -8
View File
@@ -1,15 +1,15 @@
import { AppEnv } from '@waha/apps/app_sdk/env';
import { AppDefinition, APPS } from '@waha/apps/app_sdk/apps/definition';
import { AppName } from '@waha/apps/app_sdk/apps/name';
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
import { GetApps } from '@waha/apps/app_sdk/apps/registry';
class AppRuntimeConfigC {
private constructor(private apps: AppDefinition[] | null) {}
private constructor(private apps: AppModule[] | null) {}
static FromEnv(env: typeof AppEnv) {
if (!env.enabled) {
return new AppRuntimeConfigC(null);
}
let apps = Object.values(APPS);
let apps: AppModule[] = GetApps();
// Include
if (env.on && env.on.length > 0) {
apps = apps.filter((app) => env.on!.includes(app.name));
@@ -30,18 +30,18 @@ class AppRuntimeConfigC {
}
GetAppsWithMigration() {
return this.GetApps().filter((app) => app.migrations);
return this.GetApps().filter((app) => app.definition.migrations);
}
GetAppsRequiringPlainKey() {
return this.GetApps().filter((app) => app.plainkey);
return this.GetApps().filter((app) => app.definition.plainkey);
}
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);
}
+28
View File
@@ -0,0 +1,28 @@
import { Type } from '@nestjs/common';
import { BrazilianPhoneNumbersAppConfig } from '@waha/apps/brazilian-phone-numbers/dto/config.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';
export enum AppName {
chatwoot = 'chatwoot',
calls = 'calls',
mcp = 'mcp',
brazilianPhoneNumbers = 'brazilian-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.brazilianPhoneNumbers]: BrazilianPhoneNumbersAppConfig,
[AppName.calls]: CallsAppConfig,
[AppName.chatwoot]: ChatWootAppConfig,
[AppName.mcp]: McpAppConfig,
};
export function GetAppConfigClass(name: AppName): Type<any> {
return AppConfigClasses[name] ?? Object;
}
+51 -27
View File
@@ -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 {
// App name
name: AppName;
// If app requires WAHA_API_KEY_PLAIN to work
plainkey: boolean;
// If app requires queue to work
@@ -11,29 +13,51 @@ export interface AppDefinition {
migrations: boolean;
// If adding, updating, or removing this app requires a session restart
restartOnChange: boolean;
// If only one instance of this app is allowed per session
unique: boolean;
}
// All Apps
export const APPS: Record<AppName, AppDefinition> = {
[AppName.calls]: {
name: AppName.calls,
plainkey: false,
queue: false,
migrations: false,
restartOnChange: true,
},
[AppName.chatwoot]: {
name: AppName.chatwoot,
plainkey: true,
queue: true,
migrations: true,
restartOnChange: true,
},
[AppName.mcp]: {
name: AppName.mcp,
plainkey: false,
queue: false,
migrations: false,
restartOnChange: false,
},
};
// NestJS module parts, included when the app is enabled in runtime configuration
export interface AppNestJS {
imports: any[];
controllers: any[];
providers: any[];
}
/**
* Contract for the default export of 'src/apps/<name>/app.module.ts'.
* Each app self-describes with this and gets listed in the registry ('apps/registry.ts').
*/
export interface AppModule {
// App name
name: AppName;
// OpenAPI metadata (tag title, description) from 'src/apps/<name>/openapi.ts'
openapi: AppOpenAPI;
// Static app metadata (requirements, behavior flags)
definition: AppDefinition;
// NestJS module parts
nestjs: AppNestJS;
// Service implementing app lifecycle hooks; must also be listed in 'nestjs.providers'
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;
}
-5
View File
@@ -1,5 +0,0 @@
export enum AppName {
chatwoot = 'chatwoot',
calls = 'calls',
mcp = 'mcp',
}
+14
View File
@@ -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}`;
}
+34
View File
@@ -0,0 +1,34 @@
import { ApiTags } from '@nestjs/swagger';
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
import { AppApiTag } from '@waha/apps/app_sdk/apps/openapi';
import BrazilianPhoneNumbersAppModule from '@waha/apps/brazilian-phone-numbers/app.module';
import CallsAppModule from '@waha/apps/calls/app.module';
import ChatWootAppModule from '@waha/apps/chatwoot/app.module';
import McpAppModule from '@waha/apps/mcp/app.module';
/**
* Registry of available apps.
* Add new apps here - the rest of app_sdk works off this list.
*/
const APPS: AppModule[] = [
BrazilianPhoneNumbersAppModule,
CallsAppModule,
ChatWootAppModule,
McpAppModule,
];
// 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);
}
+12 -39
View File
@@ -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 {
IsBoolean,
@@ -10,15 +7,13 @@ import {
ValidateNested,
} from 'class-validator';
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 =
| ChatWootAppConfig
| CallsAppConfig
| McpAppConfig;
@ApiExtraModels(ChatWootAppConfig, CallsAppConfig, McpAppConfig)
export class App<T extends AllowedAppConfig = any> {
export class App<T = any> {
@IsString()
id: string;
@@ -41,36 +36,14 @@ export class App<T extends AllowedAppConfig = any> {
@ValidateNested()
@Type((options) => {
if (options && options.object && options.object.app) {
switch (options.object.app) {
case AppName.chatwoot:
return ChatWootAppConfig;
case AppName.calls:
return CallsAppConfig;
case AppName.mcp:
return McpAppConfig;
default:
return Object;
}
const name = options?.object?.app;
if (!name) {
return Object;
}
return Object;
return GetAppConfigClass(name);
})
config: T;
}
export class ChatWootAppDto extends App<ChatWootAppConfig> {
@Type(() => ChatWootAppConfig)
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;
// Swagger models for app configs
ApiExtraModels(...Object.values(AppConfigClasses))(App);
+3 -3
View File
@@ -1,9 +1,9 @@
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
const IN_MEMORY_APPS = Object.values(APPS)
.filter((app) => !app.queue)
const IN_MEMORY_APPS = GetApps()
.filter((app) => !app.definition.queue)
.map((app) => app.name);
function parseCommaSeparatedList(value: string | undefined): string[] {
@@ -41,6 +41,14 @@ export class AppsDisabledService implements IAppsService {
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) {
return;
}
+77 -41
View File
@@ -1,15 +1,12 @@
import {
Injectable,
NotFoundException,
Optional,
UnprocessableEntityException,
} from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { migrate } from '@waha/apps/app_sdk/migrations';
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
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 { SessionManager } from '@waha/core/abc/manager.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 { AppRepository } from '../storage/AppRepository';
import { AppName } from '@waha/apps/app_sdk/apps/name';
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 {
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()
export class AppsEnabledService implements IAppsService {
constructor(
@InjectPinoLogger('AppsService')
protected logger: PinoLogger,
@Optional() protected readonly chatwootService: ChatWootAppService,
@Optional() protected readonly callsAppService: CallsAppService,
@Optional() protected readonly mcpAppService: McpAppService,
private readonly moduleRef: ModuleRef,
) {}
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.`);
}
let existingApps: App[] = [];
if (app.app === AppName.chatwoot || app.app === AppName.calls) {
existingApps = await repo.getAllBySession(app.session);
}
// Validate only one Chatwoot app per session
if (app.app === AppName.chatwoot) {
const existingChatwootApp = existingApps.find(
(existingApp) => existingApp.app === AppName.chatwoot,
// Validate only one instance of a unique app per session
if (isUniqueApp(app.app)) {
const existingApps = await repo.getAllBySession(app.session);
const duplicateApp = existingApps.find(
(existingApp) => existingApp.app === app.app,
);
if (existingChatwootApp) {
throw new Error(
`Only one Chatwoot app is allowed per session. Session '${app.session}' already has a Chatwoot app with ID '${existingChatwootApp.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}'.`,
if (duplicateApp) {
throw new AppUniquePerSessionError(
app.app,
app.session,
duplicateApp.id,
);
}
}
@@ -192,6 +186,35 @@ export class AppsEnabledService implements IAppsService {
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) {
const knex = manager.store.getWAHADatabase();
const repo = new AppRepository(knex);
@@ -212,6 +235,10 @@ export class AppsEnabledService implements IAppsService {
if (!service && !AppRuntimeConfig.HasApp(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);
}
}
@@ -234,6 +261,15 @@ export class AppsEnabledService implements IAppsService {
session: string,
apps: App[],
): 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 ids = new Set<string>();
@@ -266,15 +302,15 @@ export class AppsEnabledService implements IAppsService {
}
private getAppService(app: App): IAppService | null {
switch (app.app) {
case AppName.chatwoot:
return this.chatwootService;
case AppName.calls:
return this.callsAppService;
case AppName.mcp:
return this.mcpAppService;
default:
throw new Error(`App '${app.app}' not supported`);
const appModule = GetApp(app.app);
if (!appModule) {
throw new Error(`App '${app.app}' not supported`);
}
try {
return this.moduleRef.get(appModule.Service, { strict: false });
} catch {
// Provider is not registered - app is disabled via WAHA_APPS_ON / WAHA_APPS_OFF
return null;
}
}
+19
View File
@@ -1,6 +1,8 @@
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 { WhatsappSession } from '@waha/core/abc/session.abc';
import { PluginOptions } from '@waha/core/abc/session.plugin';
/**
* Exact App service
@@ -42,6 +44,12 @@ export interface IAppService {
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.
* 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>;
/**
* 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;
afterSessionStart(app: App, session: WhatsappSession): void;
@@ -17,6 +17,10 @@ export interface IAppsService {
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>;
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);
}
}
+19
View File
@@ -83,6 +83,25 @@ export class AppRepository {
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
*/
+2
View File
@@ -1,5 +1,6 @@
import { AppsService } from '@waha/apps/app_sdk/services/IAppsService';
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 { ChatwootLocalesController } from '@waha/apps/chatwoot/api/chatwoot.locales.controller';
@@ -9,6 +10,7 @@ export const AppsDisabled = {
provide: AppsService,
useClass: AppsDisabledService,
},
UniqueAppResolver,
],
imports: [],
controllers: [AppsController, ChatwootLocalesController],
+15 -32
View File
@@ -4,17 +4,20 @@ import { RMutexModule } from '@waha/modules/rmutex';
import { BullBoardModule } from '@bull-board/nestjs';
import { ExpressAdapter } from '@bull-board/express';
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 { CallsAppExports } from '@waha/apps/calls/calls.module';
import { AppsService } from '@waha/apps/app_sdk/services/IAppsService';
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 { 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 = [
HttpPathsRegistration(
{ prefix: '/jobs', include: { authBasic: false } },
{ prefix: '/jobs/', include: { authBasic: false, accessLog: false } },
),
BullModule.forRoot({
connection: {
url: process.env.REDIS_URL || 'redis://:redis@localhost:6379',
@@ -71,47 +74,27 @@ const QUEUES_IMPORTS = AppRuntimeConfig.HasAppsRequiringQueue()
? QUEUES_IMPORTS_REQUIRED
: [];
function getAppModule(name: AppName) {
if (!AppRuntimeConfig.HasApp(name)) {
return {
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}`);
}
}
// Apps enabled in the runtime configuration (WAHA_APPS_ON / WAHA_APPS_OFF)
const ENABLED_APPS = GetApps().filter((app) =>
AppRuntimeConfig.HasApp(app.name),
);
export const AppsEnabled = {
imports: [
...QUEUES_IMPORTS,
...getAppModule(AppName.mcp).imports,
...getAppModule(AppName.chatwoot).imports,
...getAppModule(AppName.calls).imports,
...ENABLED_APPS.flatMap((app) => app.nestjs.imports),
],
controllers: [
AppsController,
...getAppModule(AppName.mcp).controllers,
...getAppModule(AppName.chatwoot).controllers,
...getAppModule(AppName.calls).controllers,
...ENABLED_APPS.flatMap((app) => app.nestjs.controllers),
],
providers: [
{
provide: AppsService,
useClass: AppsEnabledService,
},
...getAppModule(AppName.mcp).providers,
...getAppModule(AppName.calls).providers,
...getAppModule(AppName.chatwoot).providers,
UniqueAppResolver,
...ENABLED_APPS.flatMap((app) => app.nestjs.providers),
],
};
@@ -0,0 +1,163 @@
import {
Controller,
Delete,
Get,
Param,
Query,
UnprocessableEntityException,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity } from '@nestjs/swagger';
import {
BrazilianPhoneCachePurgeResponse,
BrazilianPhoneCacheStatsResponse,
BrazilianPhoneDbCacheEntry,
BrazilianPhoneMemoryCacheEntry,
} from '@waha/apps/brazilian-phone-numbers/dto/cache.dto';
import {
BrazilianPhoneNumbersAppConfig,
DEFAULT_PERSISTENT_TTL,
} from '@waha/apps/brazilian-phone-numbers/dto/config.dto';
import { BrazilianPhoneCorePlugin } from '@waha/apps/brazilian-phone-numbers/plugins/BrazilianPhoneCorePlugin';
import { BrazilianPhoneNumbersAppService } from '@waha/apps/brazilian-phone-numbers/services/BrazilianPhoneNumbersAppService';
import { BrazilianPhoneCacheRepository } from '@waha/apps/brazilian-phone-numbers/storage/BrazilianPhoneCacheRepository';
import { AppName } from '@waha/apps/app_sdk/apps/apps';
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
import { AppDB } from '@waha/apps/app_sdk/storage/types';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { Action } from '@waha/core/auth/casl.types';
import { CanSession, FromParam } from '@waha/core/auth/policies';
import { CheckPolicies } from '@waha/core/auth/policies.decorator';
import { PoliciesGuard } from '@waha/core/auth/policies.guard';
import { SessionApiParam } from '@waha/nestjs/params/SessionApiParam';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import { parseDurationMs } from '@waha/nestjs/validation/IsDuration';
import { LimitOffsetParams } from '@waha/structures/pagination.dto';
import * as ms from 'ms';
const DEFAULT_LIMIT = 100;
@ApiSecurity('api_key')
@Controller('api/apps/brazilian-phone-numbers/:session')
@UseGuards(PoliciesGuard)
export class BrazilianPhoneNumbersController {
constructor(
private manager: SessionManager,
private resolver: UniqueAppResolver,
private appService: BrazilianPhoneNumbersAppService,
) {}
@Get('cache/memory')
@SessionApiParam
@ApiOperation({
summary: 'List in-memory cache entries',
description:
'Entries from the in-memory cache tier of the running session, ' +
'sorted by key. The session must be running.',
})
@CheckPolicies(CanSession(Action.Control, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async memory(
@Param('session') session: string,
@Query(new WAHAValidationPipe()) query: LimitOffsetParams,
): Promise<BrazilianPhoneMemoryCacheEntry[]> {
const app = await this.getApp(session);
const plugin = this.resolver.getPlugin(app, BrazilianPhoneCorePlugin);
if (!plugin) {
throw new UnprocessableEntityException(
`Session '${session}' is not running - the in-memory cache is not available.`,
);
}
const offset = query.offset ?? 0;
const limit = query.limit ?? DEFAULT_LIMIT;
return plugin.getMemoryCacheEntries().slice(offset, offset + limit);
}
@Get('cache/db')
@SessionApiParam
@ApiOperation({
summary: 'List persistent cache entries',
description:
'Entries from the persistent (database) cache tier, sorted by id. ' +
'Works even when the session is stopped.',
})
@CheckPolicies(CanSession(Action.Control, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async db(
@Param('session') session: string,
@Query(new WAHAValidationPipe()) query: LimitOffsetParams,
): Promise<BrazilianPhoneDbCacheEntry[]> {
const app = await this.getApp(session);
if (!this.persistentEnabled(app)) {
throw new UnprocessableEntityException(
`Persistent cache is disabled for the app in session '${session}'.`,
);
}
const offset = query.offset ?? 0;
const limit = query.limit ?? DEFAULT_LIMIT;
return await this.repository(app).list(limit, offset);
}
@Get('cache/stats')
@SessionApiParam
@ApiOperation({
summary: 'Get cache stats',
description:
'Stats for both cache tiers. "memory" is null when the session is not running, ' +
'"db" is null when the persistent cache is disabled.',
})
@CheckPolicies(CanSession(Action.Control, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async stats(
@Param('session') session: string,
): Promise<BrazilianPhoneCacheStatsResponse> {
const app = await this.getApp(session);
const plugin = this.resolver.getPlugin(app, BrazilianPhoneCorePlugin);
const memory = plugin ? plugin.getMemoryCacheStats() : null;
let db = null;
if (this.persistentEnabled(app)) {
db = await this.repository(app).stats();
}
return { memory: memory, db: db };
}
@Delete('cache/purge')
@SessionApiParam
@ApiOperation({
summary: 'Purge the resolved-numbers cache',
description:
'Removes ALL persistent cache entries and clears the in-memory tier ' +
'(the in-memory tier only when the session is running).',
})
@CheckPolicies(CanSession(Action.Control, FromParam('session')))
@UsePipes(new WAHAValidationPipe())
async purge(
@Param('session') session: string,
): Promise<BrazilianPhoneCachePurgeResponse> {
const app = await this.getApp(session);
const deleted = await this.appService.purgeCache(this.manager, app);
return { deleted: deleted };
}
private async getApp(session: string): Promise<AppDB> {
return await this.resolver.getEnabledApp(
session,
AppName.brazilianPhoneNumbers,
);
}
private persistentEnabled(app: AppDB): boolean {
const config = app.config as BrazilianPhoneNumbersAppConfig;
return config?.cache?.persistent ?? true;
}
private repository(app: AppDB): BrazilianPhoneCacheRepository {
const knex = this.manager.store.getWAHADatabase();
const config = app.config as BrazilianPhoneNumbersAppConfig;
const ttlMs =
parseDurationMs(config?.cache?.persistentTtl) ??
ms(DEFAULT_PERSISTENT_TTL);
return new BrazilianPhoneCacheRepository(knex, app.pk, ttlMs);
}
}
@@ -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/api/brazilian-phone-numbers.controller';
import { BrazilianPhoneNumbersAppService } from '@waha/apps/brazilian-phone-numbers/services/BrazilianPhoneNumbersAppService';
const BrazilianPhoneNumbersAppModule: AppModule = {
name: AppName.brazilianPhoneNumbers,
openapi: {
title: 'Brazilian Phone Numbers',
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,8 @@
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { BrazilianPhoneNumbersAppConfig } from '@waha/apps/brazilian-phone-numbers/dto/config.dto';
import { Type } from 'class-transformer';
export class BrazilianPhoneNumbersAppDto extends App<BrazilianPhoneNumbersAppConfig> {
@Type(() => BrazilianPhoneNumbersAppConfig)
config: BrazilianPhoneNumbersAppConfig;
}
@@ -0,0 +1,92 @@
import { ApiProperty } from '@nestjs/swagger';
export class BrazilianPhoneMemoryCacheEntry {
@ApiProperty({
description: 'Phone number digits the cache entry is keyed by',
})
key: string;
@ApiProperty({
description:
'Resolved chat id. An empty string is a confirmed-negative - ' +
'the number is verified NOT to exist on WhatsApp.',
})
chatId: string;
@ApiProperty({
description: 'When the entry expires; null when it has no TTL',
nullable: true,
type: Date,
})
expiresAt: Date | null;
}
export class BrazilianPhoneDbCacheEntry {
@ApiProperty({
description: 'Record id',
})
id: number;
@ApiProperty({
description: 'Phone number digits the cache entry is keyed by',
})
key: string;
@ApiProperty({
description: 'Resolved chat id',
})
chatId: string;
@ApiProperty({
description: 'Whether the resolution was verified against WhatsApp',
})
verified: boolean;
@ApiProperty({
description: 'When the number was resolved',
})
resolvedAt: Date;
}
export class BrazilianPhoneMemoryCacheStats {
@ApiProperty({
description: 'Number of entries in the in-memory cache',
})
total: number;
}
export class BrazilianPhoneDbCacheStats {
@ApiProperty({
description: 'Total number of entries in the persistent cache',
})
total: number;
@ApiProperty({
description: 'Number of entries verified against WhatsApp',
})
verified: number;
}
export class BrazilianPhoneCacheStatsResponse {
@ApiProperty({
description: 'In-memory cache stats; null when the session is not running',
nullable: true,
type: BrazilianPhoneMemoryCacheStats,
})
memory: BrazilianPhoneMemoryCacheStats | null;
@ApiProperty({
description:
'Persistent cache stats; null when the persistent cache is disabled',
nullable: true,
type: BrazilianPhoneDbCacheStats,
})
db: BrazilianPhoneDbCacheStats | null;
}
export class BrazilianPhoneCachePurgeResponse {
@ApiProperty({
description: 'Number of entries removed from the persistent cache',
})
deleted: number;
}
@@ -0,0 +1,88 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsDuration } from '@waha/nestjs/validation/IsDuration';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator';
import * as ms from 'ms';
export const DEFAULT_MEMORY_TTL: ms.StringValue = '24h';
export const DEFAULT_PERSISTENT_TTL: ms.StringValue = '31d';
export class BrazilianPhoneNumbersCacheConfig {
@ApiProperty({
description:
'TTL for resolved numbers in the in-memory cache tier, as a duration string.',
required: false,
default: DEFAULT_MEMORY_TTL,
example: '24h',
})
@IsOptional()
@IsString()
@IsDuration()
memoryTtl?: string = DEFAULT_MEMORY_TTL;
@ApiProperty({
description:
'Persist verified resolutions in the database so they survive session restarts. ' +
'Unverified best-guesses and negatives are never persisted.',
required: false,
default: true,
})
@IsOptional()
@IsBoolean()
persistent?: boolean = true;
@ApiProperty({
description:
'TTL for resolved numbers in the database cache tier, as a duration string.',
required: false,
default: DEFAULT_PERSISTENT_TTL,
example: '31d',
})
@IsOptional()
@IsString()
@IsDuration()
persistentTtl?: string = DEFAULT_PERSISTENT_TTL;
}
export class BrazilianPhoneNumbersAppConfig {
@ApiProperty({
description:
'When a Brazilian mobile number is confirmed NOT to exist on WhatsApp: ' +
'false (default) - warn and send the best-guess anyway; ' +
'true - reject the send with 422. Strict trades delivery for certainty ' +
'and can block valid sends on lookup false-negatives (throttling).',
required: false,
default: false,
})
@IsOptional()
@IsBoolean()
strict?: boolean = false;
@ApiProperty({
description:
'Allow the WhatsApp server lookup tier for numbers the cache and the ' +
'local contact store cannot resolve. When false, unresolved numbers are ' +
'sent as provided.',
required: false,
default: true,
})
@IsOptional()
@IsBoolean()
lookup?: boolean = true;
@ApiProperty({
description: 'Cache tuning for resolved numbers.',
required: false,
type: BrazilianPhoneNumbersCacheConfig,
})
@IsOptional()
@ValidateNested()
@Type(() => BrazilianPhoneNumbersCacheConfig)
cache?: BrazilianPhoneNumbersCacheConfig =
new BrazilianPhoneNumbersCacheConfig();
}
@@ -0,0 +1,29 @@
import { Knex } from 'knex';
exports.up = function (knex: Knex) {
return knex.schema
.createTable('app_brazilian_phone_numbers_cache', function (table) {
table.increments('id');
table.integer('app_pk');
table
.foreign('app_pk')
.references('pk')
.inTable('apps')
.onDelete('CASCADE');
// Phone number digits the caller may address the chat by
table.string('key', 32);
// The chat id the number resolved to ('5585...@c.us' or '...@lid')
table.string('chat_id', 64);
table.boolean('verified');
table.datetime('resolved_at');
})
.table('app_brazilian_phone_numbers_cache', function (table) {
table.unique(['app_pk', 'key'], { indexName: 'brphone_app_key_unique' });
table.index(['app_pk'], 'brphone_app_idx');
table.index(['app_pk', 'resolved_at'], 'brphone_app_resolved_idx');
});
};
exports.down = function (knex: Knex) {
return knex.schema.dropTable('app_brazilian_phone_numbers_cache');
};
@@ -0,0 +1,474 @@
import { UnprocessableEntityException } from '@nestjs/common';
import { BrazilianPhoneGowsPlugin } from '@waha/apps/brazilian-phone-numbers/plugins/BrazilianPhoneGowsPlugin';
import { BrazilianPhoneNowebPlugin } from '@waha/apps/brazilian-phone-numbers/plugins/BrazilianPhoneNowebPlugin';
import { BrazilianPhoneCorePlugin } from '@waha/apps/brazilian-phone-numbers/plugins/BrazilianPhoneCorePlugin';
import {
BrazilianPhoneNumbersAppConfig,
BrazilianPhoneNumbersCacheConfig,
} from '@waha/apps/brazilian-phone-numbers/dto/config.dto';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { RegisterPluginHooks } from '@waha/core/abc/session.plugin.hooks';
const logger: any = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
};
logger.child = () => logger;
const BaseSession = WhatsappSession as unknown as new (params: any) => any;
class TestSession extends BaseSession {}
function buildSession(): any {
return new TestSession({
name: 'test',
printQR: false,
loggerBuilder: { child: () => logger },
sessionStore: null,
mediaManager: null,
sessionConfig: null,
engineConfig: null,
ignore: {},
});
}
function buildConfig(
overrides: Partial<BrazilianPhoneNumbersAppConfig> = {},
): BrazilianPhoneNumbersAppConfig {
const config = new BrazilianPhoneNumbersAppConfig();
config.cache = new BrazilianPhoneNumbersCacheConfig();
return Object.assign(config, overrides);
}
interface BuildOptions {
config?: Partial<BrazilianPhoneNumbersAppConfig>;
repository?: any;
pluginClass?: typeof BrazilianPhoneCorePlugin;
}
function buildPlugin(options: BuildOptions = {}) {
const session = buildSession();
const PluginClass = options.pluginClass ?? BrazilianPhoneCorePlugin;
const plugin = new PluginClass(session, logger, buildConfig(options.config), {
repository: options.repository ?? null,
});
RegisterPluginHooks(plugin);
return { session: session, plugin: plugin };
}
function resolveChat(session: any, wid: string, method = 'sendText') {
return session.hooks.wid.chat.promise(wid, method);
}
function stubLookup(session: any, answer: any) {
session.checkNumberStatus = jest.fn(async () => answer);
}
describe('BrazilianPhonePlugin - wid.chat', () => {
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 non-Brazilian numbers 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('BrazilianPhonePlugin - 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('BrazilianPhonePlugin - 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('BrazilianPhoneGowsPlugin - local LID map tier', () => {
it('resolves via the LID map with no WhatsApp lookup', async () => {
const { session } = buildPlugin({ pluginClass: BrazilianPhoneGowsPlugin });
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: BrazilianPhoneGowsPlugin });
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('BrazilianPhoneNowebPlugin - local contact store tier', () => {
it('resolves via the contact store with no WhatsApp lookup', async () => {
const { session } = buildPlugin({ pluginClass: BrazilianPhoneNowebPlugin });
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: BrazilianPhoneNowebPlugin });
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,383 @@
import { UnprocessableEntityException } from '@nestjs/common';
import {
BrazilianPhoneMemoryCacheEntry,
BrazilianPhoneMemoryCacheStats,
} from '@waha/apps/brazilian-phone-numbers/dto/cache.dto';
import {
BrazilianPhoneNumbersAppConfig,
DEFAULT_MEMORY_TTL,
} from '@waha/apps/brazilian-phone-numbers/dto/config.dto';
import { BrazilianPhoneCacheRepository } from '@waha/apps/brazilian-phone-numbers/storage/BrazilianPhoneCacheRepository';
import {
BR_PHONE_NEGATIVE_CACHE_TTL_SECONDS,
extractPhoneDigits,
generateBrazilMobileLookupCandidates,
getBrazilPhoneCacheKeys,
isBrazilCountryCode,
isBrazilMobile,
isMalformedBrazilPhone,
needsBrazilWhatsAppLookup,
normalizeBrazilMobileForSendDigits,
normalizeBrazilTollFreeDigits,
shouldSkipBrazilPhoneNormalization,
} from '@waha/apps/brazilian-phone-numbers/utils/brPhone';
import { ensureSuffix, WhatsappSession } from '@waha/core/abc/session.abc';
import { SessionPlugin } from '@waha/core/abc/session.plugin';
import { PluginHook } from '@waha/core/abc/session.plugin.hooks';
import { parseDurationMs } from '@waha/nestjs/validation/IsDuration';
import { WANumberExistResult } from '@waha/structures/chatting.dto';
import * as ms from 'ms';
import * as NodeCache from 'node-cache';
import { Logger } from 'pino';
// Engine methods that call these hooks from inside the resolution tiers
// (WPP 'checkNumberStatus', NOWEB/GOWS/WPP 'findLIDByPhoneNumber' route the
// phone through 'wid.chat' themselves) - resolving here again would recurse.
const SKIP_METHODS = new Set(['checkNumberStatus', 'findLIDByPhoneNumber']);
// Message-send paths get the full resolution, including the WhatsApp lookup,
// and may reject with 422 (malformed number, strict mode). Every other method
// (typing, seen, read, delete, group ops, ...) resolves locally only - cache
// and static rule - and never reaches the network or throws.
const SEND_METHODS = new Set([
'sendText',
'sendImage',
'sendFile',
'sendVideo',
'sendVoice',
'sendMedia',
'sendPoll',
'sendList',
'sendButtons',
'sendButtonsReply',
'sendContactVCard',
'sendLocation',
'sendLinkPreview',
'sendLinkCustomPreview',
'sendEvent',
'reply',
'forwardMessage',
]);
// Confirmed-negative marker in the memory cache ('' is not a valid chat id).
const NOT_FOUND = '';
export interface BrazilianPhoneCorePluginDeps {
repository: BrazilianPhoneCacheRepository | null;
}
/**
* Resolves Brazilian phone numbers into the chat id the account is actually
* registered under (9th-digit ambiguity for DDD 31-99, static rule for
* DDD < 31, 0800 toll-free rewrite), tiered cheapest-first:
*
* 1. in-memory cache, keyed by both forms of the number
* 2. static 9th-digit rule for DDD < 31 - no network
* 3. persistent database cache (verified resolutions only)
* 4. local contact / LID store (engine-specific subclasses) - no network
* 5. WhatsApp lookup, single-flight so concurrent sends share one query
*/
export class BrazilianPhoneCorePlugin extends SessionPlugin<
BrazilianPhoneNumbersAppConfig,
BrazilianPhoneCorePluginDeps
> {
protected memory: NodeCache;
// Single-flight guard: concurrent first-time resolutions of the same number
// share one in-flight WhatsApp lookup instead of each firing its own query.
private inflight: Map<string, Promise<string>> = new Map();
constructor(
session: WhatsappSession,
logger: Logger,
config: BrazilianPhoneNumbersAppConfig,
deps: BrazilianPhoneCorePluginDeps,
) {
super(session, logger, config, deps);
const memoryTtlMs =
parseDurationMs(config?.cache?.memoryTtl) ?? ms(DEFAULT_MEMORY_TTL);
this.memory = new NodeCache({
stdTTL: Math.floor(memoryTtlMs / 1000),
});
}
@PluginHook((hooks) => hooks.wid.chat)
async resolveChatWid(wid: string, method: string): Promise<string> {
if (SKIP_METHODS.has(method)) {
return wid;
}
return await this.resolve(wid, SEND_METHODS.has(method));
}
// Mentions are best-effort: a non-existent mention must never break the send.
@PluginHook((hooks) => hooks.wid.mention)
async resolveMentionWid(wid: string, method: string): Promise<string> {
if (SKIP_METHODS.has(method)) {
return wid;
}
try {
return await this.resolve(wid, SEND_METHODS.has(method));
} catch (error) {
this.logger.warn(
`Could not resolve mention '${wid}', using as-is: ${error}`,
);
return wid;
}
}
public clearMemoryCache(): void {
this.memory.flushAll();
}
public getMemoryCacheStats(): BrazilianPhoneMemoryCacheStats {
return { total: this.memory.keys().length };
}
// Entries sorted by key, so API pagination over them is stable.
public getMemoryCacheEntries(): BrazilianPhoneMemoryCacheEntry[] {
const entries: BrazilianPhoneMemoryCacheEntry[] = [];
for (const key of this.memory.keys().sort()) {
const chatId = this.memory.get<string>(key);
if (chatId === undefined) {
continue;
}
// getTtl(): expiration timestamp in ms, 0 = no TTL
const expiresAtMs = this.memory.getTtl(key);
entries.push({
key: key,
chatId: chatId,
expiresAt: expiresAtMs ? new Date(expiresAtMs) : null,
});
}
return entries;
}
// Optional per-engine tier: resolve a candidate against the local contact /
// LID store without hitting WhatsApp servers. Default: no local store.
protected async lookupKnownChatId(
candidates: string[],
): Promise<string | null> {
void candidates;
return null;
}
// Cached values are full chat ids ('5511...@c.us' or '123@lid'), never bare
// digits: stripping the suffix loses which addressing form was resolved, and
// re-adding '@c.us' to LID digits builds an id that addresses nobody.
protected async resolve(wid: string, validate: boolean): Promise<string> {
const withSuffix = ensureSuffix(wid);
if (shouldSkipBrazilPhoneNormalization(withSuffix)) {
return withSuffix;
}
const digits = extractPhoneDigits(withSuffix);
// Brazilian toll-free (0800): deterministic rewrite to the stored form, no
// lookup. Handled before the country-code gate because the dialed form
// ('0800...') has no 55 prefix.
const tollFree = normalizeBrazilTollFreeDigits(digits);
if (tollFree) {
return ensureSuffix(tollFree);
}
if (!isBrazilCountryCode(digits)) {
return withSuffix;
}
// Malformed Brazilian numbers (e.g. 55859912): hard error only on the send
// path; local-only ops just pass it through untouched.
if (isMalformedBrazilPhone(digits)) {
if (validate) {
throw new UnprocessableEntityException(
`Invalid Brazilian phone number '${withSuffix}'.`,
);
}
return withSuffix;
}
// Landlines and already-valid non-mobile numbers are left untouched.
if (!isBrazilMobile(digits)) {
return withSuffix;
}
// Tier 1: in-memory cache. undefined = miss, '' = confirmed-negative
// (strict mode), otherwise the resolved/best-guess chat id.
const cached = this.memory.get<string>(digits);
if (cached !== undefined) {
if (cached === NOT_FOUND) {
if (validate) {
throw new UnprocessableEntityException(
`Brazilian mobile phone number '${withSuffix}' does not exist on WhatsApp.`,
);
}
return withSuffix;
}
return cached;
}
// DDD below the lookup range: static 9th-digit rule, no network needed.
if (!needsBrazilWhatsAppLookup(digits)) {
const normalized = ensureSuffix(
normalizeBrazilMobileForSendDigits(digits),
);
this.cacheInMemory(digits, normalized);
return normalized;
}
// Tier 2: database cache (verified resolutions only).
const fromDb = await this.getFromDb(digits);
if (fromDb) {
this.cacheInMemory(digits, fromDb);
return fromDb;
}
const candidates = generateBrazilMobileLookupCandidates(digits);
// Tier 3: local contact/LID store (engine-specific), no network.
const fromStore = await this.lookupKnownChatId(candidates);
if (fromStore) {
await this.cacheResolved(digits, fromStore);
this.logger.debug(
`BR mobile '${withSuffix}' resolved locally to '${fromStore}' (no WhatsApp lookup).`,
);
return fromStore;
}
// Local-only ops never reach the network: return the input as-is.
if (!validate) {
return withSuffix;
}
// The WhatsApp lookup tier is disabled by config: send as provided.
if (this.config.lookup === false) {
return withSuffix;
}
// Tier 4: WhatsApp lookup as last resort, de-duplicated via single-flight.
return await this.resolveViaWhatsApp(digits, withSuffix, candidates);
}
private resolveViaWhatsApp(
digits: string,
withSuffix: string,
candidates: string[],
): Promise<string> {
const key = getBrazilPhoneCacheKeys(digits).sort().join('|');
const inflight = this.inflight.get(key);
if (inflight) {
return inflight;
}
const promise = this.lookupOnWhatsApp(
digits,
withSuffix,
candidates,
).finally(() => this.inflight.delete(key));
this.inflight.set(key, promise);
return promise;
}
private async lookupOnWhatsApp(
digits: string,
withSuffix: string,
candidates: string[],
): Promise<string> {
this.logger.debug(
`BR mobile '${withSuffix}' not found locally, performing WhatsApp lookup for: ${candidates.join(
', ',
)}`,
);
let lookupFailed = false;
for (const candidate of candidates) {
let result: WANumberExistResult;
try {
result = await this.session.checkNumberStatus({
phone: candidate,
session: this.session.name,
});
} catch (error) {
lookupFailed = true;
this.logger.warn(
`Failed to verify Brazilian mobile candidate '${candidate}': ${error}`,
);
continue;
}
// Prefer the phone-number chat id when the engine knows it; fall back to
// whatever chat id was answered (a LID for accounts with no phone form -
// routable, and it must not be reduced to digits).
const chatId = result?.pn || result?.chatId;
if (result?.numberExists && chatId) {
await this.cacheResolved(digits, chatId);
return chatId;
}
}
// Could not validate due to network/engine error: send as-is, do not cache.
if (lookupFailed) {
this.logger.warn(
`Could not validate Brazilian mobile number '${withSuffix}', sending as-is. Tried: ${candidates.join(
', ',
)}`,
);
return withSuffix;
}
// Verified not to exist in any form.
if (this.config.strict) {
this.cacheInMemory(
digits,
NOT_FOUND,
BR_PHONE_NEGATIVE_CACHE_TTL_SECONDS,
);
throw new UnprocessableEntityException(
`Brazilian mobile phone number '${withSuffix}' does not exist on WhatsApp. Tried: ${candidates.join(
', ',
)}`,
);
}
// Soft (default): warn and send the best-guess anyway, so a lookup
// false-negative never blocks a valid send. Short TTL - the number may
// get registered later.
const bestGuess = ensureSuffix(normalizeBrazilMobileForSendDigits(digits));
this.cacheInMemory(digits, bestGuess, BR_PHONE_NEGATIVE_CACHE_TTL_SECONDS);
this.logger.warn(
`Brazilian mobile number '${withSuffix}' not found on WhatsApp, sending best-guess '${bestGuess}'. Tried: ${candidates.join(
', ',
)}`,
);
return bestGuess;
}
// Cache under every form of the number, so both '5585...' and '55859...'
// hit the same entry. Default TTL for resolutions, the short negative TTL
// for best-guesses and confirmed-negatives.
private cacheInMemory(digits: string, chatId: string, ttl?: number): void {
for (const key of getBrazilPhoneCacheKeys(digits)) {
this.memory.set(key, chatId, ttl);
}
}
// Verified resolution: memory plus the database tier (when enabled).
private async cacheResolved(digits: string, chatId: string): Promise<void> {
this.cacheInMemory(digits, chatId);
if (!this.deps.repository) {
return;
}
try {
const keys = getBrazilPhoneCacheKeys(digits);
await this.deps.repository.setMany(keys, chatId, true, new Date());
} catch (error) {
this.logger.warn(
`Failed to persist BR phone resolution for '${digits}': ${error}`,
);
}
}
private async getFromDb(digits: string): Promise<string | null> {
if (!this.deps.repository) {
return null;
}
try {
const entry = await this.deps.repository.get(digits);
return entry?.chatId ?? null;
} catch (error) {
this.logger.warn(
`Failed to read BR phone cache for '${digits}': ${error}`,
);
return null;
}
}
}
@@ -0,0 +1,30 @@
import { BrazilianPhoneCorePlugin } from '@waha/apps/brazilian-phone-numbers/plugins/BrazilianPhoneCorePlugin';
/**
* GOWS local tier: the PN<->LID map (whatsmeow's 'whatsmeow_lid_map'), which is
* populated from contact sync and received messages and persisted in the gows
* store. A PN that has a LID mapping is a number this session already knows in
* its canonical form, so the correct 9th-digit variant is picked with zero
* network calls. Only genuinely cold numbers fall through to the WhatsApp
* lookup tier.
*/
export class BrazilianPhoneGowsPlugin extends BrazilianPhoneCorePlugin {
protected async lookupKnownChatId(
candidates: string[],
): Promise<string | null> {
for (const candidate of candidates) {
try {
const { lid, pn } = await this.session.findLIDByPhoneNumber(candidate);
// A non-empty LID user part means this exact PN is known locally.
if (lid && lid.split('@')[0]) {
return pn;
}
} catch (error) {
this.logger.debug(
`LID map lookup failed for candidate '${candidate}': ${error}`,
);
}
}
return null;
}
}
@@ -0,0 +1,29 @@
import { BrazilianPhoneCorePlugin } from '@waha/apps/brazilian-phone-numbers/plugins/BrazilianPhoneCorePlugin';
import { toCusFormat, toJID } from '@waha/core/utils/jids';
import type { WhatsappSessionNoWebCore } from '@waha/core/engines/noweb/session.noweb.core';
/**
* NOWEB local tier: the session's contact store, populated from history sync
* and received messages. A contact stored under one of the candidate forms is
* the canonical 9th-digit variant - no network call needed.
*/
export class BrazilianPhoneNowebPlugin extends BrazilianPhoneCorePlugin {
protected async lookupKnownChatId(
candidates: string[],
): Promise<string | null> {
const session = this.session as WhatsappSessionNoWebCore;
const store = session.store;
if (!store) {
return null;
}
for (const candidate of candidates) {
const jid = toJID(candidate);
const contact = await store.getContactById(jid).catch(() => null);
if (contact?.id) {
return toCusFormat(contact.id);
}
}
return null;
}
}
@@ -0,0 +1,169 @@
import { Injectable } from '@nestjs/common';
import {
BrazilianPhoneNumbersAppConfig,
DEFAULT_PERSISTENT_TTL,
} from '@waha/apps/brazilian-phone-numbers/dto/config.dto';
import { BrazilianPhoneGowsPlugin } from '@waha/apps/brazilian-phone-numbers/plugins/BrazilianPhoneGowsPlugin';
import { BrazilianPhoneNowebPlugin } from '@waha/apps/brazilian-phone-numbers/plugins/BrazilianPhoneNowebPlugin';
import { BrazilianPhoneCorePlugin } from '@waha/apps/brazilian-phone-numbers/plugins/BrazilianPhoneCorePlugin';
import { BrazilianPhoneCacheRepository } from '@waha/apps/brazilian-phone-numbers/storage/BrazilianPhoneCacheRepository';
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
import { UniqueAppResolver } from '@waha/apps/app_sdk/services/UniqueAppResolver';
import { PluginOptions } from '@waha/core/abc/session.plugin';
import { AppDB } from '@waha/apps/app_sdk/storage/types';
import { DataStore } from '@waha/core/abc/DataStore';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { parseDurationMs } from '@waha/nestjs/validation/IsDuration';
import { WAHAEngine } from '@waha/structures/enums.dto';
import * as ms from 'ms';
// Engines with a local contact/LID store get their own tier; the rest run the
// core pipeline (cache + static rule + WhatsApp lookup).
const PLUGINS: Record<WAHAEngine, typeof BrazilianPhoneCorePlugin> = {
[WAHAEngine.WEBJS]: BrazilianPhoneCorePlugin,
[WAHAEngine.WPP]: BrazilianPhoneCorePlugin,
[WAHAEngine.GOWS]: BrazilianPhoneGowsPlugin,
[WAHAEngine.NOWEB]: BrazilianPhoneNowebPlugin,
};
@Injectable()
export class BrazilianPhoneNumbersAppService implements IAppService {
constructor(private readonly resolver: UniqueAppResolver) {}
validate(app: App<BrazilianPhoneNumbersAppConfig>): void {
void app;
return;
}
async beforeCreated(app: App<BrazilianPhoneNumbersAppConfig>): Promise<void> {
void app;
return;
}
async beforeEnabled(
manager: SessionManager,
savedApp: App<BrazilianPhoneNumbersAppConfig>,
newApp: App<BrazilianPhoneNumbersAppConfig>,
): Promise<void> {
void manager;
void savedApp;
void newApp;
}
async beforeDisabled(
manager: SessionManager,
savedApp: App<BrazilianPhoneNumbersAppConfig>,
newApp: App<BrazilianPhoneNumbersAppConfig>,
): Promise<void> {
void manager;
void savedApp;
void newApp;
}
async beforeUpdated(
manager: SessionManager,
savedApp: App<BrazilianPhoneNumbersAppConfig>,
newApp: App<BrazilianPhoneNumbersAppConfig>,
): Promise<void> {
void manager;
void savedApp;
void newApp;
}
async beforeDeleted(
manager: SessionManager,
app: App<BrazilianPhoneNumbersAppConfig>,
): Promise<void> {
void manager;
void app;
}
async afterCreated(
manager: SessionManager,
app: App<BrazilianPhoneNumbersAppConfig>,
): Promise<void> {
void manager;
void app;
}
async beforeSessionDeleted(
manager: SessionManager,
app: App<BrazilianPhoneNumbersAppConfig>,
): Promise<void> {
void manager;
void app;
}
async purge(
manager: SessionManager,
app: App<BrazilianPhoneNumbersAppConfig>,
): Promise<void> {
await this.purgeCache(manager, app as AppDB);
}
/**
* Deletes all persistent cache entries and clears the in-memory tier when the session is running.
*/
async purgeCache(manager: SessionManager, app: AppDB): Promise<number> {
const knex = manager.store.getWAHADatabase();
const config = app.config as BrazilianPhoneNumbersAppConfig;
const ttlMs =
parseDurationMs(config?.cache?.persistentTtl) ??
ms(DEFAULT_PERSISTENT_TTL);
const repository = new BrazilianPhoneCacheRepository(knex, app.pk, ttlMs);
const deleted = await repository.purge();
this.resolver.getPlugin(app, BrazilianPhoneCorePlugin)?.clearMemoryCache();
return deleted;
}
async enrich(
manager: SessionManager,
app: App<BrazilianPhoneNumbersAppConfig>,
): Promise<void> {
void manager;
void app;
}
plugins(
app: App<BrazilianPhoneNumbersAppConfig>,
session: WhatsappSession,
store?: DataStore,
): PluginOptions[] {
const config = app.config ?? new BrazilianPhoneNumbersAppConfig();
let repository: BrazilianPhoneCacheRepository | null = null;
const persistent = config.cache?.persistent ?? true;
const appPk = (app as AppDB).pk;
if (persistent && store && appPk) {
const ttlMs =
parseDurationMs(config.cache?.persistentTtl) ??
ms(DEFAULT_PERSISTENT_TTL);
repository = new BrazilianPhoneCacheRepository(
store.getWAHADatabase(),
appPk,
ttlMs,
);
}
const BrazilianPhonePlugin = PLUGINS[session.engine];
return [BrazilianPhonePlugin.with(config, { repository: repository })];
}
beforeSessionStart(
app: App<BrazilianPhoneNumbersAppConfig>,
session: WhatsappSession,
): void {
void app;
void session;
return;
}
afterSessionStart(
app: App<BrazilianPhoneNumbersAppConfig>,
session: WhatsappSession,
): void {
void app;
void session;
return;
}
}
@@ -0,0 +1,135 @@
import knex, { Knex } from 'knex';
import { BrazilianPhoneCacheRepository } from './BrazilianPhoneCacheRepository';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const initApps = require('../../app_sdk/migrations/001_init_apps');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const initCache = require('../migrations/001_init_brazilian_phone_numbers');
const DAY_MS = 24 * 60 * 60 * 1000;
describe('BrazilianPhoneCacheRepository', () => {
let db: Knex;
let appPk: number;
let repository: BrazilianPhoneCacheRepository;
beforeAll(async () => {
db = knex({
client: 'sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
await initApps.up(db);
await initCache.up(db);
const [pk] = await db('apps')
.insert({
id: 'app_test',
session: 'default',
app: 'brazilian-phone-numbers',
config: '{}',
})
.returning('pk');
appPk = typeof pk === 'object' ? pk.pk : pk;
});
afterAll(async () => {
await db.destroy();
});
beforeEach(async () => {
repository = new BrazilianPhoneCacheRepository(db, appPk, 31 * DAY_MS);
await repository.purge();
});
it('stores and reads a resolution under multiple keys', async () => {
await repository.setMany(
['558591203123', '5585991203123'],
'558591203123@c.us',
true,
new Date(),
);
const byShortForm = await repository.get('558591203123');
const byLongForm = await repository.get('5585991203123');
expect(byShortForm?.chatId).toBe('558591203123@c.us');
expect(byShortForm?.verified).toBe(true);
expect(byLongForm?.chatId).toBe('558591203123@c.us');
});
it('upserts on conflict instead of duplicating keys', async () => {
await repository.setMany(['558591203123'], 'old@c.us', true, new Date());
await repository.setMany(
['558591203123'],
'77820596330581@lid',
true,
new Date(),
);
const entry = await repository.get('558591203123');
const stats = await repository.stats();
expect(entry?.chatId).toBe('77820596330581@lid');
expect(stats.total).toBe(1);
});
it('does not return expired entries', async () => {
const expired = new Date(Date.now() - 40 * DAY_MS);
await repository.setMany(
['558591203123'],
'558591203123@c.us',
true,
expired,
);
const entry = await repository.get('558591203123');
expect(entry).toBeNull();
});
it('purges only entries older than the given date', async () => {
const old = new Date(Date.now() - 10 * DAY_MS);
await repository.setMany(['558591203123'], '558591203123@c.us', true, old);
await repository.setMany(
['5511998765432'],
'5511998765432@c.us',
true,
new Date(),
);
const deleted = await repository.purge(new Date(Date.now() - 5 * DAY_MS));
expect(deleted).toBe(1);
expect(await repository.get('558591203123')).toBeNull();
expect(await repository.get('5511998765432')).not.toBeNull();
});
it('purges everything when no date is given', async () => {
await repository.setMany(
['558591203123', '5585991203123'],
'558591203123@c.us',
true,
new Date(),
);
const deleted = await repository.purge();
expect(deleted).toBe(2);
expect((await repository.stats()).total).toBe(0);
});
it('reports total and verified counts', async () => {
await repository.setMany(
['558591203123', '5585991203123'],
'558591203123@c.us',
true,
new Date(),
);
const stats = await repository.stats();
expect(stats.total).toBe(2);
expect(stats.verified).toBe(2);
});
});
@@ -0,0 +1,123 @@
import { Knex } from 'knex';
export interface BrazilianPhoneCacheEntry {
key: string;
chatId: string;
verified: boolean;
resolvedAt: Date;
}
export interface BrazilianPhoneCacheRow extends BrazilianPhoneCacheEntry {
id: number;
}
export interface BrazilianPhoneCacheStats {
total: number;
verified: number;
}
export class BrazilianPhoneCacheRepository {
static tableName = 'app_brazilian_phone_numbers_cache';
constructor(
private readonly knex: Knex,
private readonly appPk: number,
private readonly ttlMs: number,
) {}
get tableName() {
return BrazilianPhoneCacheRepository.tableName;
}
// Entries resolved before this date are expired.
private ttlCutoff(): Date {
return new Date(Date.now() - this.ttlMs);
}
async get(key: string): Promise<BrazilianPhoneCacheEntry | null> {
const row = await this.knex(this.tableName)
.where({
app_pk: this.appPk,
key: key,
})
.where('resolved_at', '>=', this.ttlCutoff())
.first();
if (!row) {
return null;
}
return {
key: row.key,
chatId: row.chat_id,
verified: Boolean(row.verified),
resolvedAt: new Date(row.resolved_at),
};
}
async setMany(
keys: string[],
chatId: string,
verified: boolean,
resolvedAt: Date,
): Promise<void> {
const rows = keys.map((key) => ({
app_pk: this.appPk,
key: key,
chat_id: chatId,
verified: verified,
resolved_at: resolvedAt,
}));
await this.knex(this.tableName)
.insert(rows)
.onConflict(['app_pk', 'key'])
.merge();
}
/**
* Lists cache entries for the app, sorted by id (insertion order).
*/
async list(limit: number, offset: number): Promise<BrazilianPhoneCacheRow[]> {
const rows = await this.knex(this.tableName)
.where({ app_pk: this.appPk })
.orderBy('id', 'asc')
.limit(limit)
.offset(offset);
return rows.map((row) => ({
id: row.id,
key: row.key,
chatId: row.chat_id,
verified: Boolean(row.verified),
resolvedAt: new Date(row.resolved_at),
}));
}
/**
* Deletes cache entries for the app.
* @param olderThan Only entries resolved before this date; all entries when omitted.
* @returns Number of deleted entries
*/
async purge(olderThan?: Date): Promise<number> {
const query = this.knex(this.tableName).where({ app_pk: this.appPk });
if (olderThan) {
query.where('resolved_at', '<', olderThan);
}
return await query.delete();
}
async stats(): Promise<BrazilianPhoneCacheStats> {
const row: any = await this.knex(this.tableName)
.where({ app_pk: this.appPk })
.count({ total: '*' })
.first();
const verifiedRow: any = await this.knex(this.tableName)
.where({
app_pk: this.appPk,
verified: true,
})
.count({ verified: '*' })
.first();
return {
total: Number(row?.total ?? 0),
verified: Number(verifiedRow?.verified ?? 0),
};
}
}
@@ -0,0 +1,120 @@
import {
extractPhoneDigits,
generateBrazilMobileLookupCandidates,
isBrazilCountryCode,
isBrazilLandline,
isBrazilMobile,
isMalformedBrazilPhone,
needsBrazilWhatsAppLookup,
normalizeBrazilMobileForSendDigits,
normalizeBrazilTollFreeDigits,
shouldSkipBrazilPhoneNormalization,
} from './brPhone';
describe('brPhone', () => {
it('skips groups and lids', () => {
expect(shouldSkipBrazilPhoneNormalization('123@g.us')).toBe(true);
expect(shouldSkipBrazilPhoneNormalization('123@lid')).toBe(true);
});
it('detects BR country code', () => {
expect(isBrazilCountryCode('558591203123')).toBe(true);
expect(isBrazilCountryCode('5491123456789')).toBe(false);
});
it('flags malformed BR numbers and accepts valid lengths', () => {
expect(isMalformedBrazilPhone('55859912')).toBe(true);
expect(isMalformedBrazilPhone('558591203123')).toBe(false);
expect(isMalformedBrazilPhone('5585991203123')).toBe(false);
// not a BR number, not our concern
expect(isMalformedBrazilPhone('123')).toBe(false);
});
it('detects BR landline numbers', () => {
expect(isBrazilLandline('558540423147')).toBe(true);
expect(isBrazilMobile('558540423147')).toBe(false);
});
it('detects BR mobile numbers', () => {
expect(isBrazilMobile('558591203123')).toBe(true);
expect(isBrazilMobile('5585991203123')).toBe(true);
});
it('accepts any digit after the leading 9 on a 9-digit local', () => {
// Brazil has no 9-digit landline, so '9' + 8 digits is always mobile.
// The old rule required 6-9 right after the 9 and rejected real SP lines.
expect(isBrazilMobile('5511953523741')).toBe(true);
expect(isBrazilLandline('5511953523741')).toBe(false);
expect(isBrazilMobile('5511912345678')).toBe(true);
expect(isBrazilMobile('5511902345678')).toBe(true);
});
it('keeps 9-digit mobiles out of the lookup range untouched', () => {
// DDD 11 is below the lookup range: no candidates, no WhatsApp lookup.
expect(needsBrazilWhatsAppLookup('5511953523741')).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,178 @@
import {
isJidBroadcast,
isJidGroup,
isJidMetaAI,
isJidNewsletter,
isLidUser,
} from '@waha/core/utils/jids';
const COUNTRY_CODE = '55';
const LANDLINE_FIRST_DIGIT = /^[2-5]/;
const MOBILE_FIRST_DIGIT = /^[6-9]/;
export const BR_PHONE_DDD_LOOKUP_MIN = 31;
export const BR_PHONE_DDD_LOOKUP_MAX = 99;
// Unverified best-guesses and confirmed-negatives live only in the in-memory
// cache with this short TTL, so a number registered later is re-checked soon.
export const BR_PHONE_NEGATIVE_CACHE_TTL_SECONDS = 10 * 60;
// A Brazil number (country code 55) must be 55 + DDD(2) + local(8 or 9) digits.
const BR_PHONE_MIN_LENGTH = 12;
const BR_PHONE_MAX_LENGTH = 13;
export function isBrazilCountryCode(digits: string): boolean {
return digits.startsWith(COUNTRY_CODE);
}
export function isMalformedBrazilPhone(digits: string): boolean {
if (!isBrazilCountryCode(digits)) {
return false;
}
return (
digits.length < BR_PHONE_MIN_LENGTH || digits.length > BR_PHONE_MAX_LENGTH
);
}
export function extractPhoneDigits(value: string): string {
if (!value) {
return '';
}
const local = value.split('@')[0] ?? value;
return local.split(':')[0].replace(/\D/g, '');
}
export function shouldSkipBrazilPhoneNormalization(chatId: string): boolean {
if (!chatId) {
return true;
}
if (isJidGroup(chatId)) {
return true;
}
if (isJidBroadcast(chatId)) {
return true;
}
if (isLidUser(chatId)) {
return true;
}
if (isJidNewsletter(chatId)) {
return true;
}
if (isJidMetaAI(chatId)) {
return true;
}
if (chatId === 'me') {
return true;
}
return false;
}
export function isBrazilPhone(digits: string): boolean {
return digits.startsWith(COUNTRY_CODE) && digits.length >= 12;
}
export function getBrazilDdd(digits: string): number {
return parseInt(digits.substring(2, 4), 10);
}
export function getBrazilLocalPart(digits: string): string {
return digits.substring(4);
}
export function isBrazilLandline(digits: string): boolean {
if (!isBrazilPhone(digits)) {
return false;
}
const local = getBrazilLocalPart(digits);
if (local.length !== 8) {
return false;
}
return LANDLINE_FIRST_DIGIT.test(local);
}
export function isBrazilMobile(digits: string): boolean {
if (!isBrazilPhone(digits) || isBrazilLandline(digits)) {
return false;
}
const local = getBrazilLocalPart(digits);
// 8-digit local: the legacy form, missing its 9. Only 6-9 tells it apart
// from a landline - 2-5 is genuinely ambiguous and treated as a landline.
if (local.length === 8) {
return MOBILE_FIRST_DIGIT.test(local);
}
// 9-digit local starting with 9: unambiguously mobile. Brazil has no
// 9-digit landline, and the digit after the leading 9 is unrestricted -
// e.g. SP 11 9535-23741. Do not test it.
if (local.length === 9 && local[0] === '9') {
return true;
}
return false;
}
// Brazilian toll-free (0800) numbers are non-geographic: dialed as '0800' plus
// 7 subscriber digits. WhatsApp stores them under country code 55 with the
// leading 0 dropped ('08000464636' -> '558000464636'). Callers send the dialed
// form, so map it to the stored one. The rewrite is deterministic - no WhatsApp
// lookup, unlike the 9th-digit mobile case. Returns null when not a toll-free
// number in a dialed form (the stored form '55800...' already routes untouched).
const BR_TOLLFREE_DIALED = /^0800\d{7}$/;
const BR_TOLLFREE_DIALED_CC = /^550800\d{7}$/;
export function normalizeBrazilTollFreeDigits(digits: string): string | null {
if (BR_TOLLFREE_DIALED.test(digits)) {
return `${COUNTRY_CODE}${digits.slice(1)}`;
}
if (BR_TOLLFREE_DIALED_CC.test(digits)) {
return `${COUNTRY_CODE}${digits.slice(3)}`;
}
return null;
}
export function needsBrazilWhatsAppLookup(digits: string): 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)];
}
export function getBrazilPhoneCacheKeys(digits: string): string[] {
const candidates = generateBrazilMobileLookupCandidates(digits);
return [...new Set([digits, ...candidates])];
}
+26
View File
@@ -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;
-7
View File
@@ -1,7 +0,0 @@
import { CallsAppService } from '@waha/apps/calls/services/CallsAppService';
export const CallsAppExports = {
providers: [CallsAppService],
imports: [],
controllers: [],
};
+8
View File
@@ -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;
}
+18 -9
View File
@@ -1,19 +1,14 @@
import { Injectable } from '@nestjs/common';
import { App } from '@waha/apps/app_sdk/dto/app.dto';
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 { CallsPlugin } from '@waha/apps/calls/services/CallsPlugin';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
import { CallsListener } from '@waha/apps/calls/services/CallsListener';
@Injectable()
export class CallsAppService implements IAppService {
constructor(
@InjectPinoLogger('CallsAppService')
private readonly logger: PinoLogger,
) {}
validate(app: App<CallsAppConfig>): void {
// The DTO validation covers structure; no extra validation rules.
void app;
@@ -79,6 +74,14 @@ export class CallsAppService implements IAppService {
void app;
}
async purge(
manager: SessionManager,
app: App<CallsAppConfig>,
): Promise<void> {
void manager;
void app;
}
async enrich(
manager: SessionManager,
app: App<CallsAppConfig>,
@@ -87,9 +90,15 @@ export class CallsAppService implements IAppService {
void app;
}
plugins(app: App<CallsAppConfig>, session: WhatsappSession): PluginOptions[] {
void session;
return [CallsPlugin.with(app.config, null)];
}
beforeSessionStart(app: App<CallsAppConfig>, session: WhatsappSession): void {
const listener = new CallsListener(app, session, this.logger);
listener.attach();
void app;
void session;
return;
}
afterSessionStart(app: App<CallsAppConfig>, session: WhatsappSession): void {
@@ -1,62 +1,29 @@
import { Subscription } from 'rxjs';
import {
CallsAppChannelConfig,
CallsAppConfig,
} from '@waha/apps/calls/dto/config.dto';
import { Logger } from 'pino';
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { PinoLogger } from 'nestjs-pino';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { WAHAEvents, WAHAPresenceStatus } from '@waha/structures/enums.dto';
import { SessionPlugin } from '@waha/core/abc/session.plugin';
import { PluginEvent } from '@waha/core/abc/session.plugin.events';
import { CallData } from '@waha/structures/calls.dto';
import { MessageTextRequest } from '@waha/structures/chatting.dto';
import { WAHAEvents, WAHAPresenceStatus } from '@waha/structures/enums.dto';
import { sleep } from '@waha/utils/promiseTimeout';
import { Observable } from 'rxjs';
export class CallsListener {
private subscription?: Subscription;
private config: CallsAppConfig;
private readonly log: Logger;
private readonly session: WhatsappSession;
constructor(
app: App<CallsAppConfig>,
session: WhatsappSession,
logger: PinoLogger,
) {
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 },
/**
* Rejects incoming calls and optionally sends an auto-reply message.
*/
export class CallsPlugin extends SessionPlugin<CallsAppConfig> {
@PluginEvent(WAHAEvents.CALL_RECEIVED)
onCallReceived(calls$: Observable<CallData>) {
calls$.subscribe((call: CallData) => {
this.handleCall(call).catch((error) => {
this.logger.error(
{ err: error, callId: call?.id },
'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 {
@@ -65,17 +32,17 @@ export class CallsListener {
private async handleCall(call: CallData): Promise<void> {
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;
}
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;
}
const config = this.configFor(call);
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;
}
@@ -84,7 +51,7 @@ export class CallsListener {
const shouldMessage = message.length > 0;
if (!shouldReject && !shouldMessage) {
this.log.debug(
this.logger.debug(
{ callId: call.id, chatId: call.from },
'No actions configured for this call',
);
@@ -105,16 +72,19 @@ export class CallsListener {
}
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);
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(
chatId: string,
message: string,
): Promise<void> {
this.log.info(
this.logger.info(
{ chatId: chatId },
'Sending auto-response for rejected call',
);
@@ -132,7 +102,7 @@ export class CallsListener {
try {
await this.session.setPresence(WAHAPresenceStatus.TYPING, chatId);
} catch (error) {
this.log.warn(
this.logger.warn(
{ err: error, chatId: chatId },
'Failed to set typing presence before reply',
);
@@ -146,7 +116,7 @@ export class CallsListener {
try {
await this.session.setPresence(WAHAPresenceStatus.PAUSED, chatId);
} catch (error) {
this.log.warn(
this.logger.warn(
{ err: error, chatId: chatId },
'Failed to clear typing presence after reply',
);
@@ -1,6 +1,6 @@
import { Controller, Get } from '@nestjs/common';
import { sortBy } from 'lodash';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ApiOperation, ApiSecurity } from '@nestjs/swagger';
import { i18n } from '@waha/apps/chatwoot/i18n';
interface LanguageResponse {
@@ -10,7 +10,6 @@ interface LanguageResponse {
@Controller('api/apps/chatwoot')
@ApiSecurity('api_key')
@ApiTags('🧩 Apps')
export class ChatwootLocalesController {
@Get('locales')
@ApiOperation({
@@ -5,7 +5,7 @@ import {
Param,
Post,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiOperation } from '@nestjs/swagger';
import { IsCommandsChat } from '@waha/apps/chatwoot/client/ids';
import { EventName, MessageType } from '@waha/apps/chatwoot/client/types';
import { InboxData } from '@waha/apps/chatwoot/consumers/types';
@@ -15,7 +15,6 @@ import { AppRepository } from '@waha/apps/app_sdk/storage/AppRepository';
import { CommandPrefix } from '@waha/apps/chatwoot/cli';
@Controller('webhooks/chatwoot/')
@ApiTags('🧩 Apps')
export class ChatwootWebhookController {
constructor(
private readonly chatWootQueueService: ChatWootQueueService,
@@ -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 {
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 { BullModule } from '@nestjs/bullmq';
import { QueueManager } from '@waha/apps/chatwoot/services/QueueManager';
import { HttpPathsRegistration } from '@waha/plugins/http.paths.module';
const CONTROLLERS = [ChatwootWebhookController, ChatwootLocalesController];
const IMPORTS = lodash.flatten([
HttpPathsRegistration({
prefix: '/webhooks/',
include: { authBasic: false },
}),
BullModule.registerFlowProducer({
name: FlowProducerName.MESSAGES_PULL_FLOW,
}),
@@ -160,8 +167,25 @@ const PROVIDERS = [
QueueManager,
];
export const ChatWootExports = {
providers: PROVIDERS,
imports: IMPORTS,
controllers: CONTROLLERS,
const ChatWootAppModule: AppModule = {
name: AppName.chatwoot,
openapi: {
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;
@@ -24,6 +24,21 @@ import { AttributeKey } from '@waha/apps/chatwoot/const';
export interface ContactInfo {
ChatId(): string;
/**
* 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>;
AvatarUrl(): Promise<string | null>;
Attributes(): Promise<any>;
+93 -57
View File
@@ -40,6 +40,18 @@ export function sanitizeName(name: string) {
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 {
constructor(
private config: ChatWootAPIConfig,
@@ -52,8 +64,11 @@ export class ContactService {
contactInfo: ContactInfo,
): Promise<[ContactResponse, boolean]> {
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) {
await this.upsertPhoneNumber(contact, contactInfo);
return [contact, false];
}
@@ -62,73 +77,94 @@ export class ContactService {
return [contact, true];
}
async searchByAnyID(chatId: string): Promise<ContactResponse | null> {
const payload: any[] = [
{
attribute_key: AttributeKey.WA_CHAT_ID,
filter_operator: 'equal_to',
values: [chatId],
attribute_model: 'standard',
custom_attribute_type: '',
query_operator: 'OR',
},
{
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],
});
async search(
chatId: string,
lid: string | null,
jid: string | null,
): Promise<ContactResponse | null> {
// The chat id attribute holds the latest used address, so match any known form there
const chatIds = lodash.uniq(lodash.compact([chatId, jid, lid]));
if (chatIds.length == 0) {
return null;
}
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({
accountId: this.config.accountId,
payload: payload as any,
});
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;
}
const contact = contacts[0];
const inboxes = lodash.filter(contact.contact_inboxes, {
inbox: { id: this.config.inboxId },
});
if (inboxes.length == 0) {
return null;
// Prefer a contact with a phone number over a phone-less duplicate
const withPhone = candidates.find((candidate) =>
Boolean(candidate.data.phone_number),
);
return withPhone ?? candidates[0];
}
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(
+1 -1
View File
@@ -1,7 +1,7 @@
import { AttributeKey, INBOX_CONTACT_CHAT_ID } from '@waha/apps/chatwoot/const';
import * as lodash from 'lodash';
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';
export function GetJID(contact: any): string | null {
@@ -14,6 +14,18 @@ export class InboxContactInfo implements ContactInfo {
return INBOX_CONTACT_CHAT_ID;
}
async LidId(): Promise<string | null> {
return null;
}
async JidId(): Promise<string | null> {
return null;
}
async PhoneNumberE164(): Promise<string | null> {
return null;
}
async AvatarUrl() {
return this.l.key(TKey.APP_INBOX_CONTACT_AVATAR_URL).render();
}
@@ -32,6 +32,18 @@ abstract class ChatContactInfo implements ContactInfo {
return this.chatId;
}
async LidId(): Promise<string | null> {
return null;
}
async JidId(): Promise<string | null> {
return null;
}
async PhoneNumberE164(): Promise<string | null> {
return null;
}
abstract AvatarUrl(): Promise<string | null>;
abstract Attributes(): Promise<any>;
@@ -53,6 +65,19 @@ class JidContactInfo extends ChatContactInfo {
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()
async Attributes() {
const attributes = {
@@ -67,7 +92,9 @@ class JidContactInfo extends ChatContactInfo {
}
async PublicContactCreate(): Promise<Contact> {
const contact: any = await this.session.getContact(this.chatId);
const contact: any = await this.session
.getContact(this.chatId)
.catch(() => null);
const name =
contact?.name || contact?.pushName || contact?.pushname || this.chatId;
const phoneNumberE164 = E164Parser.fromJid(this.chatId);
@@ -110,6 +137,23 @@ class LidContactInfo extends ChatContactInfo {
return new JidContactInfo(this.session, pn, this.locale);
}
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();
}
async AvatarUrl(): Promise<string | null> {
const jid = await this.jid();
if (jid) {
@@ -135,10 +179,15 @@ class LidContactInfo extends ChatContactInfo {
if (jid) {
result = await jid.PublicContactCreate();
} else {
const contact: any = await this.session
.getContact(this.chatId)
.catch(() => null);
const name =
contact?.name || contact?.pushName || contact?.pushname || this.chatId;
result = {
inbox_id: 0,
identifier: this.chatId,
name: this.chatId,
name: name,
};
}
result.custom_attributes = await this.Attributes();
+8
View File
@@ -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;
}
@@ -1,11 +1,13 @@
import { Injectable, UnprocessableEntityException } from '@nestjs/common';
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
import { PluginOptions } from '@waha/core/abc/session.plugin';
import { CacheForConfig } from '@waha/apps/chatwoot/cache/ConversationCache';
import { CHATWOOT_CUSTOM_ATTRIBUTES } from '@waha/apps/chatwoot/const';
import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto';
import { ChatWootScheduleService } from '@waha/apps/chatwoot/services/ChatWootScheduleService';
import { ChatWootWAHAQueueService } from '@waha/apps/chatwoot/services/ChatWootWAHAQueueService';
import { App } from '@waha/apps/chatwoot/storage';
import { AppDB } from '@waha/apps/app_sdk/storage/types';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
@@ -111,6 +113,17 @@ export class ChatWootAppService implements IAppService {
void app;
}
async purge(
manager: SessionManager,
app: App<ChatWootAppConfig>,
): Promise<void> {
const appDb = app as unknown as AppDB;
const knex = manager.store.getWAHADatabase();
const di = new DIContainer(appDb.pk, app.config, this.logger, knex);
await di.MessageMappingService().purge();
this.cleanCache(app);
}
async enrich(
manager: SessionManager,
app: App<ChatWootAppConfig>,
@@ -152,6 +165,15 @@ export class ChatWootAppService implements IAppService {
await conversation.incoming(updated);
}
plugins(
app: App<ChatWootAppConfig>,
session: WhatsappSession,
): PluginOptions[] {
void app;
void session;
return [];
}
beforeSessionStart(app: App<ChatWootAppConfig>, session: WhatsappSession) {
this.chatWootWAHAQueueService.listenEvents(app, session);
}
@@ -69,4 +69,11 @@ export class ChatwootMessageRepository {
.del();
return result;
}
/**
* Deletes all rows for the app.
*/
async deleteAllWithTrx(trx: Knex.Transaction): Promise<number> {
return await trx(this.tableName).where({ app_pk: this.appPk }).delete();
}
}
@@ -90,4 +90,11 @@ export class MessageMappingRepository {
.orderBy('id', 'desc')
.first();
}
/**
* Deletes all rows for the app.
*/
async deleteAllWithTrx(trx: Knex.Transaction): Promise<number> {
return await trx(this.tableName).where({ app_pk: this.appPk }).delete();
}
}
@@ -44,6 +44,26 @@ export class MessageMappingService {
}
}
/**
* Deletes all stored messages and mappings for the app.
*/
async purge(): Promise<number> {
const trx = await this.knex.transaction();
try {
const mappings =
await this.messageMappingRepository.deleteAllWithTrx(trx);
const chatwoot =
await this.chatwootMessageRepository.deleteAllWithTrx(trx);
const whatsapp =
await this.whatsAppMessageRepository.deleteAllWithTrx(trx);
await trx.commit();
return mappings + chatwoot + whatsapp;
} catch (e) {
await trx.rollback();
throw e;
}
}
async map(
chatwoot: ChatwootMessage,
whatsapp: WhatsAppMessage,
@@ -69,4 +69,11 @@ export class WhatsAppMessageRepository {
.del();
return result;
}
/**
* Deletes all rows for the app.
*/
async deleteAllWithTrx(trx: Knex.Transaction): Promise<number> {
return await trx(this.tableName).where({ app_pk: this.appPk }).delete();
}
}
-2
View File
@@ -1,10 +1,8 @@
import { Controller, Post, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import type { Request, Response } from 'express';
import { McpService } from '../mcp.service';
@Controller('mcp')
@ApiTags('🧩 Apps')
export class McpController {
constructor(private readonly mcp: McpService) {}
+34
View File
@@ -0,0 +1,34 @@
import { AppModule } from '@waha/apps/app_sdk/apps/definition';
import { AppName } from '@waha/apps/app_sdk/apps/apps';
import { McpController } from '@waha/apps/mcp/api/mcp.controller';
import { McpService } from '@waha/apps/mcp/mcp.service';
import { McpAppService } from '@waha/apps/mcp/services/McpAppService';
import { HttpPathsRegistration } from '@waha/plugins/http.paths.module';
const McpAppModule: AppModule = {
name: AppName.mcp,
openapi: {
title: 'MCP',
description: 'Model Context Protocol (MCP) server for AI clients',
},
definition: {
plainkey: false,
queue: false,
migrations: false,
restartOnChange: false,
unique: false,
},
nestjs: {
imports: [
HttpPathsRegistration(
{ prefix: '/mcp', include: { authBasic: false } },
{ prefix: 'mcp', include: { authApiKey: true } },
),
],
controllers: [McpController],
providers: [McpService, McpAppService],
},
Service: McpAppService,
};
export default McpAppModule;
+8
View File
@@ -0,0 +1,8 @@
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { McpAppConfig } from '@waha/apps/mcp/dto/config.dto';
import { Type } from 'class-transformer';
export class McpAppDto extends App<McpAppConfig> {
@Type(() => McpAppConfig)
config: McpAppConfig;
}
-9
View File
@@ -1,9 +0,0 @@
import { McpController } from '@waha/apps/mcp/api/mcp.controller';
import { McpService } from '@waha/apps/mcp/mcp.service';
import { McpAppService } from '@waha/apps/mcp/services/McpAppService';
export const McpModuleExports = {
imports: [],
controllers: [McpController],
providers: [McpService, McpAppService],
};
+2
View File
@@ -8,6 +8,7 @@ import { SessionTools } from '@waha/apps/mcp/tools/sessions.tools';
import { ChatTools } from '@waha/apps/mcp/tools/chats.tools';
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { ApiTools } from '@waha/apps/mcp/tools/api.tools';
import { AppsTools } from '@waha/apps/mcp/tools/apps.tools';
import { CallTools } from '@waha/apps/mcp/tools/calls.tools';
import { ChannelTools } from '@waha/apps/mcp/tools/channels.tools';
import { ContactTools } from '@waha/apps/mcp/tools/contacts.tools';
@@ -44,6 +45,7 @@ export class McpService {
new StatusTools(api),
new ServerTools(api),
new KeysTools(api),
new AppsTools(api),
]);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless
+12
View File
@@ -1,6 +1,7 @@
import { Injectable, UnprocessableEntityException } from '@nestjs/common';
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
import { PluginOptions } from '@waha/core/abc/session.plugin';
import { AppRepository } from '@waha/apps/app_sdk/storage/AppRepository';
import { McpAppConfig } from '@waha/apps/mcp/dto/config.dto';
import { SessionManager } from '@waha/core/abc/manager.abc';
@@ -102,6 +103,11 @@ export class McpAppService implements IAppService {
await this.deleteKey(manager, app);
}
async purge(manager: SessionManager, app: App<McpAppConfig>): Promise<void> {
void manager;
void app;
}
async enrich(manager: SessionManager, app: App<McpAppConfig>): Promise<void> {
const keyId = app.config?.key_id;
if (!keyId) {
@@ -114,6 +120,12 @@ export class McpAppService implements IAppService {
app.config = { ...(app.config ?? {}), key: keyDto.key } as McpAppConfig;
}
plugins(app: App<McpAppConfig>, session: WhatsappSession): PluginOptions[] {
void app;
void session;
return [];
}
beforeSessionStart(app: App<McpAppConfig>, session: WhatsappSession): void {
void app;
void session;
+30
View File
@@ -0,0 +1,30 @@
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { McpController } from '@waha/apps/mcp/decorators/controller';
import { Tool } from '@waha/apps/mcp/decorators/tool';
import { AppPurgeInput } from '@waha/apps/mcp/tools/apps.zod';
import { z } from 'zod';
export class AppsTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('apps-purge', {
title: 'Purge app storage',
description:
"Purge an app's stored data (messages, caches) for a session. " +
'The app itself stays configured and keeps working.',
inputSchema: AppPurgeInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
},
})
async purge({ app, session }: z.infer<typeof AppPurgeInput>) {
return this.textRequest({
method: 'POST',
url: `/api/apps/${app}/${session}/purge`,
});
}
}
+7
View File
@@ -0,0 +1,7 @@
import { AppName } from '@waha/apps/app_sdk/apps/apps';
import { z } from 'zod';
export const AppPurgeInput = z.object({
app: z.nativeEnum(AppName).describe('App name'),
session: z.string().describe('Session name'),
});
+4 -1
View File
@@ -107,7 +107,10 @@ export class ChannelTools extends McpController {
return this.textRequest({
method: 'GET',
url: `/api/${session}/channels/${id}/messages/preview`,
params: query,
params: {
...query,
downloadMediaMimetypes: query.downloadMediaMimetypes?.join(','),
},
});
}
+10 -3
View File
@@ -145,7 +145,8 @@ export class ChatTools extends McpController {
description:
'Get messages in a chat. ' +
'To retrieve all messages, paginate by incrementing the offset by limit until the returned array is empty or shorter than the limit. ' +
'To fetch media for a specific message, use chats-get-message with that message id and downloadMedia=true instead of fetching it here.',
'To fetch media for a specific message, use chats-get-message with that message id and downloadMedia=true instead of fetching it here. ' +
'Use downloadMediaMimetypes to download only media with the given mimetypes (prefix match).',
inputSchema: ChatMessagesInput,
annotations: {
readOnlyHint: true,
@@ -162,7 +163,10 @@ export class ChatTools extends McpController {
const response = await this.request({
method: 'GET',
url: `/api/${session}/chats/${chatId}/messages`,
params: query,
params: {
...query,
downloadMediaMimetypes: query.downloadMediaMimetypes?.join(','),
},
});
let messages = response.data;
if (!_data && Array.isArray(messages)) {
@@ -229,7 +233,10 @@ export class ChatTools extends McpController {
const result = await this.textRequest({
method: 'GET',
url: `/api/${session}/chats/${chatId}/messages/${messageId}`,
params: query,
params: {
...query,
downloadMediaMimetypes: query.downloadMediaMimetypes?.join(','),
},
});
if (query.downloadMedia) {
const mediaKey = await this.mediaApiKey(session);
+103
View File
@@ -8,6 +8,7 @@ import {
GroupDescriptionInput,
GroupIdInput,
GroupJoinInput,
GroupMembershipApprovalInput,
GroupParticipantsInput,
GroupPictureInput,
GroupsListInput,
@@ -363,6 +364,108 @@ export class GroupTools extends McpController {
});
}
@Tool('groups-get-membership-approval', {
title: 'Get membership approval setting',
description:
'Get whether admin approval is required for users requesting to join the group',
inputSchema: GroupIdInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getMembershipApproval({ session, id }: z.infer<typeof GroupIdInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/groups/${id}/settings/security/membership-approval`,
});
}
@Tool('groups-set-membership-approval', {
title: 'Set membership approval setting',
description:
'Enable or disable admin approval for users requesting to join the group',
inputSchema: GroupMembershipApprovalInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async setMembershipApproval({
session,
id,
...body
}: z.infer<typeof GroupMembershipApprovalInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/${session}/groups/${id}/settings/security/membership-approval`,
data: body,
});
}
@Tool('groups-get-join-requests', {
title: 'Get pending requests to join the group',
description: 'Get pending requests to join the group',
inputSchema: GroupIdInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getJoinRequests({ session, id }: z.infer<typeof GroupIdInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/groups/${id}/participants/join-requests`,
});
}
@Tool('groups-approve-join-requests', {
title: 'Approve pending requests to join the group',
description: 'Approve pending requests to join the group',
inputSchema: GroupParticipantsInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async approveJoinRequests({
session,
id,
...body
}: z.infer<typeof GroupParticipantsInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/groups/${id}/participants/join-requests/approve`,
data: body,
});
}
@Tool('groups-reject-join-requests', {
title: 'Reject pending requests to join the group',
description: 'Reject pending requests to join the group',
inputSchema: GroupParticipantsInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async rejectJoinRequests({
session,
id,
...body
}: z.infer<typeof GroupParticipantsInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/groups/${id}/participants/join-requests/reject`,
data: body,
});
}
@Tool('groups-get-invite-code', {
title: 'Get group invite code',
description: 'Get the invite code for a group',
+8
View File
@@ -7,6 +7,7 @@ import {
GroupsPaginationParams,
JoinGroupRequest,
ParticipantsRequest,
SettingsMembershipApproval,
SettingsSecurityChangeInfo,
SubjectRequest,
} from '@waha/structures/groups.dto';
@@ -64,3 +65,10 @@ export const GroupParticipantsInput = DtoToZod(ParticipantsRequest).extend({
session: SessionField,
id: GroupIdField,
});
export const GroupMembershipApprovalInput = DtoToZod(
SettingsMembershipApproval,
).extend({
session: SessionField,
id: GroupIdField,
});
+21
View File
@@ -17,6 +17,7 @@ import {
SendPollInput,
SendPollVoteInput,
SendSeenInput,
SendStickerInput,
SendTextInput,
SendVideoInput,
SendVoiceInput,
@@ -126,6 +127,26 @@ export class SendTools extends McpController {
});
}
@Tool('send-sticker', {
title: 'Send a sticker',
description:
'Send a sticker to a chat. The file must already be in WebP format. ' +
FileNote,
inputSchema: SendStickerInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendSticker(data: z.infer<typeof SendStickerInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sendSticker',
data: data,
});
}
@Tool('send-link-custom-preview', {
title: 'Send a text message with a custom link preview',
description:
+2
View File
@@ -13,6 +13,7 @@ import {
MessagePollVoteRequest,
MessageReactionRequest,
MessageStarRequest,
MessageStickerRequest,
MessageTextRequest,
MessageVideoRequest,
MessageVoiceRequest,
@@ -27,6 +28,7 @@ export const SendImageInput = DtoToZod(MessageImageRequest);
export const SendFileInput = DtoToZod(MessageFileRequest);
export const SendVoiceInput = DtoToZod(MessageVoiceRequest);
export const SendVideoInput = DtoToZod(MessageVideoRequest);
export const SendStickerInput = DtoToZod(MessageStickerRequest);
export const SendLinkCustomPreviewInput = DtoToZod(
MessageLinkCustomPreviewRequest,
);
+6 -3
View File
@@ -5,6 +5,7 @@ import { Tool } from '@waha/apps/mcp/decorators/tool';
import {
SessionCreateInput,
SessionListInput,
SessionLogoutInput,
SessionNameInput,
SessionUpdateInput,
} from '@waha/apps/mcp/tools/sessions.zod';
@@ -140,18 +141,20 @@ export class SessionTools extends McpController {
@Tool('sessions-logout', {
title: 'Logout session',
description: 'Logout from a WhatsApp session',
inputSchema: SessionNameInput,
description:
'Logout from a WhatsApp session. Optionally purge app storage via apps.purge.',
inputSchema: SessionLogoutInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async logout({ session }: z.infer<typeof SessionNameInput>) {
async logout({ session, ...body }: z.infer<typeof SessionLogoutInput>) {
return this.textRequest({
method: 'POST',
url: `/api/sessions/${session}/logout`,
data: body,
});
}
+5
View File
@@ -4,6 +4,7 @@ import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import {
ListSessionsQuery,
SessionCreateRequest,
SessionLogoutRequest,
SessionUpdateRequest,
} from '@waha/structures/sessions.dto';
@@ -18,3 +19,7 @@ export const SessionCreateInput = DtoToZod(SessionCreateRequest);
export const SessionUpdateInput = DtoToZod(SessionUpdateRequest).extend({
session: z.string().describe('Session name'),
});
export const SessionLogoutInput = DtoToZod(SessionLogoutRequest).extend({
session: z.string().describe('Session name'),
});
+46 -26
View File
@@ -1,20 +1,18 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GlobalWebhookConfigConfig } from '@waha/core/config/GlobalWebhookConfig';
import { MediaDownloadOptions } from '@waha/core/media/IMediaManager';
import { IgnoreJidConfig } from '@waha/core/utils/jids';
import * as lodash from 'lodash';
import { parseBool } from './helpers';
import { WebhookConfig } from './structures/webhooks.config.dto';
import { Auth } from '@waha/core/auth/config';
@Injectable()
export class WhatsappConfigService implements OnApplicationBootstrap {
export class WhatsappConfigService {
private logger: Logger;
private webhookConfig: GlobalWebhookConfigConfig;
constructor(private configService: ConfigService) {
this.logger = new Logger('WhatsappConfigService');
this.webhookConfig = new GlobalWebhookConfigConfig(configService);
}
get schema() {
@@ -63,17 +61,50 @@ export class WhatsappConfigService implements OnApplicationBootstrap {
}
}
get mimetypes(): string[] {
if (!this.shouldDownloadMedia) {
return ['mimetype/ignore-all-media'];
}
const types = this.configService.get('WHATSAPP_FILES_MIMETYPES', '');
return types ? types.split(',') : [];
private get mediaGlobalParams() {
return {
download: this.configService.get('WHATSAPP_DOWNLOAD_MEDIA', 'true'),
mimetypes: this.configService.get('WHATSAPP_FILES_MIMETYPES', ''),
};
}
get shouldDownloadMedia(): boolean {
const value = this.configService.get('WHATSAPP_DOWNLOAD_MEDIA', 'true');
return parseBool(value);
private get mediaApiConfig(): MediaDownloadOptions {
const params = {
download: this.configService.get('WAHA_API_DOWNLOAD_MEDIA'),
mimetypes: this.configService.get('WAHA_API_DOWNLOAD_MEDIA_MIMETYPES'),
};
const config = lodash.defaults({}, params, this.mediaGlobalParams);
return this.parseMediaDownloadOptions(config);
}
private get mediaEventsConfig(): MediaDownloadOptions {
const params = {
download: this.configService.get('WAHA_EVENTS_DOWNLOAD_MEDIA'),
mimetypes: this.configService.get('WAHA_EVENTS_DOWNLOAD_MEDIA_MIMETYPES'),
};
const config = lodash.defaults({}, params, this.mediaGlobalParams);
return this.parseMediaDownloadOptions(config);
}
private parseMediaDownloadOptions(config: {
download: string;
mimetypes: string;
}): MediaDownloadOptions {
const types = config.mimetypes;
const mimetypes = types
? types.split(',').map((type: string) => type.trim())
: [];
return {
download: parseBool(config.download),
mimetypes: mimetypes,
};
}
get mediaConfig() {
return {
api: this.mediaApiConfig,
events: this.mediaEventsConfig,
};
}
get startSessions(): string[] {
@@ -120,10 +151,6 @@ export class WhatsappConfigService implements OnApplicationBootstrap {
return this.configService.get('WHATSAPP_PROXY_SERVER_PASSWORD', undefined);
}
getWebhookConfig(): WebhookConfig | undefined {
return this.webhookConfig.config;
}
getSessionMongoUrl(): string | undefined {
return this.configService.get('WHATSAPP_SESSIONS_MONGO_URL', undefined);
}
@@ -201,11 +228,4 @@ export class WhatsappConfigService implements OnApplicationBootstrap {
);
return { status, groups, channels, broadcast };
}
onApplicationBootstrap() {
const error = this.webhookConfig.validateConfig();
if (error) {
throw new Error(`Invalid global webhook config:\n${error}\n`);
}
}
}
+79 -112
View File
@@ -1,7 +1,8 @@
import { MessageCappingTracker } from '@waha/core/abc/MessageCappingTracker';
import { ReachoutTimelockTracker } from '@waha/core/abc/ReachoutTimelockTracker';
import { getBrowserExecutablePath as getBrowserExecutablePathAutodetect } from '@waha/core/abc/session.browser';
import { IMediaConverter } from '@waha/core/media/IConverter';
import { PluginRegistry } from '@waha/core/abc/session.plugin.registry';
import { IMediaConverter } from '@waha/core/media/IMediaConverter';
import { Ffmpeg } from '@waha/core/utils/ffmpeg';
import { MessagesForRead } from '@waha/core/utils/convertors';
import {
@@ -37,7 +38,7 @@ import { BinaryFile, RemoteFile } from '@waha/structures/files.dto';
import { Label, LabelDTO, LabelID } from '@waha/structures/labels.dto';
import { LidToPhoneNumber } from '@waha/structures/lids.dto';
import { PaginationParams } from '@waha/structures/pagination.dto';
import { MessageSource, WAMessage } from '@waha/structures/responses.dto';
import { WAMessage } from '@waha/structures/responses.dto';
import { BrowserTraceQuery } from '@waha/structures/server.debug.dto';
import { DefaultMap } from '@waha/utils/DefaultMap';
import { generatePrefixedId } from '@waha/utils/ids';
@@ -86,6 +87,7 @@ import {
MessageReactionRequest,
MessageReplyRequest,
MessageStarRequest,
MessageStickerRequest,
MessageTextRequest,
MessageVideoRequest,
MessageVoiceRequest,
@@ -106,10 +108,13 @@ import { EventMessageRequest } from '../../structures/events.dto';
import {
CreateGroupRequest,
GroupField,
GroupJoinRequest,
GroupJoinRequestResult,
GroupParticipant,
GroupsListFields,
ParticipantsRequest,
SettingsMemberAddMode,
SettingsMembershipApproval,
SettingsSecurityChangeInfo,
} from '../../structures/groups.dto';
import { WAHAChatPresences } from '../../structures/presence.dto';
@@ -120,6 +125,7 @@ import {
ProxyConfig,
ReachoutTimelockData,
SessionConfig,
SessionInfo,
} from '../../structures/sessions.dto';
import {
DeleteStatusRequest,
@@ -136,15 +142,11 @@ import {
AvailableInPlusVersion,
NotImplementedByEngineError,
} from '../exceptions';
import { IMediaManager } from '../media/IMediaManager';
import { IMediaManager, MediaDownloadOptions } from '../media/IMediaManager';
import { QR } from '../QR';
import { DataStore } from './DataStore';
import { fetchBuffer } from '@waha/utils/fetch';
import {
PRESENCE_AUTO_ONLINE,
PRESENCE_AUTO_ONLINE_DURATION_SECONDS,
} from '@waha/core/env';
import { Activity } from '@waha/core/abc/activity';
import { SessionHooks } from './session.hooks';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const qrcode = require('qrcode-terminal');
@@ -159,6 +161,11 @@ export function ensureSuffix(phone) {
return phone + suffix;
}
interface MediaConfig {
api: MediaDownloadOptions;
events: MediaDownloadOptions;
}
export interface SessionParams {
name: string;
printQR: boolean;
@@ -171,6 +178,8 @@ export interface SessionParams {
engineConfig?: any;
// Ignore settings
ignore: IgnoreJidConfig;
// Media config
media: MediaConfig;
}
/**
@@ -196,6 +205,7 @@ export abstract class WhatsappSession {
protected sessionStore: DataStore;
protected proxyConfig?: ProxyConfig;
public sessionConfig?: SessionConfig;
protected media: MediaConfig;
protected engineConfig?: any;
protected unpairing: boolean = false;
protected jids: JidFilter;
@@ -208,11 +218,6 @@ export abstract class WhatsappSession {
| WAHAPresenceStatus.ONLINE
| WAHAPresenceStatus.OFFLINE
| null = null;
private lastActivityTimestamp?: number;
protected presenceAutoOnlineConfig = {
enabled: PRESENCE_AUTO_ONLINE,
duration: PRESENCE_AUTO_ONLINE_DURATION_SECONDS * 1000,
};
private shouldPrintQR: boolean;
protected events2: DefaultMap<WAHAEvents, SwitchObservable<any>>;
@@ -221,15 +226,9 @@ export abstract class WhatsappSession {
stdTTL: 24 * 60 * 60, // 1 day
});
// Save sent messages ids in cache so we can determine if a message was sent
// via API or APP
private sentMessageIds: NodeCache = new NodeCache({
stdTTL: 10 * 60, // 10 minutes
});
private presenceOfflineTimeout?: ReturnType<typeof setTimeout>;
public mediaConverter: IMediaConverter;
public hooks: SessionHooks;
public plugins: PluginRegistry;
public constructor({
name,
@@ -241,14 +240,17 @@ export abstract class WhatsappSession {
sessionConfig,
engineConfig,
ignore,
media,
}: SessionParams) {
this._status = WAHASessionStatus.STOPPED;
this.status$ = new Subject<SessionStatusUpdate>();
this.name = name;
this.proxyConfig = proxyConfig;
this.loggerBuilder = loggerBuilder;
this.logger = loggerBuilder.child({ name: 'WhatsappSession' });
this.hooks = new SessionHooks();
this.plugins = new PluginRegistry(this);
this.mediaConverter = new Ffmpeg(this.name, this.logger);
this.reachoutTimelock = new ReachoutTimelockTracker(this.logger);
this.reachoutTimelock.changes$.subscribe((timelock) => {
@@ -359,6 +361,17 @@ export abstract class WhatsappSession {
'The session ignores the following chat ids',
);
this.jids = new JidFilter(ignore);
//
// Media options
//
this.media = media;
const mimetypes = this.media.events.mimetypes;
if (mimetypes && mimetypes.length > 0) {
const str = mimetypes.join(',');
const msg = `Only '${str}' mimetypes will be downloaded for the session`;
this.logger.info(msg);
}
}
public getEventObservable(event: WAHAEvents) {
@@ -414,7 +427,7 @@ export abstract class WhatsappSession {
return this._statusData;
}
protected set presence(value: WAHAPresenceStatus) {
public set presence(value: WAHAPresenceStatus) {
switch (value) {
case null:
this._presence = null;
@@ -569,6 +582,14 @@ export abstract class WhatsappSession {
return null;
}
/**
* Builds the runtime session info via the "session.info" hook.
*/
public getSessionInfo(): Promise<SessionInfo> {
const info = {} as SessionInfo;
return this.hooks.session.info.promise(info);
}
/**
* Profile methods
*/
@@ -665,6 +686,10 @@ export abstract class WhatsappSession {
throw new NotImplementedByEngineError();
}
sendSticker(request: MessageStickerRequest) {
throw new NotImplementedByEngineError();
}
sendButtons(request: SendButtonsRequest) {
throw new NotImplementedByEngineError();
}
@@ -685,75 +710,6 @@ export abstract class WhatsappSession {
abstract stopTyping(chat: ChatRequest);
/**
* Activity tracking and presence management
*/
/**
* Returns the timestamp of the last "activity" in the session
* @returns Timestamp in milliseconds or undefined if there was never any activity
*/
public getLastActivityTimestamp(): number | undefined {
return this.lastActivityTimestamp;
}
/**
* Maintains ONLINE presence active while there is activity
* Resets the timer on each activity, only goes OFFLINE after Xs without activity
*/
async maintainPresenceOnline(): Promise<void> {
if (!this.presenceAutoOnlineConfig.enabled) {
return;
}
if (this.status !== WAHASessionStatus.WORKING) {
return;
}
this.lastActivityTimestamp = Date.now();
// If not ONLINE yet, send ONLINE
if (this._presence !== WAHAPresenceStatus.ONLINE) {
try {
// Force set ONLINE in case of many requests comes at the same time
// So we'll set ONLINE exactly once
this.presence = WAHAPresenceStatus.ONLINE;
await this.setPresence(WAHAPresenceStatus.ONLINE);
this.logger.debug('Set presence to ONLINE due to activity');
} catch (error) {
this.logger.debug('Failed to set presence ONLINE', error);
return;
}
}
// Cancel the previous timeout (if exists)
this.cleanupPresenceTimeout();
// Schedule to go back OFFLINE after timeout without activity
this.presenceOfflineTimeout = setTimeout(async () => {
try {
const working = this.status === WAHASessionStatus.WORKING;
const online = this.presence === WAHAPresenceStatus.ONLINE;
if (!working || !online) {
// Nothing to do
return;
}
await this.setPresence(WAHAPresenceStatus.OFFLINE);
this.logger.debug(
'Auto-set presence to OFFLINE after time without activity',
);
} catch (error) {
this.presence = WAHAPresenceStatus.OFFLINE;
this.logger.debug('Failed to set presence OFFLINE', error);
}
this.cleanupPresenceTimeout();
}, this.presenceAutoOnlineConfig.duration);
}
/**
* Cleans up the timeout when the session stops
*/
protected cleanupPresenceTimeout() {
clearTimeout(this.presenceOfflineTimeout);
this.presenceOfflineTimeout = null;
}
abstract setReaction(request: MessageReactionRequest);
setStar(request: MessageStarRequest): Promise<void> {
@@ -1069,6 +1025,37 @@ export abstract class WhatsappSession {
throw new NotImplementedByEngineError();
}
public getMembershipApprovalMode(
id: string,
): Promise<SettingsMembershipApproval> {
throw new NotImplementedByEngineError();
}
public setMembershipApprovalMode(
id: string,
value: boolean,
): Promise<boolean> {
throw new NotImplementedByEngineError();
}
public getGroupJoinRequests(id: string): Promise<GroupJoinRequest[]> {
throw new NotImplementedByEngineError();
}
public approveGroupJoinRequests(
id: string,
request: ParticipantsRequest,
): Promise<GroupJoinRequestResult[]> {
throw new NotImplementedByEngineError();
}
public rejectGroupJoinRequests(
id: string,
request: ParticipantsRequest,
): Promise<GroupJoinRequestResult[]> {
throw new NotImplementedByEngineError();
}
public deleteGroup(id) {
throw new NotImplementedByEngineError();
}
@@ -1263,14 +1250,6 @@ export abstract class WhatsappSession {
* END - Methods for API
*/
/**
* Add WhatsApp suffix (@c.us) to the phone number if it doesn't have it yet
* @param phone
*/
protected ensureSuffix(phone) {
return ensureSuffix(phone);
}
protected deserializeId(messageId: string): MessageId {
const parts = messageId.split('_');
return {
@@ -1297,18 +1276,6 @@ export abstract class WhatsappSession {
qrcode.generate(qr.raw, { small: true });
}
protected saveSentMessageId(id: string) {
this.sentMessageIds.set(id, true);
}
protected getMessageSource(id: string): MessageSource {
if (!id) {
return MessageSource.APP;
}
const api = this.sentMessageIds.has(id);
return api ? MessageSource.API : MessageSource.APP;
}
/**
* Fetches the content from the specified URL and returns it as a Buffer.
*/
@@ -1,8 +1,7 @@
import type { WhatsappSession } from '@waha/core/abc/session.abc';
/**
* Decorator to mark a method as an activity that
* keeps the WhatsApp session online.
* Decorator to call "activity" hook
* @constructor
*/
export function Activity() {
@@ -16,7 +15,7 @@ export function Activity() {
this: WhatsappSession,
...args: Parameters<T>
): Promise<ReturnType<T>> {
await this.maintainPresenceOnline();
await this.hooks.activity.promise(String(propertyKey));
return await original.apply(this, args);
} as T;
+64
View File
@@ -0,0 +1,64 @@
import { MessageSource } from '@waha/structures/responses.dto';
import { SessionInfo } from '@waha/structures/sessions.dto';
import {
AsyncSeriesBailHook,
AsyncSeriesHook,
AsyncSeriesWaterfallHook,
SyncHook,
} from 'tapable';
/**
* Well-known stages for hook taps. Lower stage runs earlier, taps without a stage run in between (default stage is 0).
*/
export const Stage = {
FIRST: -100,
LAST: 100,
};
export class SessionHooks {
/**
* Called before an engine method that makes a network call to WhatsApp.
* method - the name of the method about to run.
*/
readonly activity = new AsyncSeriesHook<[string]>(['method'], 'activity');
readonly session = Object.freeze({
/**
* Builds the runtime session info (waterfall) - starts with an empty object, each tap merges its part in.
* Called when the API reports session state, e.g. GET /api/sessions.
*/
info: new AsyncSeriesWaterfallHook<[SessionInfo]>(['info'], 'session.info'),
});
/**
* Converts a WhatsApp identifier (wid) supplied by the API caller into the form the engine addresses.
* "wid.chat" converts the chat target, "wid.mention" converts a mention entry.
* method - the engine method the wid is being converted for, e.g. 'sendText'.
*/
readonly wid = Object.freeze({
chat: new AsyncSeriesWaterfallHook<[string, string]>(
['wid', 'method'],
'wid.chat',
),
mention: new AsyncSeriesWaterfallHook<[string, string]>(
['wid', 'method'],
'wid.mention',
),
});
readonly message = Object.freeze({
/**
* Called with the message id when the session sends a message via API.
* Fire and forget - return values are ignored.
*/
sent: new SyncHook<[string]>(['id'], 'message.sent'),
/**
* Resolves the MessageSource (api/app) for a message id. The first tap returning a non-undefined result wins,
* if all return undefined - callers fall back to MessageSource.APP.
*/
source: new AsyncSeriesBailHook<[string], MessageSource | undefined>(
['id'],
'message.source',
),
});
}
+62
View File
@@ -0,0 +1,62 @@
import type { SessionPlugin } from '@waha/core/abc/session.plugin';
import { WAHAEvents } from '@waha/structures/enums.dto';
import { Observable } from 'rxjs';
type EventFn = (events$: Observable<any>) => void;
interface EventSubscriptionMetadata {
event: WAHAEvents;
propertyKey: string;
}
const EVENT_SUBSCRIPTIONS = Symbol('PluginEventSubscriptions');
function getOwnEventSubscriptions(ctor: any): EventSubscriptionMetadata[] {
if (!Object.prototype.hasOwnProperty.call(ctor, EVENT_SUBSCRIPTIONS)) {
ctor[EVENT_SUBSCRIPTIONS] = [];
}
return ctor[EVENT_SUBSCRIPTIONS];
}
function collectEventSubscriptions(ctor: any): EventSubscriptionMetadata[] {
const subscriptions: EventSubscriptionMetadata[] = [];
let current = ctor;
while (current) {
if (Object.prototype.hasOwnProperty.call(current, EVENT_SUBSCRIPTIONS)) {
subscriptions.push(...current[EVENT_SUBSCRIPTIONS]);
}
current = Object.getPrototypeOf(current);
}
return subscriptions;
}
/**
* Passes the session event observable to the decorated (public) method once, the method subscribes itself:
*
* @PluginEvent(WAHAEvents.SESSION_STATUS)
* onSessionStatus(events$: Observable<WASessionStatusBody>) {
* events$.subscribe({ next: ..., complete: ... });
* }
*/
export function PluginEvent(event: WAHAEvents) {
return function <K extends string, T extends Record<K, EventFn>>(
target: T,
propertyKey: K,
descriptor: PropertyDescriptor,
): void {
getOwnEventSubscriptions(target.constructor).push({
event: event,
propertyKey: propertyKey,
});
};
}
/**
* Calls all @PluginEvent methods of the plugin's class with the session event observables.
*/
export function RegisterPluginEvents(plugin: SessionPlugin<any, any>) {
for (const meta of collectEventSubscriptions(plugin.constructor)) {
const method = (plugin as any)[meta.propertyKey].bind(plugin);
method(plugin.session.getEventObservable(meta.event));
}
}
+129
View File
@@ -0,0 +1,129 @@
import { SessionHooks } from '@waha/core/abc/session.hooks';
import type { SessionPlugin } from '@waha/core/abc/session.plugin';
import {
SyncBailHook,
SyncHook,
SyncLoopHook,
SyncWaterfallHook,
} from 'tapable';
/**
* Which tapable method the tap is registered with.
*/
export enum TapType {
/** tap() for sync hooks, tapPromise() for async ones - the default */
Auto = 'auto',
/** tap() - the method runs synchronously, its return value is used as is */
Sync = 'sync',
/** tapPromise() - the method result is awaited (works for sync methods too) */
Promise = 'promise',
}
/**
* Tapable tap options (tapable does not export its TapOptions type) and the tap type.
*/
export interface HookTapOptions {
before?: string;
stage?: number;
type?: TapType;
}
/**
* The method signature a hook accepts, sync or async - inferred from the hook's tap() method.
*/
type HookFn<H> = H extends {
tap(options: any, fn: (...args: infer A) => infer R): void;
}
? (...args: A) => R | Promise<R>
: never;
type TapableHook = {
tap(options: any, fn: any): void;
};
interface HookTapMetadata {
selector: (hooks: SessionHooks) => TapableHook;
propertyKey: string;
options?: HookTapOptions;
}
const HOOK_TAPS = Symbol('PluginHookTaps');
function getOwnHookTaps(ctor: any): HookTapMetadata[] {
if (!Object.prototype.hasOwnProperty.call(ctor, HOOK_TAPS)) {
ctor[HOOK_TAPS] = [];
}
return ctor[HOOK_TAPS];
}
function collectHookTaps(ctor: any): HookTapMetadata[] {
const taps: HookTapMetadata[] = [];
let current = ctor;
while (current) {
if (Object.prototype.hasOwnProperty.call(current, HOOK_TAPS)) {
taps.push(...current[HOOK_TAPS]);
}
current = Object.getPrototypeOf(current);
}
return taps;
}
// identity check, not instanceof - tapable reassigns hook.constructor while all hooks share the base Hook prototype
const SYNC_HOOK_CLASSES: any[] = [
SyncHook,
SyncBailHook,
SyncLoopHook,
SyncWaterfallHook,
];
function isSyncHook(hook: any): boolean {
return SYNC_HOOK_CLASSES.includes(hook.constructor);
}
function useSyncTap(type: TapType, hook: any): boolean {
if (type === TapType.Sync) {
return true;
}
if (type === TapType.Promise) {
return false;
}
return isSyncHook(hook);
}
/**
* Taps the decorated (public) method into a session hook, stackable:
* @PluginHook((hooks) => hooks.wid.chat, { stage: Stage.FIRST })
*/
export function PluginHook<H extends TapableHook>(
selector: (hooks: SessionHooks) => H,
options?: HookTapOptions,
) {
return function <K extends string, T extends Record<K, HookFn<H>>>(
target: T,
propertyKey: K,
descriptor: PropertyDescriptor,
): void {
getOwnHookTaps(target.constructor).push({
selector: selector,
propertyKey: propertyKey,
options: options,
});
};
}
/**
* Applies all @PluginHook taps of the plugin's class to its session hooks, named after the plugin class.
*/
export function RegisterPluginHooks(plugin: SessionPlugin<any, any>) {
for (const meta of collectHookTaps(plugin.constructor)) {
const hook: any = meta.selector(plugin.session.hooks);
const { type, ...tapOptions } = meta.options ?? {};
const options = { name: plugin.constructor.name, ...tapOptions };
const method = (plugin as any)[meta.propertyKey].bind(plugin);
if (useSyncTap(type ?? TapType.Auto, hook)) {
hook.tap(options, method);
} else {
hook.tapPromise(options, async (...args: any[]) => method(...args));
}
}
}
+68
View File
@@ -0,0 +1,68 @@
import type { WhatsappSession } from './session.abc';
import { PluginOptions, SessionPlugin } from './session.plugin';
import { RegisterPluginEvents } from './session.plugin.events';
import { RegisterPluginHooks } from './session.plugin.hooks';
interface PluginEntry {
id: string | null;
plugin: SessionPlugin<any, any>;
}
export class PluginRegistry {
private entries: PluginEntry[] = [];
constructor(private readonly session: WhatsappSession) {}
/**
* Construct the plugin with the session and a child logger, and store it. Hooks and events are registered later, in attach().
* id distinguishes multiple instances of the same plugin class (e.g. apps pass their app id) and enables get(Class, id).
*/
add<Plugin extends SessionPlugin<Config, Deps>, Config, Deps = null>(
options: PluginOptions<Plugin, Config, Deps>,
id?: string,
): Plugin {
const PluginClass = options.plugin;
const logger = this.session.loggerBuilder.child({
plugin: PluginClass.name,
});
const plugin = new PluginClass(
this.session,
logger,
options.config,
options.deps,
);
this.entries.push({ id: id ?? null, plugin: plugin });
return plugin;
}
/**
* The first plugin matching the class (instanceof), and the id when provided.
*/
get<Plugin extends SessionPlugin<any, any>>(
PluginClass: abstract new (...args: any[]) => Plugin,
id?: string,
): Plugin | null {
for (const entry of this.entries) {
if (!(entry.plugin instanceof PluginClass)) {
continue;
}
if (id != null && entry.id !== id) {
continue;
}
return entry.plugin;
}
return null;
}
/**
* Register @PluginHook and @PluginEvent handlers for all plugins and run their attach().
* Deferred until all plugins (core, engine, apps) are added.
*/
attach(): void {
for (const entry of this.entries) {
RegisterPluginHooks(entry.plugin);
RegisterPluginEvents(entry.plugin);
entry.plugin.attach();
}
}
}
+59
View File
@@ -0,0 +1,59 @@
import type { WhatsappSession } from '@waha/core/abc/session.abc';
import { Logger } from 'pino';
/**
* Constructor contract for session plugins - SessionPlugin (and any subclass) conforms to it: (session, logger, config, deps).
* deps holds extra plugin-specific dependencies (e.g. { repository }); plugins without dependencies just omit the parameter.
*/
export interface SessionPluginConstructor<
Plugin extends SessionPlugin<Config, Deps>,
Config,
Deps = null,
> {
new (
session: WhatsappSession,
logger: Logger,
config: Config,
deps: Deps,
): Plugin;
}
/**
* A plugin class with its config and deps bound, ready to be constructed with a session and logger -
* build it with Plugin.with(config, deps). config and deps are always explicit - pass null when the plugin has none.
*/
export interface PluginOptions<
Plugin extends SessionPlugin<Config, Deps> = SessionPlugin<any, any>,
Config = any,
Deps = any,
> {
plugin: SessionPluginConstructor<Plugin, Config, Deps>;
config: NoInfer<Config>;
deps: NoInfer<Deps>;
}
export abstract class SessionPlugin<Config = null, Deps = null> {
constructor(
public readonly session: WhatsappSession,
protected logger: Logger,
protected config: Config,
protected deps: Deps,
) {}
/**
* Override for custom wiring (event subscriptions, extra hooks); keep constructors construction-only.
*/
attach(): void {}
/**
* Bind config and deps to the plugin class, type-checked against its constructor.
* Whoever registers the plugin supplies session and logger.
*/
static with<Plugin extends SessionPlugin<Config, Deps>, Config, Deps = null>(
this: SessionPluginConstructor<Plugin, Config, Deps>,
config: NoInfer<Config>,
deps: NoInfer<Deps>,
): PluginOptions<Plugin, Config, Deps> {
return { plugin: this, config: config, deps: deps };
}
}
+86 -68
View File
@@ -5,7 +5,6 @@ import { Provider } from '@nestjs/common/interfaces/modules/provider.interface';
import { ConditionalModule, ConfigModule } from '@nestjs/config';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { PassportModule } from '@nestjs/passport';
import { ServeStaticModule } from '@nestjs/serve-static';
import { TerminusModule } from '@nestjs/terminus';
import { ChannelsController } from '@waha/api/channels.controller';
import { LidsController } from '@waha/api/lids.controller';
@@ -19,7 +18,6 @@ import { ContactsSessionController } from '@waha/api/contacts.session.controller
import { ApiKeyStrategy } from '@waha/core/auth/apiKey.strategy';
import { IApiKeyAuth } from '@waha/core/auth/auth';
import { ApiKeyAuthMiddleware } from '@waha/core/auth/api-key-auth.middleware';
import { BasicAuthFunction } from '@waha/core/auth/basicAuth';
import { WebSocketAuth } from '@waha/core/auth/WebSocketAuth';
import { GowsEngineConfigService } from '@waha/core/config/GowsEngineConfigService';
import { NowebEngineConfigService } from '@waha/core/config/NowebEngineConfigService';
@@ -29,6 +27,24 @@ import { MediaLocalStorageModule } from '@waha/core/media/local/media.local.stor
import { MediaLocalStorageConfig } from '@waha/core/media/local/MediaLocalStorageConfig';
import { MediaPsqlStorageModule } from '@waha/core/media/psql/media.psql.storage.module';
import { MediaS3StorageModule } from '@waha/core/media/s3/media.s3.storage.module';
import { HttpPathsModule } from '@waha/plugins/http.paths.module';
import { HttpPathsService } from '@waha/plugins/HttpPathsService';
import { AppBootstrapModule } from '@waha/plugins/app.bootstrap.module';
import { SessionPluginsModule } from '@waha/plugins/session.plugins.module';
import { isDashboardEnabled } from '@waha/modules/waha-dashboard/dashboard.config';
import { DashboardModule } from '@waha/modules/waha-dashboard/dashboard.module';
import { isSwaggerEnabled } from '@waha/modules/waha-swagger/swagger.config';
import { SwaggerEnabledModule } from '@waha/modules/waha-swagger/swagger.module.enabled';
import { isPresenceAutoOnlineEnabled } from '@waha/modules/waha-maintain-online-status/maintain-online-status.config';
import { MaintainOnlineStatusModule } from '@waha/modules/waha-maintain-online-status/maintain-online-status.module';
import { isJidEngine } from '@waha/modules/waha-wid-jid/wid-jid.plugins';
import { MessageSourceModule } from '@waha/modules/waha-message-source/message-source.module';
import { isPrometheusEnabled } from '@waha/modules/waha-prometheus/prometheus.config';
import { PrometheusModule } from '@waha/modules/waha-prometheus/prometheus.module';
import { WebhookModule } from '@waha/modules/waha-webhook/webhook.module';
import { SessionRuntimeInfoModule } from '@waha/modules/waha-session-runtime-info/session-runtime-info.module';
import { WidJIDModule } from '@waha/modules/waha-wid-jid/wid-jid.module';
import { WidSuffixModule } from '@waha/modules/waha-wid-suffix/wid-suffix.module';
import { CheckFreeDiskSpaceIndicator } from '@waha/core/health/CheckFreeDiskSpaceIndicator';
import { MongoStoreHealthIndicator } from '@waha/core/health/MongoStoreHealthIndicator';
import { ChannelsInfoServiceCore } from '@waha/core/services/ChannelsInfoServiceCore';
@@ -45,7 +61,6 @@ import {
import * as Joi from 'joi';
import { LoggerModule } from 'nestjs-pino';
import { Logger as NestJSPinoLogger } from 'nestjs-pino';
import { join } from 'path';
import { Logger } from 'pino';
import { AuthController } from '../api/auth.controller';
@@ -68,9 +83,7 @@ import { WhatsappConfigService } from '../config.service';
import { SessionManager } from './abc/manager.abc';
import { WAHAHealthCheckService } from './abc/WAHAHealthCheckService';
import { ApiKeyAuthFactory } from './auth/ApiKeyAuthFactory';
import { DashboardConfigServiceCore } from './config/DashboardConfigServiceCore';
import { EngineConfigService } from './config/EngineConfigService';
import { SwaggerConfigServiceCore } from './config/SwaggerConfigServiceCore';
import { WAHAHealthCheckServiceCore } from './health/WAHAHealthCheckServiceCore';
import { SessionManagerCore } from './manager.core';
import { CaslAbilityFactory } from '@waha/core/auth/casl.ability';
@@ -80,40 +93,37 @@ import { SessionService } from '@waha/core/services/SessionService';
export const IMPORTS_CORE = [
...AppsModuleExports.imports,
LoggerModule.forRoot({
renameContext: 'name',
pinoHttp: {
quietReqLogger: true,
level: getPinoLogLevel(),
useLevel: getPinoHttpUseLevel(),
transport: getPinoTransport(),
autoLogging: {
ignore: (req) => {
return (
req.url.startsWith('/ping') ||
req.url.startsWith('/dashboard/') ||
req.url.startsWith('/api/files/') ||
req.url.startsWith('/api/s3/') ||
req.url.startsWith('/jobs/')
);
LoggerModule.forRootAsync({
imports: [HttpPathsModule],
inject: [HttpPathsService],
useFactory: (httpPaths: HttpPathsService) => {
return {
renameContext: 'name',
pinoHttp: {
level: getPinoLogLevel(),
useLevel: getPinoHttpUseLevel(),
transport: getPinoTransport(),
autoLogging: {
ignore: (req) => httpPaths.isAccessLogIgnored(req.url),
},
redact: {
paths: ['req.query["x-api-key"]'],
censor: '[REDACTED]',
},
customAttributeKeys: { req: 'req', res: 'res' },
serializers: {
req: (req) => ({
method: req.method,
url: redactUrlParams('x-api-key', req.url, req.query),
query: req.query,
params: req.params,
}),
res: (res) => ({
statusCode: res.statusCode,
}),
},
},
},
redact: {
paths: ['req.query["x-api-key"]'],
censor: '[REDACTED]',
},
serializers: {
req: (req) => ({
id: req.id,
method: req.method,
url: redactUrlParams('x-api-key', req.url, req.query),
query: req.query,
params: req.params,
}),
res: (res) => ({
statusCode: res.statusCode,
}),
},
};
},
}),
ConfigModule.forRoot({
@@ -122,24 +132,32 @@ export const IMPORTS_CORE = [
WHATSAPP_API_SCHEMA: Joi.string().valid('http', 'https').default('http'),
}),
}),
ServeStaticModule.forRootAsync({
imports: [],
extraProviders: [DashboardConfigServiceCore],
inject: [DashboardConfigServiceCore],
useFactory: (dashboardConfig: DashboardConfigServiceCore) => {
if (!dashboardConfig.enabled) {
return [];
}
return [
{
rootPath: join(__dirname, '..', 'dashboard'),
serveRoot: dashboardConfig.dashboardUri,
},
];
},
}),
PassportModule,
TerminusModule,
AppBootstrapModule,
HttpPathsModule,
SessionPluginsModule,
SessionRuntimeInfoModule,
WebhookModule,
MessageSourceModule,
WidSuffixModule,
ConditionalModule.registerWhen(WidJIDModule, isJidEngine, {
debug: isDebugEnabled(),
}),
ConditionalModule.registerWhen(
MaintainOnlineStatusModule,
isPresenceAutoOnlineEnabled,
{ debug: isDebugEnabled() },
),
ConditionalModule.registerWhen(PrometheusModule, isPrometheusEnabled, {
debug: isDebugEnabled(),
}),
ConditionalModule.registerWhen(DashboardModule, isDashboardEnabled, {
debug: isDebugEnabled(),
}),
ConditionalModule.registerWhen(SwaggerEnabledModule, isSwaggerEnabled, {
debug: isDebugEnabled(),
}),
];
const IMPORTS_MEDIA = [
@@ -201,8 +219,6 @@ export const PROVIDERS_BASE: Provider[] = [
provide: APP_INTERCEPTOR,
useClass: BufferJsonReplacerInterceptor,
},
DashboardConfigServiceCore,
SwaggerConfigServiceCore,
WebJSEngineConfigService,
WPPEngineConfigService,
GowsEngineConfigService,
@@ -250,9 +266,21 @@ export class AppModuleCore {
constructor(
protected config: WhatsappConfigService,
private dashboardConfig: DashboardConfigServiceCore,
private httpPaths: HttpPathsService,
) {
this.startTimestamp = Date.now();
this.httpPaths.register(
{ prefix: '/ping', include: { accessLog: false, authBasic: false } },
{ prefix: '/api/', include: { authBasic: false } },
{ prefix: 'api', include: { authApiKey: true } },
{ prefix: '/health', include: { authBasic: false } },
{ prefix: 'health', include: { authApiKey: true } },
{ prefix: '/ws', include: { authBasic: false } },
);
// WHATSAPP_API_KEY_EXCLUDE_PATH - env-driven auth exclusions
for (const path of this.config.getExcludedFullPaths()) {
this.httpPaths.register({ prefix: path, include: { authBasic: false } });
}
}
static getHttpsOptions(logger: Logger) {
@@ -281,16 +309,6 @@ export class AppModuleCore {
consumer
.apply(ApiKeyAuthMiddleware)
.exclude(...exclude)
.forRoutes('api', 'health', 'mcp');
// Dashboard
const dashboardCredentials = this.dashboardConfig.credentials;
if (dashboardCredentials) {
const username = dashboardCredentials[0];
const password = dashboardCredentials[1];
consumer
.apply(BasicAuthFunction(username, password))
.forRoutes('dashboard');
}
.forRoutes(...this.httpPaths.apiKeyRoutes());
}
}
@@ -1,38 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
import { parseBool } from '../../helpers';
import { Auth } from '@waha/core/auth/config';
@Injectable()
export class DashboardConfigServiceCore {
public dashboardUri = '/dashboard';
constructor(
protected configService: ConfigService,
@InjectPinoLogger('DashboardConfigService')
protected logger: PinoLogger,
) {}
get enabled(): boolean {
const value = this.configService.get('WAHA_DASHBOARD_ENABLED', 'true');
return parseBool(value);
}
get credentials(): [string, string] | null {
const user = Auth.dashboard.username.value || '';
const password = Auth.dashboard.password.value || '';
if (!user && !password) {
return null;
}
if ((user && !password) || (!user && password)) {
this.logger.warn(
'Set up both WAHA_DASHBOARD_USERNAME and WAHA_DASHBOARD_PASSWORD ' +
'to enable dashboard authentication.',
);
return null;
}
return [user, password];
}
}
@@ -1,56 +0,0 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
import { parseBool } from '../../helpers';
import { Auth } from '@waha/core/auth/config';
@Injectable()
export class SwaggerConfigServiceCore {
constructor(
protected configService: ConfigService,
@InjectPinoLogger('SwaggerConfigService')
protected logger: PinoLogger,
) {}
get advancedConfigEnabled(): boolean {
const value = this.configService.get(
'WHATSAPP_SWAGGER_CONFIG_ADVANCED',
false,
);
return parseBool(value);
}
get enabled(): boolean {
const value = this.configService.get('WHATSAPP_SWAGGER_ENABLED', 'true');
return parseBool(value);
}
get credentials(): [string, string] | undefined {
const user = Auth.swagger.username.value;
const password = Auth.swagger.password.value;
if (!user && !password) {
return null;
}
if ((user && !password) || (!user && password)) {
this.logger.warn(
'Set up both WHATSAPP_SWAGGER_USERNAME and WHATSAPP_SWAGGER_PASSWORD ' +
'to enable swagger authentication.',
);
return null;
}
return [user, password];
}
get title() {
return this.configService.get('WHATSAPP_SWAGGER_TITLE', '');
}
get description() {
return this.configService.get('WHATSAPP_SWAGGER_DESCRIPTION', '');
}
get externalDocUrl() {
return this.configService.get('WHATSAPP_SWAGGER_EXTERNAL_DOC_URL', '');
}
}
+76
View File
@@ -1,20 +1,28 @@
import {
GOWSGroupJoinRequest,
GOWSGroupParticipant,
GOWSGroupParticipantRequest,
GroupInfoEvent,
GroupInfoFull,
GroupJoinRequestEvent,
JoinedGroupEvent,
} from '@waha/core/engines/gows/types.group';
import { isPnUser, toCusFormat, toJID } from '@waha/core/utils/jids';
import {
GroupId,
GroupInfo,
GroupJoinRequest,
GroupJoinRequestResult,
GroupParticipant,
GroupParticipantRole,
NormalizeJoinRequestMethod,
} from '@waha/structures/groups.dto';
import {
GroupParticipantsJoinRequestAction,
GroupParticipantType,
GroupV2JoinEvent,
GroupV2LeaveEvent,
GroupV2ParticipantsJoinRequestEvent,
GroupV2ParticipantsEvent,
GroupV2UpdateEvent,
} from '@waha/structures/groups.events.dto';
@@ -171,6 +179,74 @@ function ToGroupInfoPartial(group: GroupInfoEvent): GroupInfo | null {
return null;
}
export function ToGroupJoinRequest(
request: GOWSGroupParticipantRequest,
): GroupJoinRequest {
const requestedAt = Date.parse(request.RequestedAt);
return {
requesterId: toCusFormat(request.JID),
addedById: null,
parentGroupId: null,
requestMethod: null,
timestamp: requestedAt ? Math.floor(requestedAt / 1000) : 0,
};
}
export function ToGroupJoinRequestResult(
participant: GOWSGroupParticipant,
): GroupJoinRequestResult {
return {
requesterId: toCusFormat(participant.JID),
success: !participant.Error,
error: participant.Error || undefined,
};
}
function ToGroupParticipantsJoinRequestAction(
event: GroupJoinRequestEvent,
request: GOWSGroupJoinRequest,
): GroupParticipantsJoinRequestAction | null {
if (event.Action === 'created') {
return GroupParticipantsJoinRequestAction.CREATED;
}
if (event.Action !== 'revoked') {
return null;
}
// The wire has one 'revoked' change for both cases - tell them apart by who sent it:
// the requester cancelled their own request, anyone else (an admin) rejected it
const self =
event.Sender === request.JID ||
(!!event.SenderPN && event.SenderPN === request.PhoneNumber);
if (self) {
return GroupParticipantsJoinRequestAction.REVOKED;
}
return GroupParticipantsJoinRequestAction.REJECTED;
}
export function ToGroupV2ParticipantsJoinRequestEvents(
event: GroupJoinRequestEvent,
): GroupV2ParticipantsJoinRequestEvent[] {
const events: GroupV2ParticipantsJoinRequestEvent[] = [];
for (const request of event.Requests || []) {
const action = ToGroupParticipantsJoinRequestAction(event, request);
if (!action) {
continue;
}
events.push({
group: {
id: event.JID,
},
action: action,
requesterId: toCusFormat(request.JID),
requesterPn: toCusFormat(request.PhoneNumber) || null,
requestMethod: NormalizeJoinRequestMethod(request.RequestMethod),
timestamp: Date.now(),
_data: event,
});
}
return events;
}
export function ToGroupParticipants(
participants: GOWSGroupParticipant[],
): GroupParticipant[] {
+184 -1
View File
@@ -19,12 +19,17 @@ export namespace messages {
PROMOTE = 2,
DEMOTE = 3
}
export enum ParticipantRequestAction {
APPROVE = 0,
REJECT = 1
}
export enum MediaType {
IMAGE = 0,
AUDIO = 1,
VIDEO = 2,
DOCUMENT = 3,
PTV = 4
PTV = 4,
STICKER = 5
}
export enum Presence {
AVAILABLE = 0,
@@ -2978,6 +2983,145 @@ export namespace messages {
return UpdateParticipantsRequest.deserialize(bytes);
}
}
export class UpdateRequestParticipantsRequest extends pb_1.Message {
#one_of_decls: number[][] = [];
constructor(data?: any[] | {
session?: Session;
jid?: string;
participants?: string[];
action?: ParticipantRequestAction;
}) {
super();
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [3], this.#one_of_decls);
if (!Array.isArray(data) && typeof data == "object") {
if ("session" in data && data.session != undefined) {
this.session = data.session;
}
if ("jid" in data && data.jid != undefined) {
this.jid = data.jid;
}
if ("participants" in data && data.participants != undefined) {
this.participants = data.participants;
}
if ("action" in data && data.action != undefined) {
this.action = data.action;
}
}
}
get session() {
return pb_1.Message.getWrapperField(this, Session, 1) as Session;
}
set session(value: Session) {
pb_1.Message.setWrapperField(this, 1, value);
}
get has_session() {
return pb_1.Message.getField(this, 1) != null;
}
get jid() {
return pb_1.Message.getFieldWithDefault(this, 2, "") as string;
}
set jid(value: string) {
pb_1.Message.setField(this, 2, value);
}
get participants() {
return pb_1.Message.getFieldWithDefault(this, 3, []) as string[];
}
set participants(value: string[]) {
pb_1.Message.setField(this, 3, value);
}
get action() {
return pb_1.Message.getFieldWithDefault(this, 4, ParticipantRequestAction.APPROVE) as ParticipantRequestAction;
}
set action(value: ParticipantRequestAction) {
pb_1.Message.setField(this, 4, value);
}
static fromObject(data: {
session?: ReturnType<typeof Session.prototype.toObject>;
jid?: string;
participants?: string[];
action?: ParticipantRequestAction;
}): UpdateRequestParticipantsRequest {
const message = new UpdateRequestParticipantsRequest({});
if (data.session != null) {
message.session = Session.fromObject(data.session);
}
if (data.jid != null) {
message.jid = data.jid;
}
if (data.participants != null) {
message.participants = data.participants;
}
if (data.action != null) {
message.action = data.action;
}
return message;
}
toObject() {
const data: {
session?: ReturnType<typeof Session.prototype.toObject>;
jid?: string;
participants?: string[];
action?: ParticipantRequestAction;
} = {};
if (this.session != null) {
data.session = this.session.toObject();
}
if (this.jid != null) {
data.jid = this.jid;
}
if (this.participants != null) {
data.participants = this.participants;
}
if (this.action != null) {
data.action = this.action;
}
return data;
}
serialize(): Uint8Array;
serialize(w: pb_1.BinaryWriter): void;
serialize(w?: pb_1.BinaryWriter): Uint8Array | void {
const writer = w || new pb_1.BinaryWriter();
if (this.has_session)
writer.writeMessage(1, this.session, () => this.session.serialize(writer));
if (this.jid.length)
writer.writeString(2, this.jid);
if (this.participants.length)
writer.writeRepeatedString(3, this.participants);
if (this.action != ParticipantRequestAction.APPROVE)
writer.writeEnum(4, this.action);
if (!w)
return writer.getResultBuffer();
}
static deserialize(bytes: Uint8Array | pb_1.BinaryReader): UpdateRequestParticipantsRequest {
const reader = bytes instanceof pb_1.BinaryReader ? bytes : new pb_1.BinaryReader(bytes), message = new UpdateRequestParticipantsRequest();
while (reader.nextField()) {
if (reader.isEndGroup())
break;
switch (reader.getFieldNumber()) {
case 1:
reader.readMessage(message.session, () => message.session = Session.deserialize(reader));
break;
case 2:
message.jid = reader.readString();
break;
case 3:
pb_1.Message.addToRepeatedField(message, 3, reader.readString());
break;
case 4:
message.action = reader.readEnum();
break;
default: reader.skipField();
}
}
return message;
}
serializeBinary(): Uint8Array {
return this.serialize();
}
static deserializeBinary(bytes: Uint8Array): UpdateRequestParticipantsRequest {
return UpdateRequestParticipantsRequest.deserialize(bytes);
}
}
export class GroupCodeRequest extends pb_1.Message {
#one_of_decls: number[][] = [];
constructor(data?: any[] | {
@@ -11475,6 +11619,33 @@ export namespace messages {
responseSerialize: (message: JsonList) => Buffer.from(message.serialize()),
responseDeserialize: (bytes: Buffer) => JsonList.deserialize(new Uint8Array(bytes))
},
SetGroupJoinApprovalMode: {
path: "/messages.MessageService/SetGroupJoinApprovalMode",
requestStream: false,
responseStream: false,
requestSerialize: (message: JidBoolRequest) => Buffer.from(message.serialize()),
requestDeserialize: (bytes: Buffer) => JidBoolRequest.deserialize(new Uint8Array(bytes)),
responseSerialize: (message: Empty) => Buffer.from(message.serialize()),
responseDeserialize: (bytes: Buffer) => Empty.deserialize(new Uint8Array(bytes))
},
GetGroupRequestParticipants: {
path: "/messages.MessageService/GetGroupRequestParticipants",
requestStream: false,
responseStream: false,
requestSerialize: (message: JidRequest) => Buffer.from(message.serialize()),
requestDeserialize: (bytes: Buffer) => JidRequest.deserialize(new Uint8Array(bytes)),
responseSerialize: (message: JsonList) => Buffer.from(message.serialize()),
responseDeserialize: (bytes: Buffer) => JsonList.deserialize(new Uint8Array(bytes))
},
UpdateGroupRequestParticipants: {
path: "/messages.MessageService/UpdateGroupRequestParticipants",
requestStream: false,
responseStream: false,
requestSerialize: (message: UpdateRequestParticipantsRequest) => Buffer.from(message.serialize()),
requestDeserialize: (bytes: Buffer) => UpdateRequestParticipantsRequest.deserialize(new Uint8Array(bytes)),
responseSerialize: (message: JsonList) => Buffer.from(message.serialize()),
responseDeserialize: (bytes: Buffer) => JsonList.deserialize(new Uint8Array(bytes))
},
GetProfilePicture: {
path: "/messages.MessageService/GetProfilePicture",
requestStream: false,
@@ -11858,6 +12029,9 @@ export namespace messages {
abstract SetGroupAnnounce(call: grpc_1.ServerUnaryCall<JidBoolRequest, Empty>, callback: grpc_1.sendUnaryData<Empty>): void;
abstract SetGroupMemberAddMode(call: grpc_1.ServerUnaryCall<JidBoolRequest, Empty>, callback: grpc_1.sendUnaryData<Empty>): void;
abstract UpdateGroupParticipants(call: grpc_1.ServerUnaryCall<UpdateParticipantsRequest, JsonList>, callback: grpc_1.sendUnaryData<JsonList>): void;
abstract SetGroupJoinApprovalMode(call: grpc_1.ServerUnaryCall<JidBoolRequest, Empty>, callback: grpc_1.sendUnaryData<Empty>): void;
abstract GetGroupRequestParticipants(call: grpc_1.ServerUnaryCall<JidRequest, JsonList>, callback: grpc_1.sendUnaryData<JsonList>): void;
abstract UpdateGroupRequestParticipants(call: grpc_1.ServerUnaryCall<UpdateRequestParticipantsRequest, JsonList>, callback: grpc_1.sendUnaryData<JsonList>): void;
abstract GetProfilePicture(call: grpc_1.ServerUnaryCall<ProfilePictureRequest, ProfilePictureResponse>, callback: grpc_1.sendUnaryData<ProfilePictureResponse>): void;
abstract SendPresence(call: grpc_1.ServerUnaryCall<PresenceRequest, Empty>, callback: grpc_1.sendUnaryData<Empty>): void;
abstract SendChatPresence(call: grpc_1.ServerUnaryCall<ChatPresenceRequest, Empty>, callback: grpc_1.sendUnaryData<Empty>): void;
@@ -11992,6 +12166,15 @@ export namespace messages {
UpdateGroupParticipants: GrpcUnaryServiceInterface<UpdateParticipantsRequest, JsonList> = (message: UpdateParticipantsRequest, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback<JsonList>, options?: grpc_1.CallOptions | grpc_1.requestCallback<JsonList>, callback?: grpc_1.requestCallback<JsonList>): grpc_1.ClientUnaryCall => {
return super.UpdateGroupParticipants(message, metadata, options, callback);
};
SetGroupJoinApprovalMode: GrpcUnaryServiceInterface<JidBoolRequest, Empty> = (message: JidBoolRequest, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback<Empty>, options?: grpc_1.CallOptions | grpc_1.requestCallback<Empty>, callback?: grpc_1.requestCallback<Empty>): grpc_1.ClientUnaryCall => {
return super.SetGroupJoinApprovalMode(message, metadata, options, callback);
};
GetGroupRequestParticipants: GrpcUnaryServiceInterface<JidRequest, JsonList> = (message: JidRequest, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback<JsonList>, options?: grpc_1.CallOptions | grpc_1.requestCallback<JsonList>, callback?: grpc_1.requestCallback<JsonList>): grpc_1.ClientUnaryCall => {
return super.GetGroupRequestParticipants(message, metadata, options, callback);
};
UpdateGroupRequestParticipants: GrpcUnaryServiceInterface<UpdateRequestParticipantsRequest, JsonList> = (message: UpdateRequestParticipantsRequest, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback<JsonList>, options?: grpc_1.CallOptions | grpc_1.requestCallback<JsonList>, callback?: grpc_1.requestCallback<JsonList>): grpc_1.ClientUnaryCall => {
return super.UpdateGroupRequestParticipants(message, metadata, options, callback);
};
GetProfilePicture: GrpcUnaryServiceInterface<ProfilePictureRequest, ProfilePictureResponse> = (message: ProfilePictureRequest, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback<ProfilePictureResponse>, options?: grpc_1.CallOptions | grpc_1.requestCallback<ProfilePictureResponse>, callback?: grpc_1.requestCallback<ProfilePictureResponse>): grpc_1.ClientUnaryCall => {
return super.GetProfilePicture(message, metadata, options, callback);
};
@@ -697,6 +697,17 @@ function deserialize_messages_UpdateParticipantsRequest(buffer_arg) {
return gows_pb.UpdateParticipantsRequest.deserializeBinary(new Uint8Array(buffer_arg));
}
function serialize_messages_UpdateRequestParticipantsRequest(arg) {
if (!(arg instanceof gows_pb.UpdateRequestParticipantsRequest)) {
throw new Error('Expected argument of type messages.UpdateRequestParticipantsRequest');
}
return Buffer.from(arg.serializeBinary());
}
function deserialize_messages_UpdateRequestParticipantsRequest(buffer_arg) {
return gows_pb.UpdateRequestParticipantsRequest.deserializeBinary(new Uint8Array(buffer_arg));
}
function serialize_messages_UpsertLabelRequest(arg) {
if (!(arg instanceof gows_pb.UpsertLabelRequest)) {
throw new Error('Expected argument of type messages.UpsertLabelRequest');
@@ -1073,6 +1084,39 @@ updateGroupParticipants: {
responseSerialize: serialize_messages_JsonList,
responseDeserialize: deserialize_messages_JsonList,
},
setGroupJoinApprovalMode: {
path: '/messages.MessageService/SetGroupJoinApprovalMode',
requestStream: false,
responseStream: false,
requestType: gows_pb.JidBoolRequest,
responseType: gows_pb.Empty,
requestSerialize: serialize_messages_JidBoolRequest,
requestDeserialize: deserialize_messages_JidBoolRequest,
responseSerialize: serialize_messages_Empty,
responseDeserialize: deserialize_messages_Empty,
},
getGroupRequestParticipants: {
path: '/messages.MessageService/GetGroupRequestParticipants',
requestStream: false,
responseStream: false,
requestType: gows_pb.JidRequest,
responseType: gows_pb.JsonList,
requestSerialize: serialize_messages_JidRequest,
requestDeserialize: deserialize_messages_JidRequest,
responseSerialize: serialize_messages_JsonList,
responseDeserialize: deserialize_messages_JsonList,
},
updateGroupRequestParticipants: {
path: '/messages.MessageService/UpdateGroupRequestParticipants',
requestStream: false,
responseStream: false,
requestType: gows_pb.UpdateRequestParticipantsRequest,
responseType: gows_pb.JsonList,
requestSerialize: serialize_messages_UpdateRequestParticipantsRequest,
requestDeserialize: deserialize_messages_UpdateRequestParticipantsRequest,
responseSerialize: serialize_messages_JsonList,
responseDeserialize: deserialize_messages_JsonList,
},
//
// Actions
//
+300 -1
View File
@@ -84,6 +84,7 @@ goog.exportSymbol('proto.messages.Pagination', null, global);
goog.exportSymbol('proto.messages.PairCodeRequest', null, global);
goog.exportSymbol('proto.messages.PairCodeResponse', null, global);
goog.exportSymbol('proto.messages.ParticipantAction', null, global);
goog.exportSymbol('proto.messages.ParticipantRequestAction', null, global);
goog.exportSymbol('proto.messages.PasskeyResponseRequest', null, global);
goog.exportSymbol('proto.messages.PhoneInfo', null, global);
goog.exportSymbol('proto.messages.PollMessage', null, global);
@@ -120,6 +121,7 @@ goog.exportSymbol('proto.messages.StreamEventsRequest', null, global);
goog.exportSymbol('proto.messages.SubscribePresenceRequest', null, global);
goog.exportSymbol('proto.messages.UpdateContactRequest', null, global);
goog.exportSymbol('proto.messages.UpdateParticipantsRequest', null, global);
goog.exportSymbol('proto.messages.UpdateRequestParticipantsRequest', null, global);
goog.exportSymbol('proto.messages.UpsertLabelRequest', null, global);
goog.exportSymbol('proto.messages.VideoInfo', null, global);
goog.exportSymbol('proto.messages.vCardContact', null, global);
@@ -753,6 +755,27 @@ if (goog.DEBUG && !COMPILED) {
*/
proto.messages.UpdateParticipantsRequest.displayName = 'proto.messages.UpdateParticipantsRequest';
}
/**
* Generated by JsPbCodeGenerator.
* @param {Array=} opt_data Optional initial data array, typically from a
* server response, or constructed directly in Javascript. The array is used
* in place and becomes part of the constructed object. It is not cloned.
* If no data is provided, the constructed object will be empty, but still
* valid.
* @extends {jspb.Message}
* @constructor
*/
proto.messages.UpdateRequestParticipantsRequest = function(opt_data) {
jspb.Message.initialize(this, opt_data, 0, -1, proto.messages.UpdateRequestParticipantsRequest.repeatedFields_, null);
};
goog.inherits(proto.messages.UpdateRequestParticipantsRequest, jspb.Message);
if (goog.DEBUG && !COMPILED) {
/**
* @public
* @override
*/
proto.messages.UpdateRequestParticipantsRequest.displayName = 'proto.messages.UpdateRequestParticipantsRequest';
}
/**
* Generated by JsPbCodeGenerator.
* @param {Array=} opt_data Optional initial data array, typically from a
@@ -7652,6 +7675,273 @@ proto.messages.UpdateParticipantsRequest.prototype.setAction = function(value) {
/**
* List of repeated fields within this message type.
* @private {!Array<number>}
* @const
*/
proto.messages.UpdateRequestParticipantsRequest.repeatedFields_ = [3];
if (jspb.Message.GENERATE_TO_OBJECT) {
/**
* Creates an object representation of this proto.
* Field names that are reserved in JavaScript and will be renamed to pb_name.
* Optional fields that are not set will be set to undefined.
* To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
* For the list of reserved names please see:
* net/proto2/compiler/js/internal/generator.cc#kKeyword.
* @param {boolean=} opt_includeInstance Deprecated. whether to include the
* JSPB instance for transitional soy proto support:
* http://goto/soy-param-migration
* @return {!Object}
*/
proto.messages.UpdateRequestParticipantsRequest.prototype.toObject = function(opt_includeInstance) {
return proto.messages.UpdateRequestParticipantsRequest.toObject(opt_includeInstance, this);
};
/**
* Static version of the {@see toObject} method.
* @param {boolean|undefined} includeInstance Deprecated. Whether to include
* the JSPB instance for transitional soy proto support:
* http://goto/soy-param-migration
* @param {!proto.messages.UpdateRequestParticipantsRequest} msg The msg instance to transform.
* @return {!Object}
* @suppress {unusedLocalVariables} f is only used for nested messages
*/
proto.messages.UpdateRequestParticipantsRequest.toObject = function(includeInstance, msg) {
var f, obj = {
session: (f = msg.getSession()) && proto.messages.Session.toObject(includeInstance, f),
jid: jspb.Message.getFieldWithDefault(msg, 2, ""),
participantsList: (f = jspb.Message.getRepeatedField(msg, 3)) == null ? undefined : f,
action: jspb.Message.getFieldWithDefault(msg, 4, 0)
};
if (includeInstance) {
obj.$jspbMessageInstance = msg;
}
return obj;
};
}
/**
* Deserializes binary data (in protobuf wire format).
* @param {jspb.ByteSource} bytes The bytes to deserialize.
* @return {!proto.messages.UpdateRequestParticipantsRequest}
*/
proto.messages.UpdateRequestParticipantsRequest.deserializeBinary = function(bytes) {
var reader = new jspb.BinaryReader(bytes);
var msg = new proto.messages.UpdateRequestParticipantsRequest;
return proto.messages.UpdateRequestParticipantsRequest.deserializeBinaryFromReader(msg, reader);
};
/**
* Deserializes binary data (in protobuf wire format) from the
* given reader into the given message object.
* @param {!proto.messages.UpdateRequestParticipantsRequest} msg The message object to deserialize into.
* @param {!jspb.BinaryReader} reader The BinaryReader to use.
* @return {!proto.messages.UpdateRequestParticipantsRequest}
*/
proto.messages.UpdateRequestParticipantsRequest.deserializeBinaryFromReader = function(msg, reader) {
while (reader.nextField()) {
if (reader.isEndGroup()) {
break;
}
var field = reader.getFieldNumber();
switch (field) {
case 1:
var value = new proto.messages.Session;
reader.readMessage(value,proto.messages.Session.deserializeBinaryFromReader);
msg.setSession(value);
break;
case 2:
var value = /** @type {string} */ (reader.readString());
msg.setJid(value);
break;
case 3:
var value = /** @type {string} */ (reader.readString());
msg.addParticipants(value);
break;
case 4:
var value = /** @type {!proto.messages.ParticipantRequestAction} */ (reader.readEnum());
msg.setAction(value);
break;
default:
reader.skipField();
break;
}
}
return msg;
};
/**
* Serializes the message to binary data (in protobuf wire format).
* @return {!Uint8Array}
*/
proto.messages.UpdateRequestParticipantsRequest.prototype.serializeBinary = function() {
var writer = new jspb.BinaryWriter();
proto.messages.UpdateRequestParticipantsRequest.serializeBinaryToWriter(this, writer);
return writer.getResultBuffer();
};
/**
* Serializes the given message to binary data (in protobuf wire
* format), writing to the given BinaryWriter.
* @param {!proto.messages.UpdateRequestParticipantsRequest} message
* @param {!jspb.BinaryWriter} writer
* @suppress {unusedLocalVariables} f is only used for nested messages
*/
proto.messages.UpdateRequestParticipantsRequest.serializeBinaryToWriter = function(message, writer) {
var f = undefined;
f = message.getSession();
if (f != null) {
writer.writeMessage(
1,
f,
proto.messages.Session.serializeBinaryToWriter
);
}
f = message.getJid();
if (f.length > 0) {
writer.writeString(
2,
f
);
}
f = message.getParticipantsList();
if (f.length > 0) {
writer.writeRepeatedString(
3,
f
);
}
f = message.getAction();
if (f !== 0.0) {
writer.writeEnum(
4,
f
);
}
};
/**
* optional Session session = 1;
* @return {?proto.messages.Session}
*/
proto.messages.UpdateRequestParticipantsRequest.prototype.getSession = function() {
return /** @type{?proto.messages.Session} */ (
jspb.Message.getWrapperField(this, proto.messages.Session, 1));
};
/**
* @param {?proto.messages.Session|undefined} value
* @return {!proto.messages.UpdateRequestParticipantsRequest} returns this
*/
proto.messages.UpdateRequestParticipantsRequest.prototype.setSession = function(value) {
return jspb.Message.setWrapperField(this, 1, value);
};
/**
* Clears the message field making it undefined.
* @return {!proto.messages.UpdateRequestParticipantsRequest} returns this
*/
proto.messages.UpdateRequestParticipantsRequest.prototype.clearSession = function() {
return this.setSession(undefined);
};
/**
* Returns whether this field is set.
* @return {boolean}
*/
proto.messages.UpdateRequestParticipantsRequest.prototype.hasSession = function() {
return jspb.Message.getField(this, 1) != null;
};
/**
* optional string jid = 2;
* @return {string}
*/
proto.messages.UpdateRequestParticipantsRequest.prototype.getJid = function() {
return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 2, ""));
};
/**
* @param {string} value
* @return {!proto.messages.UpdateRequestParticipantsRequest} returns this
*/
proto.messages.UpdateRequestParticipantsRequest.prototype.setJid = function(value) {
return jspb.Message.setProto3StringField(this, 2, value);
};
/**
* repeated string participants = 3;
* @return {!Array<string>}
*/
proto.messages.UpdateRequestParticipantsRequest.prototype.getParticipantsList = function() {
return /** @type {!Array<string>} */ (jspb.Message.getRepeatedField(this, 3));
};
/**
* @param {!Array<string>} value
* @return {!proto.messages.UpdateRequestParticipantsRequest} returns this
*/
proto.messages.UpdateRequestParticipantsRequest.prototype.setParticipantsList = function(value) {
return jspb.Message.setField(this, 3, value || []);
};
/**
* @param {string} value
* @param {number=} opt_index
* @return {!proto.messages.UpdateRequestParticipantsRequest} returns this
*/
proto.messages.UpdateRequestParticipantsRequest.prototype.addParticipants = function(value, opt_index) {
return jspb.Message.addToRepeatedField(this, 3, value, opt_index);
};
/**
* Clears the list making it empty but non-null.
* @return {!proto.messages.UpdateRequestParticipantsRequest} returns this
*/
proto.messages.UpdateRequestParticipantsRequest.prototype.clearParticipantsList = function() {
return this.setParticipantsList([]);
};
/**
* optional ParticipantRequestAction action = 4;
* @return {!proto.messages.ParticipantRequestAction}
*/
proto.messages.UpdateRequestParticipantsRequest.prototype.getAction = function() {
return /** @type {!proto.messages.ParticipantRequestAction} */ (jspb.Message.getFieldWithDefault(this, 4, 0));
};
/**
* @param {!proto.messages.ParticipantRequestAction} value
* @return {!proto.messages.UpdateRequestParticipantsRequest} returns this
*/
proto.messages.UpdateRequestParticipantsRequest.prototype.setAction = function(value) {
return jspb.Message.setProto3EnumField(this, 4, value);
};
if (jspb.Message.GENERATE_TO_OBJECT) {
@@ -22845,6 +23135,14 @@ proto.messages.ParticipantAction = {
DEMOTE: 3
};
/**
* @enum {number}
*/
proto.messages.ParticipantRequestAction = {
APPROVE: 0,
REJECT: 1
};
/**
* @enum {number}
*/
@@ -22853,7 +23151,8 @@ proto.messages.MediaType = {
AUDIO: 1,
VIDEO: 2,
DOCUMENT: 3,
PTV: 4
PTV: 4,
STICKER: 5
};
/**
+270 -77
View File
@@ -15,12 +15,19 @@ import { Jid } from '@waha/core/engines/const';
import { EventsFromObservable } from '@waha/core/engines/gows/EventsFromObservable';
import { GowsEventStreamObservable } from '@waha/core/engines/gows/GowsEventStreamObservable';
import {
ToGroupJoinRequest,
ToGroupJoinRequestResult,
ToGroupParticipants,
ToGroupV2JoinEvent,
ToGroupV2LeaveEvent,
ToGroupV2ParticipantsJoinRequestEvents,
ToGroupV2ParticipantsEvents,
ToGroupV2UpdateEvent,
} from '@waha/core/engines/gows/groups.gows';
import {
GOWSGroupParticipant,
GOWSGroupParticipantRequest,
} from '@waha/core/engines/gows/types.group';
import { messages } from '@waha/core/engines/gows/grpc/gows';
import {
optional,
@@ -37,7 +44,10 @@ import {
} from '@waha/core/engines/noweb/session.noweb.core';
import { extractMediaContent } from '@waha/core/engines/noweb/utils';
import { NotImplementedByEngineError } from '@waha/core/exceptions';
import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
import {
IMediaEngineProcessor,
MediaContent,
} from '@waha/core/media/IMediaEngineProcessor';
import { LottieMediaProcessorWrapper } from '@waha/core/media/LottieMediaProcessorWrapper';
import { QR } from '@waha/core/QR';
import { ExtractMessageKeysForRead } from '@waha/core/utils/convertors';
@@ -46,9 +56,7 @@ import {
isJidBroadcast,
isJidGroup,
isJidNewsletter,
normalizeJid,
toCusFormat,
toJID,
} from '@waha/core/utils/jids';
import {
PasskeyChallenge,
@@ -94,6 +102,7 @@ import {
MessageReactionRequest,
MessageReplyRequest,
MessageTextRequest,
MessageStickerRequest,
MessageVideoRequest,
MessageVoiceRequest,
SendSeenRequest,
@@ -103,6 +112,7 @@ import { SendListRequest } from '@waha/structures/chatting.list.dto';
import { ContactQuery, ContactUpdateBody } from '@waha/structures/contacts.dto';
import {
ACK_UNKNOWN,
WAHAEngine,
WAHAEvents,
WAHAPresenceStatus,
WAHASessionStatus,
@@ -116,11 +126,14 @@ import {
import { BinaryFile, RemoteFile } from '@waha/structures/files.dto';
import {
CreateGroupRequest,
GroupJoinRequest,
GroupJoinRequestResult,
GroupParticipant,
GroupSortField,
Participant,
ParticipantsRequest,
SettingsMemberAddMode,
SettingsMembershipApproval,
SettingsSecurityChangeInfo,
} from '@waha/structures/groups.dto';
import { ReplyToMessage } from '@waha/structures/message.dto';
@@ -202,7 +215,6 @@ import { IsEditedMessage } from '@waha/core/utils/pwa';
import { GoToJSWAProto } from '@waha/core/engines/gows/waproto';
import { extractWALocation } from '@waha/core/engines/waproto/locaiton';
import { extractVCards } from '@waha/core/engines/waproto/vcards';
import { Activity } from '@waha/core/abc/activity';
import { TmpDir } from '@waha/utils/tmpdir';
import { detectMimetype } from '@waha/utils/files';
import { WAMimeType } from '@waha/core/media/WAMimeType';
@@ -212,6 +224,9 @@ import axiosRetry from 'axios-retry';
import * as path from 'path';
import MessageServiceClient = messages.MessageServiceClient;
import * as fsp from 'fs/promises';
import { MediaDownloadOptions } from '@waha/core/media/IMediaManager';
import { Activity } from '@waha/core/abc/session.hooks.activity';
axiosRetry(axios, { retries: 3 });
@@ -237,6 +252,7 @@ enum WhatsMeowEvent {
KEEP_ALIVE_RESTORED = 'events.KeepAliveRestored',
QR_CHANNEL_ITEM = 'whatsmeow.QRChannelItem',
MESSAGE = 'events.Message',
UNDECRYPTABLE_MESSAGE = 'events.UndecryptableMessage',
RECEIPT = 'events.Receipt',
PRESENCE = 'events.Presence',
CHAT_PRESENCE = 'events.ChatPresence',
@@ -247,6 +263,7 @@ enum WhatsMeowEvent {
// Groups
GROUP_INFO = 'events.GroupInfo',
JOINED_GROUP = 'events.JoinedGroup',
GROUP_JOIN_REQUEST = 'gows.GroupJoinRequestEvent',
// Labels
LABEL_EDIT = 'events.LabelEdit',
LABEL_ASSOCIATION_CHAT = 'events.LabelAssociationChat',
@@ -273,6 +290,8 @@ export interface GowsConfig {
}
export class WhatsappSessionGoWSCore extends WhatsappSession {
engine = WAHAEngine.GOWS;
protected authFactory = new GowsAuthFactoryCore();
protected qr: QR;
@@ -408,14 +427,12 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
events.on(WhatsMeowEvent.DISCONNECTED, () => {
if (this.status != WAHASessionStatus.STARTING) {
this.cleanupPresenceTimeout();
this.presence = null;
this.status = WAHASessionStatus.STARTING;
}
});
events.on(WhatsMeowEvent.KEEP_ALIVE_TIMEOUT, () => {
if (this.status != WAHASessionStatus.STARTING) {
this.cleanupPresenceTimeout();
this.presence = null;
this.status = WAHASessionStatus.STARTING;
}
@@ -488,6 +505,11 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
events.on(WhatsMeowEvent.MESSAGE_CAPPING, (data) => {
this.messageCapping.update(parseMessageCapping(data));
});
events.on(WhatsMeowEvent.UNDECRYPTABLE_MESSAGE, (data) => {
this.logger.error(
`Undecryptable message in '${data?.Info?.Chat}' from '${data?.Info?.Sender}', id '${data?.Info?.ID}' - content lost unless the sender answers the retry receipt`,
);
});
events.on(WhatsMeowEvent.PRESENCE, (event: gows.Presence) => {
if (isJidGroup(event.From)) {
// So group is not "online"
@@ -575,7 +597,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
msg.Status = MessageStatus.ServerAck;
return msg;
}),
mergeMap((msg) => this.processIncomingMessage(msg, true)),
mergeMap((msg) => this.processIncomingMessage(msg, this.media.events)),
filter(Boolean),
// Deduplicate messages by ID to prevent duplicate webhooks
// @see https://github.com/devlikeapro/waha/issues/1564
@@ -587,7 +609,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
msg.Status = MessageStatus.DeliveryAck;
return msg;
}),
mergeMap((msg) => this.processIncomingMessage(msg, true)),
mergeMap((msg) => this.processIncomingMessage(msg, this.media.events)),
filter(Boolean),
// Deduplicate messages by ID to prevent duplicate webhooks
// @see https://github.com/devlikeapro/waha/issues/1564
@@ -753,6 +775,15 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
);
this.events2.get(WAHAEvents.GROUP_V2_UPDATE).switch(groupV2Update$);
const groupV2ParticipantsJoinRequest$ = all$.pipe(
onlyEvent(WhatsMeowEvent.GROUP_JOIN_REQUEST),
map(ToGroupV2ParticipantsJoinRequestEvents),
mergeMap((events) => events),
);
this.events2
.get(WAHAEvents.GROUP_V2_PARTICIPANTS_JOIN_REQUEST)
.switch(groupV2ParticipantsJoinRequest$);
// Label Events
// First, create streams for the raw label events
const labelEditEvents$ = all$.pipe(
@@ -845,7 +876,10 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
@Activity()
async fetchContactProfilePicture(id: string): Promise<string> {
const jid = normalizeJid(toJID(this.ensureSuffix(id)));
const jid = await this.hooks.wid.chat.promise(
id,
'fetchContactProfilePicture',
);
const request = new messages.ProfilePictureRequest({
jid: jid,
session: this.session,
@@ -862,7 +896,6 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
}
async stop(): Promise<void> {
this.cleanupPresenceTimeout();
this.status = WAHASessionStatus.STOPPED;
this.events?.stop();
this.stopEvents();
@@ -1028,6 +1061,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return true;
}
@Activity()
protected async deleteProfilePicture(): Promise<boolean> {
const request = new messages.SetProfilePictureRequest({
session: this.session,
@@ -1050,9 +1084,10 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
@Activity()
async rejectCall(from: string, id: string): Promise<void> {
const jid = await this.hooks.wid.chat.promise(from, 'rejectCall');
const request = new messages.RejectCallRequest({
session: this.session,
from: normalizeJid(toJID(this.ensureSuffix(from))),
from: jid,
id: id,
});
await promisify(this.client.RejectCall)(request);
@@ -1060,7 +1095,15 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
@Activity()
async sendText(request: MessageTextRequest) {
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
const jid = await this.hooks.wid.chat.promise(request.chatId, 'sendText');
let mentions: string[] | undefined;
if (request.mentions) {
mentions = await Promise.all(
request.mentions.map((mention) =>
this.hooks.wid.mention.promise(mention, 'sendText'),
),
);
}
const message = new messages.MessageRequest({
id: request.id,
jid: jid,
@@ -1069,9 +1112,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
linkPreview: request.linkPreview ?? true,
linkPreviewHighQuality: request.linkPreviewHighQuality,
replyTo: getMessageIdFromSerialized(request.reply_to),
mentions: request.mentions?.map((mention) =>
normalizeJid(toJID(mention)),
),
mentions: mentions,
});
const response = await promisify(this.client.SendMessage)(message);
const data = response.toObject();
@@ -1084,7 +1125,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
messageId: string,
request: EditMessageRequest,
) {
const jid = normalizeJid(toJID(this.ensureSuffix(chatId)));
const jid = await this.hooks.wid.chat.promise(chatId, 'editMessage');
const key = parseMessageIdSerialized(messageId, true);
const message = new messages.EditMessageRequest({
session: this.session,
@@ -1101,7 +1142,10 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
@Activity()
async sendContactVCard(request: MessageContactVcardRequest) {
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
const jid = await this.hooks.wid.chat.promise(
request.chatId,
'sendContactVCard',
);
const contacts = request.contacts.map((el) => ({
displayName:
(el as any).fullName || parseVCardV3(el.vcard || '').fullName,
@@ -1121,7 +1165,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
@Activity()
async sendPoll(request: MessagePollRequest) {
const jid = normalizeJid(toJID(request.chatId));
const jid = await this.hooks.wid.chat.promise(request.chatId, 'sendPoll');
const message = new messages.MessageRequest({
id: request.id,
jid: jid,
@@ -1140,7 +1184,10 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
@Activity()
async sendPollVote(request: MessagePollVoteRequest) {
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
const jid = await this.hooks.wid.chat.promise(
request.chatId,
'sendPollVote',
);
const key = parseMessageIdSerialized(request.pollMessageId, true);
const pollVote = new messages.PollVoteMessage({
pollMessageId: key.id,
@@ -1162,7 +1209,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
@Activity()
async sendList(request: SendListRequest): Promise<any> {
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
const jid = await this.hooks.wid.chat.promise(request.chatId, 'sendList');
if (isJidGroup(jid) || isJidBroadcast(jid) || isJidNewsletter(jid)) {
throw new UnprocessableEntityException(
`List message can only be sent to a direct message chat.`,
@@ -1189,7 +1236,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
@Activity()
public async deleteMessage(chatId: string, messageId: string) {
const jid = normalizeJid(toJID(this.ensureSuffix(chatId)));
const jid = await this.hooks.wid.chat.promise(chatId, 'deleteMessage');
const key = parseMessageIdSerialized(messageId);
const message = new messages.RevokeMessageRequest({
session: this.session,
@@ -1206,7 +1253,11 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
if (!contacts || contacts.length == 0) {
return [];
}
return contacts.map((c) => normalizeJid(toJID(c)));
return await Promise.all(
contacts.map((c) =>
this.hooks.wid.chat.promise(c, 'prepareJidsForStatus'),
),
);
}
@Activity()
@@ -1232,6 +1283,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return this.messageResponse(Jid.BROADCAST, data);
}
@Activity()
public async deleteStatus(request: DeleteStatusRequest) {
const participants = await this.prepareJidsForStatus(request.contacts);
const key = parseMessageIdSerialized(request.id, true);
@@ -1283,7 +1335,10 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
@Activity()
async sendLocation(request: MessageLocationRequest) {
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
const jid = await this.hooks.wid.chat.promise(
request.chatId,
'sendLocation',
);
const message = new messages.MessageRequest({
id: request.id,
jid: jid,
@@ -1305,7 +1360,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
}
private async sendMedia(type: messages.MediaType, request: any) {
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
const jid = await this.hooks.wid.chat.promise(request.chatId, 'sendMedia');
const media = await this.fileToMedia(request.file);
media.type = type;
if (type === messages.MediaType.IMAGE) {
@@ -1321,6 +1376,8 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
if (!media.mimetype) {
media.mimetype = await detectMimetype(media.content as Buffer);
}
} else if (type === messages.MediaType.STICKER) {
media.mimetype = media.mimetype || WAMimeType.STICKER;
}
if (request.convert) {
@@ -1357,6 +1414,14 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
});
}
const participants = await this.prepareJidsForStatus(request.contacts);
let mentions: string[] | undefined;
if (request.mentions) {
mentions = await Promise.all(
request.mentions.map((mention) =>
this.hooks.wid.mention.promise(mention, 'sendMedia'),
),
);
}
const message = new messages.MessageRequest({
id: request.id,
jid: jid,
@@ -1364,9 +1429,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
session: this.session,
media: media,
backgroundColor: backgroundColor,
mentions: request.mentions?.map((mention) =>
normalizeJid(toJID(mention)),
),
mentions: mentions,
participants: participants,
});
@@ -1438,11 +1501,19 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return await this.sendMedia(type, request);
}
@Activity()
async sendSticker(request: MessageStickerRequest) {
return await this.sendMedia(messages.MediaType.STICKER, request);
}
@Activity()
async sendLinkCustomPreview(
request: MessageLinkCustomPreviewRequest,
): Promise<any> {
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
const jid = await this.hooks.wid.chat.promise(
request.chatId,
'sendLinkCustomPreview',
);
const media = await this.fileToMedia(request.preview.image as RemoteFile);
const preview = new messages.LinkPreview({
url: request.preview.url,
@@ -1469,7 +1540,10 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
throw new NotImplementedByEngineError();
// Doesn't work yet
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
const jid = await this.hooks.wid.chat.promise(
request.chatId,
'sendButtonsReply',
);
const message = new messages.ButtonReplyRequest({
jid: jid,
session: this.session,
@@ -1552,10 +1626,15 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
*/
@Activity()
public async createGroup(request: CreateGroupRequest) {
const participants = await Promise.all(
request.participants.map((p) =>
this.hooks.wid.chat.promise(p.id, 'createGroup'),
),
);
const req = new messages.CreateGroupRequest({
session: this.session,
name: request.name,
participants: request.participants.map((p) => normalizeJid(toJID(p.id))),
participants: participants,
});
const response = await promisify(this.client.CreateGroup)(req);
const data = parseJson(response);
@@ -1683,6 +1762,83 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return;
}
public async getMembershipApprovalMode(
id: string,
): Promise<SettingsMembershipApproval> {
const group = await this.getGroup(id);
return {
newMembersApprovalRequired: !!group.IsJoinApprovalRequired,
};
}
@Activity()
public async setMembershipApprovalMode(
id: string,
value: boolean,
): Promise<boolean> {
const req = new messages.JidBoolRequest({
session: this.session,
jid: id,
value: value,
});
await promisify(this.client.SetGroupJoinApprovalMode)(req);
return true;
}
@Activity()
public async getGroupJoinRequests(id: string): Promise<GroupJoinRequest[]> {
const req = new messages.JidRequest({
session: this.session,
jid: id,
});
const response = await promisify(this.client.GetGroupRequestParticipants)(
req,
);
const requests: GOWSGroupParticipantRequest[] = parseJsonList(response);
return requests.map(ToGroupJoinRequest);
}
@Activity()
public approveGroupJoinRequests(
id: string,
request: ParticipantsRequest,
): Promise<GroupJoinRequestResult[]> {
const action = messages.ParticipantRequestAction.APPROVE;
return this.updateRequestParticipants(id, request.participants, action);
}
@Activity()
public rejectGroupJoinRequests(
id: string,
request: ParticipantsRequest,
): Promise<GroupJoinRequestResult[]> {
const action = messages.ParticipantRequestAction.REJECT;
return this.updateRequestParticipants(id, request.participants, action);
}
private async updateRequestParticipants(
id: string,
participants: Array<Participant>,
action: messages.ParticipantRequestAction,
): Promise<GroupJoinRequestResult[]> {
const jids = await Promise.all(
participants.map((p) =>
this.hooks.wid.chat.promise(p.id, 'updateRequestParticipants'),
),
);
const req = new messages.UpdateRequestParticipantsRequest({
session: this.session,
jid: id,
participants: jids,
action: action,
});
const response = await promisify(
this.client.UpdateGroupRequestParticipants,
)(req);
const results: GOWSGroupParticipant[] = parseJsonList(response);
return results.map(ToGroupJoinRequestResult);
}
public deleteGroup(id) {
throw new NotImplementedByEngineError();
}
@@ -1775,7 +1931,11 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
participants: Array<Participant>,
action: messages.ParticipantAction,
): Promise<any> {
const jids = participants.map((p) => normalizeJid(toJID(p.id)));
const jids = await Promise.all(
participants.map((p) =>
this.hooks.wid.chat.promise(p.id, 'updateParticipants'),
),
);
const req = new messages.UpdateParticipantsRequest({
session: this.session,
jid: id,
@@ -1828,7 +1988,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
@Activity()
async sendEvent(request: EventMessageRequest): Promise<WAMessage> {
const jid = normalizeJid(toJID(this.ensureSuffix(request.chatId)));
const jid = await this.hooks.wid.chat.promise(request.chatId, 'sendEvent');
const event = request.event;
// Create EventLocation if provided
@@ -1884,7 +2044,10 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
public async setPresence(presence: WAHAPresenceStatus, chatId?: string) {
let request: any;
let method: any;
const jid = chatId ? normalizeJid(toJID(this.ensureSuffix(chatId))) : null;
let jid: string | null = null;
if (chatId) {
jid = await this.hooks.wid.chat.promise(chatId, 'setPresence');
}
switch (presence) {
case WAHAPresenceStatus.ONLINE:
request = new messages.PresenceRequest({
@@ -1901,7 +2064,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
method = this.client.SendPresence;
break;
case WAHAPresenceStatus.TYPING:
await this.maintainPresenceOnline();
await this.hooks.activity.promise('setPresence');
request = new messages.ChatPresenceRequest({
session: this.session,
jid: jid,
@@ -1910,7 +2073,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
method = this.client.SendChatPresence;
break;
case WAHAPresenceStatus.RECORDING:
await this.maintainPresenceOnline();
await this.hooks.activity.promise('setPresence');
request = new messages.ChatPresenceRequest({
session: this.session,
jid: jid,
@@ -1919,7 +2082,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
method = this.client.SendChatPresence;
break;
case WAHAPresenceStatus.PAUSED:
await this.maintainPresenceOnline();
await this.hooks.activity.promise('setPresence');
request = new messages.ChatPresenceRequest({
session: this.session,
jid: jid,
@@ -1945,7 +2108,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
}
public async getPresence(chatId: string): Promise<WAHAChatPresences> {
const jid = normalizeJid(toJID(chatId));
const jid = await this.hooks.wid.chat.promise(chatId, 'getPresence');
await this.subscribePresence(jid);
if (!(jid in this.presences.keys())) {
await sleep(1000);
@@ -1956,7 +2119,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
@Activity()
async subscribePresence(chatId: string) {
const jid = normalizeJid(toJID(chatId));
const jid = await this.hooks.wid.chat.promise(chatId, 'subscribePresence');
const req = new messages.SubscribePresenceRequest({
session: this.session,
jid: jid,
@@ -2080,7 +2243,6 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
inviteCode: string,
query: PreviewChannelMessages,
): Promise<ChannelMessage[]> {
const downloadMedia = query.downloadMedia;
const request = new messages.GetNewsletterMessagesByInviteRequest({
session: this.session,
invite: inviteCode,
@@ -2094,12 +2256,17 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
if (!resp.Messages) {
return [];
}
const params = {
download: query.downloadMedia,
mimetypes: query.downloadMediaMimetypes,
};
const options = lodash.defaults({}, params, this.media.api);
for (const msg of resp.Messages) {
promises.push(
this.GowsChannelMessageToChannelMessage(
resp.NewsletterJID,
msg,
downloadMedia,
options,
),
);
}
@@ -2111,7 +2278,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
private async GowsChannelMessageToChannelMessage(
jid: string,
channelMessage: any,
downloadMedia: boolean,
options: MediaDownloadOptions,
): Promise<ChannelMessage> {
const msg = {
Info: {
@@ -2124,7 +2291,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
},
Message: channelMessage.Message,
};
const message = await this.processIncomingMessage(msg, downloadMedia);
const message = await this.processIncomingMessage(msg, options);
const reactions: any =
sortObjectByValues(channelMessage.ReactionCounts) || {};
return {
@@ -2158,6 +2325,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
};
}
@Activity()
public async channelsList(query: ListChannelsQuery): Promise<Channel[]> {
const request = new messages.NewsletterListRequest({
session: this.session,
@@ -2259,7 +2427,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
*/
@Activity()
public async upsertContact(chatId: string, body: ContactUpdateBody) {
const jid = normalizeJid(toJID(chatId));
const jid = await this.hooks.wid.chat.promise(chatId, 'upsertContact');
const request = new messages.UpdateContactRequest({
session: this.session,
jid: jid,
@@ -2279,7 +2447,10 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
}
public async getContact(query: ContactQuery) {
const jid = normalizeJid(toJID(query.contactId));
const jid = await this.hooks.wid.chat.promise(
query.contactId,
'getContact',
);
const request = new messages.EntityByIdRequest({
session: this.session,
id: jid,
@@ -2374,7 +2545,10 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
public async findLIDByPhoneNumber(
phoneNumber: string,
): Promise<LidToPhoneNumber> {
const pn = normalizeJid(toJID(phoneNumber));
const pn = await this.hooks.wid.chat.promise(
phoneNumber,
'findLIDByPhoneNumber',
);
const request = new messages.EntityByIdRequest({
session: this.session,
id: pn,
@@ -2453,7 +2627,9 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
}
let jids = [];
if (filter?.ids && filter.ids.length > 0) {
jids = filter.ids.map((id) => normalizeJid(toJID(id)));
jids = await Promise.all(
filter.ids.map((id) => this.hooks.wid.chat.promise(id, 'getChats')),
);
}
const request = new messages.GetChatsRequest({
session: this.session,
@@ -2483,14 +2659,17 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
query: GetChatMessagesQuery,
filter: GetChatMessagesFilter,
) {
const downloadMedia = query.downloadMedia;
const merge = query.merge ?? true;
let jid: messages.OptionalString;
if (chatId === 'all') {
jid = null;
} else {
const value = await this.hooks.wid.chat.promise(
chatId,
'getChatMessages',
);
jid = new messages.OptionalString({
value: normalizeJid(toJID(this.ensureSuffix(chatId))),
value: value,
});
}
@@ -2527,8 +2706,13 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
const response = await promisify(this.client.GetMessages)(request);
const msgs = parseJsonList(response);
const promises = [];
const params = {
download: query.downloadMedia,
mimetypes: query.downloadMediaMimetypes,
};
const options = lodash.defaults({}, params, this.media.api);
for (const msg of msgs) {
promises.push(this.processIncomingMessage(msg, downloadMedia));
promises.push(this.processIncomingMessage(msg, options));
}
let result = await Promise.all(promises);
result = result.filter(Boolean);
@@ -2555,7 +2739,12 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
});
const response = await promisify(this.client.GetMessageById)(request);
const msg = parseJson(response);
return this.processIncomingMessage(msg, query.downloadMedia);
const params = {
download: query.downloadMedia,
mimetypes: query.downloadMediaMimetypes,
};
const options = lodash.defaults({}, params, this.media.api);
return this.processIncomingMessage(msg, options);
}
/**
@@ -2639,7 +2828,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
}
public async getChatLabels(chatId: string): Promise<Label[]> {
const jid = normalizeJid(toJID(chatId));
const jid = await this.hooks.wid.chat.promise(chatId, 'getChatLabels');
const request = new messages.EntityByIdRequest({
session: this.session,
id: jid,
@@ -2651,7 +2840,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
@Activity()
public async chatsUnreadChat(chatId: string): Promise<any> {
const jid = normalizeJid(toJID(this.ensureSuffix(chatId)));
const jid = await this.hooks.wid.chat.promise(chatId, 'chatsUnreadChat');
const request = new messages.ChatUnreadRequest({
session: this.session,
jid: jid,
@@ -2663,7 +2852,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
@Activity()
public async putLabelsToChat(chatId: string, labels: LabelID[]) {
const jid = normalizeJid(toJID(chatId));
const jid = await this.hooks.wid.chat.promise(chatId, 'putLabelsToChat');
const labelsIds = labels.map((label) => label.id);
const currentLabels = await this.getChatLabels(jid);
const currentLabelsIds = currentLabels.map((label) => label.id);
@@ -2719,19 +2908,19 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return true;
}
protected async processIncomingMessage(message, downloadMedia = true) {
protected async processIncomingMessage(
message,
options: MediaDownloadOptions,
) {
// Filter
if (!this.shouldProcessIncomingMessage(message)) {
return null;
}
// Convert
const wamessage = this.toWAMessage(message);
// Media
if (downloadMedia) {
const media = await this.downloadMediaSafe(message);
wamessage.media = media;
}
if (downloadMedia && wamessage.replyTo?.hasMedia) {
const media = await this.downloadMediaSafe(message, options);
wamessage.media = media;
if (wamessage.replyTo?.hasMedia) {
const msg = {
Message: wamessage.replyTo._data,
Info: {
@@ -2739,14 +2928,23 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
ID: wamessage.replyTo.id || '',
},
};
wamessage.replyTo.media = await this.downloadMediaSafe(msg);
wamessage.replyTo.media = await this.downloadMediaSafe(msg, options);
}
return wamessage;
}
protected async downloadMediaSafe(message) {
protected async downloadMediaSafe(message, options: MediaDownloadOptions) {
try {
return await this.downloadMedia(message);
let processor: IMediaEngineProcessor<any> = new GOWSEngineMediaProcessor(
this,
);
processor = new LottieMediaProcessorWrapper(processor, this.logger);
const media = await this.mediaManager.processMedia(
processor,
message,
options,
);
return media;
} catch (e) {
this.logger.error('Failed when tried to download media for a message');
this.logger.error(e, e.stack);
@@ -2754,19 +2952,6 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
}
}
protected async downloadMedia(message) {
let processor: IMediaEngineProcessor<any> = new GOWSEngineMediaProcessor(
this,
);
processor = new LottieMediaProcessorWrapper(processor, this.logger);
const media = await this.mediaManager.processMedia(
processor,
message,
this.name,
);
return media;
}
protected toWAMessage(message): WAMessage {
const fromToParticipant = getFromToParticipant(message);
const id = buildMessageId(message);
@@ -3162,7 +3347,15 @@ export class GOWSEngineMediaProcessor implements IMediaEngineProcessor<any> {
return content.mimetype;
}
async getMediaBuffer(message: any): Promise<Buffer | null> {
async getMediaContent(message: any): Promise<MediaContent | null> {
const buffer = await this.getMediaBuffer(message);
if (!buffer) {
return null;
}
return { buffer: buffer };
}
private async getMediaBuffer(message: any): Promise<Buffer | null> {
const mediaDownloadTimeoutMs = 600_000; // 10 minutes
message = normalizeGowsStickerUrlForDownload(message);
+22
View File
@@ -89,6 +89,7 @@ export interface GOWSGroupParticipant {
PhoneNumber: JID;
IsAdmin: boolean;
IsSuperAdmin: boolean;
Error?: number; // set when the participant action failed
}
export interface GroupInfoFull {
@@ -107,3 +108,24 @@ export interface JoinedGroupEvent extends GroupInfoFull {
Reason: string;
Type: string;
}
export interface GOWSGroupJoinRequest {
JID: JID;
PhoneNumber: JID; // empty string if not provided
RequestMethod: string; // invite_link | linked_group_join | non_admin_add
}
export interface GroupJoinRequestEvent {
JID: JID;
Sender?: JID;
SenderPN?: JID;
Timestamp: string;
Action: string; // created | revoked
Requests: GOWSGroupJoinRequest[];
}
// GetGroupRequestParticipants RPC result item
export interface GOWSGroupParticipantRequest {
JID: JID;
RequestedAt: string;
}
+88
View File
@@ -7,13 +7,18 @@ import type {
import { getGroupInviteLink } from '@waha/core/abc/session.abc';
import {
GroupInfo,
GroupJoinRequest,
GroupJoinRequestResult,
GroupParticipant,
GroupParticipantRole,
NormalizeJoinRequestMethod,
} from '@waha/structures/groups.dto';
import {
GroupParticipantsJoinRequestAction,
GroupParticipantType,
GroupV2JoinEvent,
GroupV2LeaveEvent,
GroupV2ParticipantsJoinRequestEvent,
GroupV2ParticipantsEvent,
GroupV2UpdateEvent,
} from '@waha/structures/groups.events.dto';
@@ -164,6 +169,89 @@ export function ToGroupV2UpdateEvent(
};
}
// Raw <membership_approval_request> node attrs from groupRequestParticipantsList
interface MembershipApprovalRequestAttrs {
jid?: string;
phone_number?: string;
request_method?: string;
request_time?: string;
t?: string;
}
export function ToGroupJoinRequest(
attrs: MembershipApprovalRequestAttrs,
): GroupJoinRequest {
return {
requesterId: toCusFormat(attrs.jid),
requesterPn: toCusFormat(attrs.phone_number) || null,
addedById: null,
parentGroupId: null,
requestMethod: NormalizeJoinRequestMethod(attrs.request_method),
timestamp: Number(attrs.request_time || attrs.t) || 0,
};
}
export function ToGroupJoinRequestResult(result: {
status: string;
jid: string | undefined;
}): GroupJoinRequestResult {
const success = result.status === '200';
let error: number | undefined;
if (!success) {
error = Number(result.status) || undefined;
}
return {
requesterId: toCusFormat(result.jid) || null,
success: success,
error: error,
};
}
interface GroupJoinRequestUpdate {
id: string;
author: string;
authorPn?: string;
participant: string;
participantPn?: string;
action: string;
method?: string;
}
function ToGroupParticipantsJoinRequestAction(
action: string,
): GroupParticipantsJoinRequestAction | null {
switch (action) {
case 'created':
return GroupParticipantsJoinRequestAction.CREATED;
case 'rejected':
return GroupParticipantsJoinRequestAction.REJECTED;
case 'revoked':
return GroupParticipantsJoinRequestAction.REVOKED;
default:
return null;
}
}
export function ToGroupV2ParticipantsJoinRequestEvent(
update: GroupJoinRequestUpdate,
): GroupV2ParticipantsJoinRequestEvent | null {
const action = ToGroupParticipantsJoinRequestAction(update.action);
if (!action) {
return null;
}
return {
group: {
id: toCusFormat(update.id),
},
action: action,
requesterId: toCusFormat(update.participant),
requesterPn: toCusFormat(update.participantPn) || null,
requestMethod: NormalizeJoinRequestMethod(update.method),
timestamp: Date.now(),
_data: update,
};
}
export function ToGroupV2LeaveEvent(
me: Contact,
update: GroupParticipantUpdate,
+314 -120
View File
@@ -51,9 +51,12 @@ import {
WhatsappSession,
} from '@waha/core/abc/session.abc';
import {
ToGroupJoinRequest,
ToGroupJoinRequestResult,
ToGroupParticipant,
ToGroupV2JoinEvent,
ToGroupV2LeaveEvent,
ToGroupV2ParticipantsJoinRequestEvent,
ToGroupV2Participants,
ToGroupV2UpdateEvent,
} from '@waha/core/engines/noweb/groups.noweb';
@@ -68,7 +71,11 @@ import { NotImplementedByEngineError } from '@waha/core/exceptions';
import { toVcardV3 } from '@waha/core/vcard';
import { createAgentProxy } from '@waha/core/helpers.proxy';
import type { Agent } from 'https';
import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
import {
IMediaEngineProcessor,
MediaContent,
} from '@waha/core/media/IMediaEngineProcessor';
import { MediaDownloadOptions } from '@waha/core/media/IMediaManager';
import { LottieMediaProcessorWrapper } from '@waha/core/media/LottieMediaProcessorWrapper';
import { QR } from '@waha/core/QR';
import { AckToStatus, StatusToAck } from '@waha/core/utils/acks';
@@ -130,6 +137,7 @@ import {
MessageReactionRequest,
MessageReplyRequest,
MessageStarRequest,
MessageStickerRequest,
MessageTextRequest,
MessageVideoRequest,
MessageVoiceRequest,
@@ -154,9 +162,12 @@ import {
import { BinaryFile, FileType, RemoteFile } from '@waha/structures/files.dto';
import {
CreateGroupRequest,
GroupJoinRequest,
GroupJoinRequestResult,
GroupParticipant,
ParticipantsRequest,
SettingsMemberAddMode,
SettingsMembershipApproval,
SettingsSecurityChangeInfo,
} from '@waha/structures/groups.dto';
import {
@@ -173,7 +184,11 @@ import {
WAHAChatPresences,
WAHAPresenceData,
} from '@waha/structures/presence.dto';
import { WAMessage, WAMessageReaction } from '@waha/structures/responses.dto';
import {
MessageSource,
WAMessage,
WAMessageReaction,
} from '@waha/structures/responses.dto';
import {
MeInfo,
MessageCappingData,
@@ -208,6 +223,7 @@ import * as lodash from 'lodash';
import * as NodeCache from 'node-cache';
import {
filter,
concatMap,
fromEvent,
groupBy,
identity,
@@ -225,7 +241,7 @@ import { NowebClient } from './NowebClient';
import { INowebStore } from './store/INowebStore';
import { NowebPersistentStore } from './store/NowebPersistentStore';
import { NowebStorageFactoryCore } from './store/NowebStorageFactoryCore';
import { ensureNumber, extractMediaContent } from './utils';
import { buildMessageId, ensureNumber, extractMediaContent } from './utils';
import { Agents } from '@waha/core/engines/noweb/types';
import {
IsEditedMessage,
@@ -239,7 +255,6 @@ import {
} from '@waha/core/utils/secretEncryptedMessageEdit';
import { extractWALocation } from '@waha/core/engines/waproto/locaiton';
import { extractVCards } from '@waha/core/engines/waproto/vcards';
import { Activity } from '@waha/core/abc/activity';
import { WAMimeType } from '@waha/core/media/WAMimeType';
import {
WAHA_CLIENT_BROWSER_NAME,
@@ -250,6 +265,8 @@ import esm from '@waha/vendor/esm';
import axios from 'axios';
import axiosRetry from 'axios-retry';
import { formatWaVersion } from '@waha/core/engines/noweb/waversion';
import { Activity } from '@waha/core/abc/session.hooks.activity';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const promiseRetry = require('promise-retry');
@@ -895,7 +912,6 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
}
private async end() {
this.cleanupPresenceTimeout();
this.presence = null;
this.autoRestartJob.stop();
const sock = this.sock;
@@ -1031,6 +1047,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
return true;
}
@Activity()
protected async deleteProfilePicture(): Promise<boolean> {
const me = this.getSessionMeInfo();
await this.sock.removeProfilePicture(me.id);
@@ -1095,16 +1112,27 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
@Activity()
async rejectCall(from: string, id: string): Promise<void> {
const jid = toJID(this.ensureSuffix(from));
const jid = await this.hooks.wid.chat.promise(from, 'rejectCall');
await this.sock.rejectCall(id, jid);
}
@Activity()
async sendText(request: MessageTextRequest) {
const chatId = toJID(this.ensureSuffix(request.chatId));
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'sendText',
);
let mentions: string[] | undefined;
if (request.mentions) {
mentions = await Promise.all(
request.mentions.map((mention) =>
this.hooks.wid.mention.promise(mention, 'sendText'),
),
);
}
const message = {
text: request.text,
mentions: request.mentions?.map(toJID),
mentions: mentions,
linkPreview: this.getLinkPreview(request),
};
const options: any = await this.getMessageOptions(request);
@@ -1113,8 +1141,8 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
}
@Activity()
public deleteMessage(chatId: string, messageId: string) {
const jid = toJID(this.ensureSuffix(chatId));
public async deleteMessage(chatId: string, messageId: string) {
const jid = await this.hooks.wid.chat.promise(chatId, 'deleteMessage');
const key = parseMessageIdSerialized(messageId);
const options = {
messageId: this.generateMessageID(),
@@ -1128,7 +1156,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
messageId: string,
request: EditMessageRequest,
) {
const jid = toJID(this.ensureSuffix(chatId));
const jid = await this.hooks.wid.chat.promise(chatId, 'editMessage');
const key = parseMessageIdSerialized(messageId);
const stored = await this.store
?.loadMessage(key.remoteJid, key.id)
@@ -1164,9 +1192,17 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
},
};
}
let mentions: string[] | undefined;
if (request.mentions) {
mentions = await Promise.all(
request.mentions.map((mention) =>
this.hooks.wid.mention.promise(mention, 'editMessage'),
),
);
}
let message: any = {
text: request.text,
mentions: request.mentions?.map(toJID),
mentions: mentions,
edit: key,
editedMessage: editedMessage,
linkPreview: this.getLinkPreview(request),
@@ -1184,7 +1220,10 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
@Activity()
async sendContactVCard(request: MessageContactVcardRequest) {
const chatId = toJID(this.ensureSuffix(request.chatId));
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'sendContactVCard',
);
const contacts = request.contacts.map((el) => ({ vcard: toVcardV3(el) }));
const options = await this.getMessageOptions(request);
const msg = { contacts: { contacts: contacts } };
@@ -1202,20 +1241,32 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
: 1,
};
const message = { poll: poll };
const remoteJid = toJID(request.chatId);
const remoteJid = await this.hooks.wid.chat.promise(
request.chatId,
'sendPoll',
);
const options = await this.getMessageOptions(request);
const result = await this.sock.sendMessage(remoteJid, message, options);
return this.toWAMessage(result);
return await this.toWAMessage(result);
}
@Activity()
async reply(request: MessageReplyRequest) {
const chatId = await this.hooks.wid.chat.promise(request.chatId, 'reply');
const options = await this.getMessageOptions(request);
let mentions: string[] | undefined;
if (request.mentions) {
mentions = await Promise.all(
request.mentions.map((mention) =>
this.hooks.wid.mention.promise(mention, 'reply'),
),
);
}
const message = {
text: request.text,
mentions: request.mentions?.map(toJID),
mentions: mentions,
};
return await this.sock.sendMessage(request.chatId, message, options);
return await this.sock.sendMessage(chatId, message, options);
}
@Activity()
@@ -1226,7 +1277,10 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
request.caption,
);
message.mimetype = message.mimetype || WAMimeType.IMAGE;
const chatId = toJID(this.ensureSuffix(request.chatId));
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'sendImage',
);
// Baileys' newsletter media path skips thumbnail and dimension computation.
// Pre-compute them so iOS renders the image with the correct aspect ratio.
if (isJidNewsletter(chatId)) {
@@ -1243,7 +1297,11 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
}
}
if (request.mentions?.length) {
message.mentions = request.mentions.map((mention) => toJID(mention));
message.mentions = await Promise.all(
request.mentions.map((mention) =>
this.hooks.wid.mention.promise(mention, 'sendImage'),
),
);
}
const options = await this.getMessageOptions(request);
return this.sock.sendMessage(chatId, message, options);
@@ -1260,9 +1318,16 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
message.mimetype = await detectMimetype(message['document']);
}
if (request.mentions?.length) {
message.mentions = request.mentions.map((mention) => toJID(mention));
message.mentions = await Promise.all(
request.mentions.map((mention) =>
this.hooks.wid.mention.promise(mention, 'sendFile'),
),
);
}
const chatId = toJID(this.ensureSuffix(request.chatId));
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'sendFile',
);
const options = await this.getMessageOptions(request);
return this.sock.sendMessage(chatId, message, options);
}
@@ -1275,7 +1340,10 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
message['audio'] = await this.mediaConverter.voice(message['audio']);
message.mimetype = WAMimeType.VOICE;
}
const chatId = toJID(this.ensureSuffix(request.chatId));
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'sendVoice',
);
const options = await this.getMessageOptions(request);
return this.sock.sendMessage(chatId, message, options);
}
@@ -1293,7 +1361,11 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
message.mimetype = WAMimeType.VIDEO;
}
if (request.mentions?.length) {
message.mentions = request.mentions.map((mention) => toJID(mention));
message.mentions = await Promise.all(
request.mentions.map((mention) =>
this.hooks.wid.mention.promise(mention, 'sendVideo'),
),
);
}
const duration = await esm.b
.getAudioDuration(message['video'])
@@ -1307,17 +1379,35 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
message.gifPlayback = true;
message.externalShareFullVideoDurationInSeconds = 0;
}
const chatId = toJID(this.ensureSuffix(request.chatId));
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'sendVideo',
);
const options = await this.getMessageOptions(request);
message.ptv = parseBool(request.asNote);
return this.sock.sendMessage(chatId, message, options);
}
@Activity()
async sendSticker(request: MessageStickerRequest) {
const message: any = await this.fileToMessage(request.file, 'sticker');
message.mimetype = message.mimetype || WAMimeType.STICKER;
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'sendSticker',
);
const options = await this.getMessageOptions(request);
return this.sock.sendMessage(chatId, message, options);
}
@Activity()
async sendLinkCustomPreview(
request: MessageLinkCustomPreviewRequest,
): Promise<any> {
const chatId = toJID(this.ensureSuffix(request.chatId));
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'sendLinkCustomPreview',
);
const options = await this.getMessageOptions(request);
const preview = request.preview;
const urlInfo = {
@@ -1433,7 +1523,10 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
@Activity()
async sendButtons(request: SendButtonsRequest) {
const chatId = toJID(this.ensureSuffix(request.chatId));
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'sendButtons',
);
const headerImage = await this.uploadMedia(request.headerImage, 'image');
return await sendButtonMessage(
this.sock,
@@ -1448,7 +1541,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
@Activity()
async sendList(request: SendListRequest): Promise<any> {
const jid = toJID(this.ensureSuffix(request.chatId));
const jid = await this.hooks.wid.chat.promise(request.chatId, 'sendList');
if (!isLidUser(jid) && !isPnUser(jid)) {
throw new UnprocessableEntityException(
`List message can only be sent to a direct message chat.`,
@@ -1468,7 +1561,10 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
@Activity()
async sendLocation(request: MessageLocationRequest) {
const chatId = toJID(this.ensureSuffix(request.chatId));
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'sendLocation',
);
const msg = {
location: {
name: request.title || null,
@@ -1489,20 +1585,26 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
`Message with id '${request.messageId}' not found`,
);
}
const chatId = toJID(this.ensureSuffix(request.chatId));
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'forwardMessage',
);
const message = {
forward: forwardMessage,
force: true,
};
const options = await this.getMessageOptions(request);
const result = await this.sock.sendMessage(chatId, message as any, options);
return this.toWAMessage(result);
return await this.toWAMessage(result);
}
@Activity()
async sendLinkPreview(request: MessageLinkPreviewRequest) {
const text = `${request.title}\n${request.url}`;
const chatId = toJID(this.ensureSuffix(request.chatId));
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'sendLinkPreview',
);
const msg = { text: text };
const options = await this.getMessageOptions(request);
return this.sock.sendMessage(chatId, msg, options);
@@ -1528,13 +1630,19 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
@Activity()
async startTyping(request: ChatRequest): Promise<void> {
const chatId = toJID(this.ensureSuffix(request.chatId));
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'startTyping',
);
await this.sock.sendPresenceUpdate('composing', chatId);
}
@Activity()
async stopTyping(request: ChatRequest) {
const chatId = toJID(this.ensureSuffix(request.chatId));
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'stopTyping',
);
return this.sock.sendPresenceUpdate('paused', chatId);
}
@@ -1543,19 +1651,24 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
query: GetChatMessagesQuery,
filter: GetChatMessagesFilter,
) {
const downloadMedia = query.downloadMedia;
const pagination = query as PaginationParams;
const merge = query.merge ?? true;
const jid = await this.hooks.wid.chat.promise(chatId, 'getChatMessages');
const messages = await this.store.getMessagesByJid(
toJID(chatId),
jid,
filter,
pagination,
merge,
);
const promises = [];
const params = {
download: query.downloadMedia,
mimetypes: query.downloadMediaMimetypes,
};
const options = lodash.defaults({}, params, this.media.api);
for (const msg of messages) {
promises.push(this.processIncomingMessage(msg, downloadMedia));
promises.push(this.processIncomingMessage(msg, options));
}
let result = await Promise.all(promises);
result = result.filter(Boolean);
@@ -1577,13 +1690,15 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
): Promise<null | WAMessage> {
const key = parseMessageIdSerialized(messageId, true);
const merge = query.merge ?? true;
const message = await this.store.getMessageById(
toJID(chatId),
key.id,
merge,
);
const jid = await this.hooks.wid.chat.promise(chatId, 'getChatMessage');
const message = await this.store.getMessageById(jid, key.id, merge);
if (!message) return null;
return await this.processIncomingMessage(message, query.downloadMedia);
const params = {
download: query.downloadMedia,
mimetypes: query.downloadMediaMimetypes,
};
const options = lodash.defaults({}, params, this.media.api);
return await this.processIncomingMessage(message, options);
}
@Activity()
@@ -1592,7 +1707,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
messageId: string,
duration: PinDuration,
): Promise<boolean> {
const jid = toJID(chatId);
const jid = await this.hooks.wid.chat.promise(chatId, 'pinMessage');
const key = parseMessageIdSerialized(messageId);
await this.sock.sendMessage(jid, {
pin: key,
@@ -1607,7 +1722,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
chatId: string,
messageId: string,
): Promise<boolean> {
const jid = toJID(chatId);
const jid = await this.hooks.wid.chat.promise(chatId, 'unpinMessage');
const key = parseMessageIdSerialized(messageId);
await this.sock.sendMessage(jid, {
pin: key,
@@ -1652,6 +1767,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
@Activity()
async setStar(request: MessageStarRequest) {
const key = parseMessageIdSerialized(request.messageId);
const jid = await this.hooks.wid.chat.promise(request.chatId, 'setStar');
await this.sock.chatModify(
{
star: {
@@ -1659,7 +1775,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
star: request.star,
},
},
toJID(request.chatId),
jid,
);
}
@@ -1683,8 +1799,13 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
// Convert customer format IDs to JID format if filter is provided
let jidFilter;
if (filter?.ids && filter.ids.length > 0) {
const ids = await Promise.all(
filter.ids.map((id) =>
this.hooks.wid.chat.promise(id, 'getChatsOverview'),
),
);
jidFilter = {
ids: filter.ids.map((id) => toJID(id)),
ids: ids,
};
}
@@ -1740,7 +1861,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
chatId: string,
archive: boolean,
): Promise<any> {
const jid = toJID(chatId);
const jid = await this.hooks.wid.chat.promise(chatId, 'chatsPutArchive');
const messages = await this.store.getMessagesByJid(jid, {}, { limit: 1 });
return await this.sock.chatModify(
{ archive: archive, lastMessages: messages },
@@ -1760,7 +1881,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
@Activity()
public async chatsUnreadChat(chatId: string): Promise<any> {
const jid = toJID(chatId);
const jid = await this.hooks.wid.chat.promise(chatId, 'chatsUnreadChat');
const messages = await this.store.getMessagesByJid(jid, {}, { limit: 1 });
return await this.sock.chatModify(
{ markRead: false, lastMessages: messages },
@@ -1834,14 +1955,14 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
}
public async getChatLabels(chatId: string): Promise<Label[]> {
const jid = toJID(chatId);
const jid = await this.hooks.wid.chat.promise(chatId, 'getChatLabels');
const labels = await this.store.getChatLabels(jid);
return labels.map(this.toLabel);
}
@Activity()
public async putLabelsToChat(chatId: string, labels: LabelID[]) {
const jid = toJID(chatId);
const jid = await this.hooks.wid.chat.promise(chatId, 'putLabelsToChat');
const labelsIds = labels.map((label) => label.id);
const currentLabels = await this.store.getChatLabels(jid);
const currentLabelsIds = currentLabels.map((label) => label.id);
@@ -1883,7 +2004,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
@Activity()
public async upsertContact(chatId: string, body: ContactUpdateBody) {
const jid = toJID(chatId);
const jid = await this.hooks.wid.chat.promise(chatId, 'upsertContact');
let fullName = body.firstName;
if (body.lastName) {
fullName = `${body.firstName} ${body.lastName}`;
@@ -1904,7 +2025,10 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
}
async getContact(query: ContactQuery) {
const jid = toJID(query.contactId);
const jid = await this.hooks.wid.chat.promise(
query.contactId,
'getContact',
);
const contact = await this.store.getContactById(jid);
if (!contact) {
return null;
@@ -1919,7 +2043,10 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
@Activity()
public async fetchContactProfilePicture(id: string) {
const contact = this.ensureSuffix(id);
const contact = await this.hooks.wid.chat.promise(
id,
'fetchContactProfilePicture',
);
try {
const url = await this.sock.profilePictureUrl(contact, 'image');
return url;
@@ -1972,7 +2099,10 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
public async findLIDByPhoneNumber(
phoneNumber: string,
): Promise<LidToPhoneNumber> {
const pn = toJID(phoneNumber);
const pn = await this.hooks.wid.chat.promise(
phoneNumber,
'findLIDByPhoneNumber',
);
const lid = await this.store.findLidByPN(pn);
return {
lid: lid || null,
@@ -2086,6 +2216,59 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
return await this.sock.groupMemberAddMode(id, mode);
}
public async getMembershipApprovalMode(
id: string,
): Promise<SettingsMembershipApproval> {
const group = await this.getGroup(id);
return { newMembersApprovalRequired: !!group.joinApprovalMode };
}
@Activity()
public async setMembershipApprovalMode(
id: string,
value: boolean,
): Promise<boolean> {
const mode = value ? 'on' : 'off';
await this.sock.groupJoinApprovalMode(id, mode);
return true;
}
@Activity()
public async getGroupJoinRequests(id: string): Promise<GroupJoinRequest[]> {
const requests = await this.sock.groupRequestParticipantsList(id);
return requests.map(ToGroupJoinRequest);
}
@Activity()
public async approveGroupJoinRequests(
id: string,
request: ParticipantsRequest,
): Promise<GroupJoinRequestResult[]> {
return await this.updateGroupJoinRequests(id, request, 'approve');
}
@Activity()
public async rejectGroupJoinRequests(
id: string,
request: ParticipantsRequest,
): Promise<GroupJoinRequestResult[]> {
return await this.updateGroupJoinRequests(id, request, 'reject');
}
private async updateGroupJoinRequests(
id: string,
request: ParticipantsRequest,
action: 'approve' | 'reject',
): Promise<GroupJoinRequestResult[]> {
const participants = request.participants.map(getId);
const results = await this.sock.groupRequestParticipantsUpdate(
id,
participants,
action,
);
return results.map(ToGroupJoinRequestResult);
}
@Activity()
public async leaveGroup(id) {
return this.sock.groupLeave(id);
@@ -2146,7 +2329,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
case WAHAPresenceStatus.TYPING:
case WAHAPresenceStatus.RECORDING:
case WAHAPresenceStatus.PAUSED:
await this.maintainPresenceOnline();
await this.hooks.activity.promise('setPresence');
}
const enginePresence = ToEnginePresenceStatus[presence];
if (!enginePresence) {
@@ -2155,7 +2338,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
);
}
if (chatId) {
chatId = toJID(this.ensureSuffix(chatId));
chatId = await this.hooks.wid.chat.promise(chatId, 'setPresence');
}
await this.sock.sendPresenceUpdate(enginePresence, chatId);
this.presence = presence;
@@ -2171,7 +2354,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
}
public async getPresence(chatId: string): Promise<WAHAChatPresences> {
const jid = toJID(chatId);
const jid = await this.hooks.wid.chat.promise(chatId, 'getPresence');
await this.subscribePresence(jid);
if (!(jid in this.store.presences)) {
this.store.presences[jid] = {};
@@ -2182,8 +2365,8 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
}
@Activity()
public subscribePresence(id: string): Promise<void> {
const jid = toJID(id);
public async subscribePresence(id: string): Promise<void> {
const jid = await this.hooks.wid.chat.promise(id, 'subscribePresence');
return this.sock.presenceSubscribe(jid);
}
@@ -2361,7 +2544,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
protected prepareMessageIdForStatus(status: StatusRequest) {
if (status.id) {
this.saveSentMessageId(status.id);
this.hooks.message.sent.call(status.id);
return status.id;
}
return this.generateMessageID();
@@ -2370,7 +2553,11 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
protected async prepareJidsForStatus(contacts: string[]) {
let jids: string[];
if (contacts?.length > 0) {
jids = contacts.map(toJID);
jids = await Promise.all(
contacts.map((contact) =>
this.hooks.wid.chat.promise(contact, 'prepareJidsForStatus'),
),
);
} else {
jids = await this.fetchMyContactsJids();
}
@@ -2451,7 +2638,6 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
inviteCode: string,
query: PreviewChannelMessages,
): Promise<ChannelMessage[]> {
const downloadMedia = query.downloadMedia;
const updates = await this.sock.newsletterFetchPreviewMessages(
'invite',
inviteCode,
@@ -2459,9 +2645,14 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
null,
);
const promises = [];
const params = {
download: query.downloadMedia,
mimetypes: query.downloadMediaMimetypes,
};
const options = lodash.defaults({}, params, this.media.api);
for (const update of updates) {
promises.push(
this.NewsletterFetchedUpdateToChannelMessage(update, downloadMedia),
this.NewsletterFetchedUpdateToChannelMessage(update, options),
);
}
let result = await Promise.all(promises);
@@ -2471,16 +2662,13 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
private async NewsletterFetchedUpdateToChannelMessage(
update: NewsletterFetchedUpdate,
downloadMedia: boolean,
options: MediaDownloadOptions,
): Promise<ChannelMessage> {
let reactions: any = Object.fromEntries(
update.reactions.map(({ code, count }) => [code, count]),
);
reactions = sortObjectByValues(reactions) || {};
const message = await this.processIncomingMessage(
update.message,
downloadMedia,
);
const message = await this.processIncomingMessage(update.message, options);
return {
message: message,
reactions: reactions,
@@ -2512,6 +2700,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
};
}
@Activity()
public async channelsList(query: ListChannelsQuery): Promise<Channel[]> {
const newsletters = await this.sock.newsletterSubscribed();
let channels = newsletters
@@ -2550,6 +2739,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
return channel;
}
@Activity()
public async channelsGetChannel(id: string) {
const newsletter = await this.sock.newsletterMetadata('jid', id);
return this.toChannel(toNewsletterMetadata(newsletter));
@@ -2615,13 +2805,13 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
isMine,
);
messagesFromMe$ = messagesFromMe$.pipe(
mergeMap((msg) => this.processIncomingMessage(msg, true)),
mergeMap((msg) => this.processIncomingMessage(msg, this.media.events)),
filter(Boolean),
DistinctMessages(),
share(), // share it so we don't process twice in message.any
);
messagesFromOthers$ = messagesFromOthers$.pipe(
mergeMap((msg) => this.processIncomingMessage(msg, true)),
mergeMap((msg) => this.processIncomingMessage(msg, this.media.events)),
filter(Boolean),
DistinctMessages(),
share(), // share it so we don't process twice in message.any
@@ -2638,7 +2828,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
proto.Message.ProtocolMessage.Type.REVOKE,
),
mergeMap(async (message): Promise<WAMessageRevokedBody> => {
const afterMessage = this.toWAMessage(message);
const afterMessage = await this.toWAMessage(message);
// Extract the revoked message ID from protocolMessage.key
const revokedMessageId = message.message.protocolMessage.key?.id;
return {
@@ -2659,7 +2849,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
IsSecretEncryptedMessageEdit(message.message),
),
mergeMap(async (message): Promise<WAMessageEditedBody> => {
const waMessage = this.toWAMessage(message);
const waMessage = await this.toWAMessage(message);
let body = '';
let editedMessageId: string | undefined;
if (IsEditedMessage(message.message)) {
@@ -2686,7 +2876,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
// Message Reactions
//
const messageReactions$ = messagesUpsert$.pipe(
map(this.processMessageReaction.bind(this)),
concatMap((message) => this.processMessageReaction(message)),
filter(Boolean),
);
this.events2.get(WAHAEvents.MESSAGE_REACTION).switch(messageReactions$);
@@ -2781,6 +2971,17 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
);
this.events2.get(WAHAEvents.GROUP_V2_LEAVE).switch(groupV2Leave$);
const groupV2ParticipantsJoinRequest$: Observable<any> = fromEvent(
this.sock.ev,
'group.join-request',
).pipe(
map((event: any) => ToGroupV2ParticipantsJoinRequestEvent(event)),
filter(Boolean),
);
this.events2
.get(WAHAEvents.GROUP_V2_PARTICIPANTS_JOIN_REQUEST)
.switch(groupV2ParticipantsJoinRequest$);
this.events2.get(WAHAEvents.PRESENCE_UPDATE).switch(
fromEvent(this.sock.ev, 'presence.update').pipe(
filter((presence: any) => this.jids.include(presence.id)),
@@ -2944,7 +3145,9 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
* END - Methods for API
*/
private processMessageReaction(message): WAMessageReaction | null {
private async processMessageReaction(
message,
): Promise<WAMessageReaction | null> {
if (!message) return null;
if (!message.message) return null;
if (!message.message.reactionMessage) return null;
@@ -2953,7 +3156,8 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
const fromToParticipant = getFromToParticipant(message.key);
const reactionMessage = message.message.reactionMessage;
const messageId = buildMessageId(reactionMessage.key);
const source = this.getMessageSource(message.key.id);
let source = await this.hooks.message.source.promise(message.key.id);
source = source ?? MessageSource.APP;
const reaction: WAMessageReaction = {
id: id,
timestamp: ensureNumber(message.messageTimestamp),
@@ -3160,23 +3364,21 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
protected async processIncomingMessage(
message,
downloadMedia: boolean,
options: MediaDownloadOptions,
): Promise<WAMessage | null> {
// Filter
if (!this.shouldProcessIncomingMessage(message)) {
return null;
}
// Convert
const wamessage = this.toWAMessageSafe(message);
const wamessage = await this.toWAMessageSafe(message);
if (!wamessage) {
return null;
}
// Media
if (downloadMedia && wamessage.hasMedia) {
wamessage.media = await this.downloadMediaSafe(message);
}
wamessage.media = await this.downloadMediaSafe(message, options);
if (downloadMedia && wamessage.replyTo?.hasMedia) {
if (wamessage.replyTo?.hasMedia) {
const mediaContent = extractMediaContent(wamessage.replyTo._data);
const m = {
message: wamessage.replyTo._data,
@@ -3189,14 +3391,14 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
remoteJid: message.key.remoteJid,
},
};
wamessage.replyTo.media = await this.downloadMediaSafe(m);
wamessage.replyTo.media = await this.downloadMediaSafe(m, options);
}
return wamessage;
}
protected toWAMessageSafe(message): WAMessage | null {
protected async toWAMessageSafe(message): Promise<WAMessage | null> {
try {
return this.toWAMessage(message);
return await this.toWAMessage(message);
} catch (error) {
this.logger.error('Failed to process incoming message');
this.logger.error(error);
@@ -3204,14 +3406,15 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
}
}
protected toWAMessage(message): WAMessage {
protected async toWAMessage(message): Promise<WAMessage> {
const fromToParticipant = getFromToParticipant(message.key);
const id = buildMessageId(message.key);
const body = extractBody(message.message);
const replyTo = this.extractReplyTo(message.message);
const ack = message.ack || StatusToAck(message.status);
const mediaContent = extractMediaContent(message.message);
const source = this.getMessageSource(message.key.id);
let source = await this.hooks.message.source.promise(message.key.id);
source = source ?? MessageSource.APP;
const waproto = message.message;
return {
id: id,
@@ -3444,9 +3647,17 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
return { id: chatId, presences: presences };
}
protected async downloadMediaSafe(message): Promise<WAMedia | null> {
protected async downloadMediaSafe(
message,
options: MediaDownloadOptions,
): Promise<WAMedia | null> {
try {
return await this.downloadMedia(message);
let processor: IMediaEngineProcessor<any> = new NOWEBEngineMediaProcessor(
this,
this.loggerBuilder,
);
processor = new LottieMediaProcessorWrapper(processor, this.logger);
return await this.mediaManager.processMedia(processor, message, options);
} catch (e) {
this.logger.error('Failed when tried to download media for a message');
this.logger.error(e, e.stack);
@@ -3454,21 +3665,15 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
return null;
}
protected async downloadMedia(message): Promise<WAMedia | null> {
let processor: IMediaEngineProcessor<any> = new NOWEBEngineMediaProcessor(
this,
this.loggerBuilder,
);
processor = new LottieMediaProcessorWrapper(processor, this.logger);
return this.mediaManager.processMedia(processor, message, this.name);
}
protected async getMessageOptions(request: {
id?: string;
chatId: string;
reply_to?: string;
}) {
const jid = toJID(request.chatId);
const jid = await this.hooks.wid.chat.promise(
request.chatId,
'getMessageOptions',
);
let quoted;
if (request.reply_to) {
@@ -3477,7 +3682,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
}
const chat = await this.store.getChat(jid);
const messageId = request.id ? request.id : this.generateMessageID();
this.saveSentMessageId(messageId);
this.hooks.message.sent.call(messageId);
return {
quoted: quoted,
ephemeralExpiration: chat?.ephemeralExpiration,
@@ -3503,7 +3708,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
protected generateMessageID() {
const id = generateMessageIDV2(this.sock.user?.id);
this.saveSentMessageId(id);
this.hooks.message.sent.call(id);
return id;
}
}
@@ -3549,7 +3754,15 @@ export class NOWEBEngineMediaProcessor implements IMediaEngineProcessor<any> {
return content.mimetype;
}
async getMediaBuffer(message: any): Promise<Buffer | null> {
async getMediaContent(message: any): Promise<MediaContent | null> {
const buffer = await this.getMediaBuffer(message);
if (!buffer) {
return null;
}
return { buffer: buffer };
}
private async getMediaBuffer(message: any): Promise<Buffer | null> {
const content = extractMediaContent(message.message);
const url = content.url;
// Fix Stickers
@@ -3594,25 +3807,6 @@ export class NOWEBEngineMediaProcessor implements IMediaEngineProcessor<any> {
export const ALL_JID = 'all@s.whatsapp.net';
/**
* Build WAHA message id from engine one
* {id: "AAA", remoteJid: "11111111111@s.whatsapp.net", "fromMe": false}
* false_11111111111@c.us_AA
*/
export function buildMessageId({
id,
remoteJid,
fromMe,
participant,
}: WAMessageKey) {
const chatId = toCusFormat(remoteJid);
const parts = [fromMe || false, chatId, id];
if (participant) {
parts.push(toCusFormat(participant));
}
return parts.join('_');
}
function getId(object) {
return object.id;
}
+21 -1
View File
@@ -1,6 +1,26 @@
import type { proto } from '@adiwajshing/baileys';
import type { proto, WAMessageKey } from '@adiwajshing/baileys';
import { toCusFormat } from '@waha/core/utils/jids';
import esm from '@waha/vendor/esm';
/**
* Build WAHA message id from engine one
* {id: "AAA", remoteJid: "11111111111@s.whatsapp.net", "fromMe": false}
* false_11111111111@c.us_AA
*/
export function buildMessageId({
id,
remoteJid,
fromMe,
participant,
}: WAMessageKey) {
const chatId = toCusFormat(remoteJid);
const parts = [fromMe || false, chatId, id];
if (participant) {
parts.push(toCusFormat(participant));
}
return parts.join('_');
}
export function extractMediaContent(
content: any | proto.IMessage | null | undefined,
) {
+64 -1
View File
@@ -2,21 +2,28 @@ import { WebjsClientCore } from '@waha/core/engines/webjs/WebjsClientCore';
import {
GroupId,
GroupInfo,
GroupJoinRequest,
GroupJoinRequestResult,
GroupParticipant,
GroupParticipantRole,
NormalizeJoinRequestMethod,
} from '@waha/structures/groups.dto';
import {
GroupParticipantsJoinRequestAction,
GroupParticipantType,
GroupV2JoinEvent,
GroupV2LeaveEvent,
GroupV2ParticipantsJoinRequestEvent,
GroupV2ParticipantsEvent,
GroupV2UpdateEvent,
} from '@waha/structures/groups.events.dto';
import {
GroupChat,
GroupMembershipRequest as WEBJSGroupJoinRequest,
GroupNotification,
GroupNotificationTypes,
GroupParticipant as WEBJSGroupParticipant,
MembershipRequestActionResult as WEBJSMembershipRequestActionResult,
} from 'whatsapp-web.js';
import { isPnUser } from '@waha/core/utils/jids';
import { GetSerialized } from '@waha/core/utils/serialized';
@@ -36,12 +43,68 @@ function ToGroupInfo(
invite: invite,
membersCanAddNewMember: groupMetadata.memberAddMode === 'all_member_add',
membersCanSendMessages: groupMetadata.announce,
newMembersApprovalRequired: groupMetadata.membershipApprovalMode,
newMembersApprovalRequired: ToGroupMembershipApprovalRequired(
groupMetadata.membershipApprovalMode,
),
participants: participants,
};
return info;
}
export function ToGroupMembershipApprovalRequired(value: unknown): boolean {
return value === true || value === 1;
}
export function ToGroupJoinRequest(
request: WEBJSGroupJoinRequest,
): GroupJoinRequest {
const requesterId = GetSerialized(request.id);
if (!requesterId) {
throw new Error('Unable to serialize group membership requester ID');
}
return {
requesterId: requesterId,
addedById: GetSerialized(request.addedBy),
parentGroupId: GetSerialized(request.parentGroupId),
requestMethod: NormalizeJoinRequestMethod(request.requestMethod),
timestamp: request.t,
};
}
export function ToGroupJoinRequestResults(
result: WEBJSMembershipRequestActionResult,
): GroupJoinRequestResult[] {
// whatsapp-web.js may report one result for multiple requesters - flatten to one entry per requester
let requesterIds: (string | null)[];
if (Array.isArray(result.requesterId)) {
requesterIds = result.requesterId;
} else {
requesterIds = [result.requesterId ?? null];
}
return requesterIds.map((requesterId) => ({
requesterId: requesterId,
success: !result.error,
error: result.error,
}));
}
export function ToGroupV2ParticipantsJoinRequestEvent(
notification: GroupNotification,
): GroupV2ParticipantsJoinRequestEvent | null {
if (!notification.chatId || !notification.author) {
return null;
}
return {
group: {
id: notification.chatId,
},
action: GroupParticipantsJoinRequestAction.CREATED,
requesterId: notification.author,
timestamp: notification.timestamp,
_data: notification,
};
}
export async function ToGroupV2JoinEvent(
client: WebjsClientCore,
me: string,
+376 -145
View File
@@ -15,8 +15,12 @@ import {
} from '@waha/core/engines/webjs/ack.webjs';
import {
getParticipants,
ToGroupMembershipApprovalRequired,
ToGroupJoinRequest,
ToGroupJoinRequestResults,
ToGroupV2JoinEvent,
ToGroupV2LeaveEvent,
ToGroupV2ParticipantsJoinRequestEvent,
ToGroupV2ParticipantsEvent,
ToGroupV2UpdateEvent,
} from '@waha/core/engines/webjs/groups.webjs';
@@ -35,7 +39,11 @@ import {
import { WAMimeType } from '@waha/core/media/WAMimeType';
import { detectMimetype } from '@waha/utils/files';
import { NotImplementedByEngineError } from '@waha/core/exceptions';
import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
import {
IMediaEngineProcessor,
MediaContent,
} from '@waha/core/media/IMediaEngineProcessor';
import { MediaDownloadOptions } from '@waha/core/media/IMediaManager';
import { LottieMediaProcessorWrapper } from '@waha/core/media/LottieMediaProcessorWrapper';
import { QR } from '@waha/core/QR';
import { StatusToAck } from '@waha/core/utils/acks';
@@ -43,7 +51,7 @@ import {
parseMessageIdSerialized,
SerializeMessageKey,
} from '@waha/core/utils/ids';
import { DistinctAck } from '@waha/core/utils/reactive';
import { DistinctAck, DistinctMessages } from '@waha/core/utils/reactive';
import { splitAt } from '@waha/helpers';
import { PairingCodeResponse } from '@waha/structures/auth.dto';
import {
@@ -83,6 +91,7 @@ import {
MessageReactionRequest,
MessageReplyRequest,
MessageStarRequest,
MessageStickerRequest,
MessageTextRequest,
MessageVideoRequest,
SendSeenRequest,
@@ -106,10 +115,13 @@ import {
import { BinaryFile, RemoteFile } from '@waha/structures/files.dto';
import {
CreateGroupRequest,
GroupJoinRequest,
GroupJoinRequestResult,
GroupParticipant,
GroupSortField,
ParticipantsRequest,
SettingsMemberAddMode,
SettingsMembershipApproval,
SettingsSecurityChangeInfo,
} from '@waha/structures/groups.dto';
import { Label, LabelDTO, LabelID } from '@waha/structures/labels.dto';
@@ -122,6 +134,7 @@ import {
WAHAPresenceData,
} from '@waha/structures/presence.dto';
import {
MessageSource,
WALocation,
WAMessage,
WAMessageReaction,
@@ -159,6 +172,7 @@ import * as lodash from 'lodash';
import * as path from 'path';
import { Browser, ProtocolError } from 'puppeteer';
import {
concatMap,
filter,
fromEvent,
merge,
@@ -184,6 +198,7 @@ import {
PollVote as WebjsPollVote,
Message,
MessageMedia,
MessageTypes,
Reaction,
WAState,
} from 'whatsapp-web.js';
@@ -203,7 +218,6 @@ import {
normalizeJid,
toCusFormat,
} from '@waha/core/utils/jids';
import { Activity } from '@waha/core/abc/activity';
import { CallData } from '@waha/structures/calls.dto';
import { Jid } from '@waha/core/engines/const';
import {
@@ -214,6 +228,8 @@ import { removeSingletonFiles } from '@waha/core/utils/chrome';
import { killProcessesByPatterns } from '@waha/core/utils/processes';
import { IsChrome } from '@waha/version';
import { Activity } from '@waha/core/abc/session.hooks.activity';
export interface WebJSConfig {
webVersion?: string;
cacheType: 'local' | 'none';
@@ -471,7 +487,6 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
async stop() {
this.cleanupPresenceTimeout();
this.shouldRestart = false;
this.startDelayedJob.cancel();
await this.end();
@@ -505,7 +520,6 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
private async end() {
this.cleanupPresenceTimeout();
this.presence = null;
this.engineStateCheckDelayedJob.cancel();
this.whatsNewModalJob.stop();
@@ -886,6 +900,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
return await this.whatsapp.setProfilePicture(media);
}
@Activity()
protected async deleteProfilePicture(): Promise<boolean> {
return await this.whatsapp.deleteProfilePicture();
}
@@ -971,7 +986,8 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
*/
@Activity()
async rejectCall(from: string, id: string): Promise<void> {
const peerJid = normalizeJid(this.ensureSuffix(from));
const wid = await this.hooks.wid.chat.promise(from, 'rejectCall');
const peerJid = normalizeJid(wid);
const call = new CallInstance(this.whatsapp, null);
call.id = id;
call.from = peerJid;
@@ -980,13 +996,13 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
@Activity()
sendText(request: MessageTextRequest) {
const options = this.getMessageOptions(request);
return this.whatsapp.sendMessage(
this.ensureSuffix(request.chatId),
request.text,
options,
async sendText(request: MessageTextRequest) {
const options = await this.getMessageOptions(request);
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'sendText',
);
return this.whatsapp.sendMessage(chatId, request.text, options);
}
@Activity()
@@ -1012,9 +1028,12 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
@Activity()
async sendContactVCard(request: MessageContactVcardRequest) {
const chatId = this.ensureSuffix(request.chatId);
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'sendContactVCard',
);
const vcards = request.contacts.map((el) => toVcardV3(el as any));
const options = this.getMessageOptions(request);
const options = await this.getMessageOptions(request);
// Single vCard: pass raw vcard text as a message body.
// WEBJS will detect BEGIN:VCARD when parseVCards=true and send as a contact card.
@@ -1035,12 +1054,9 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
@Activity()
async reply(request: MessageReplyRequest) {
const options = this.getMessageOptions(request);
return this.whatsapp.sendMessage(
this.ensureSuffix(request.chatId),
request.text,
options,
);
const options = await this.getMessageOptions(request);
const chatId = await this.hooks.wid.chat.promise(request.chatId, 'reply');
return this.whatsapp.sendMessage(chatId, request.text, options);
}
@Activity()
@@ -1049,12 +1065,12 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
allowMultipleAnswers: request.poll.multipleAnswers,
messageSecret: undefined,
});
const options = this.getMessageOptions(request);
return this.whatsapp.sendMessage(
this.ensureSuffix(request.chatId),
poll,
options,
const options = await this.getMessageOptions(request);
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'sendPoll',
);
return this.whatsapp.sendMessage(chatId, poll, options);
}
@Activity()
@@ -1063,33 +1079,33 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
if (!media.mimetype) {
media.mimetype = await detectMimetype(Buffer.from(media.data, 'base64'));
}
let options = this.getMessageOptions(request);
let options = await this.getMessageOptions(request);
options = {
...options,
sendMediaAsDocument: true,
caption: request.caption,
};
return this.whatsapp.sendMessage(
this.ensureSuffix(request.chatId),
media,
options,
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'sendFile',
);
return this.whatsapp.sendMessage(chatId, media, options);
}
@Activity()
async sendImage(request: MessageImageRequest) {
const media = await this.fileToMedia(request.file);
media.mimetype = media.mimetype || WAMimeType.IMAGE;
let options = this.getMessageOptions(request);
let options = await this.getMessageOptions(request);
options = {
...options,
caption: request.caption,
};
return this.whatsapp.sendMessage(
this.ensureSuffix(request.chatId),
media,
options,
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'sendImage',
);
return this.whatsapp.sendMessage(chatId, media, options);
}
@Activity()
@@ -1099,16 +1115,16 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
await this.convertVoice(media);
}
media.mimetype = request.file.mimetype || WAMimeType.VOICE;
let options = this.getMessageOptions(request);
let options = await this.getMessageOptions(request);
options = {
...options,
sendAudioAsVoice: true,
};
return this.whatsapp.sendMessage(
this.ensureSuffix(request.chatId),
media,
options,
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'sendVoice',
);
return this.whatsapp.sendMessage(chatId, media, options);
}
@Activity()
@@ -1120,16 +1136,32 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
await this.convertVideo(media);
}
let options = this.getMessageOptions(request);
let options = await this.getMessageOptions(request);
options = {
...options,
caption: request.caption,
};
return this.whatsapp.sendMessage(
this.ensureSuffix(request.chatId),
media,
options,
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'sendVideo',
);
return this.whatsapp.sendMessage(chatId, media, options);
}
@Activity()
async sendSticker(request: MessageStickerRequest) {
const media = await this.fileToMedia(request.file);
media.mimetype = media.mimetype || WAMimeType.STICKER;
let options = await this.getMessageOptions(request);
options = {
...options,
sendMediaAsSticker: true,
};
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'sendSticker',
);
return this.whatsapp.sendMessage(chatId, media, options);
}
private async convertVideo(media: MessageMedia) {
@@ -1163,7 +1195,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
@Activity()
async sendButtonsReply(request: MessageButtonReply) {
const options = this.getMessageOptions(request);
const options = await this.getMessageOptions(request);
const extra: any = {
type: 'buttons_response',
kind: 'buttonsResponse',
@@ -1179,8 +1211,12 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
viewMode: 'VISIBLE',
};
options.extra = extra;
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'sendButtonsReply',
);
return this.whatsapp.sendMessage(
this.ensureSuffix(request.chatId),
chatId,
request.selectedDisplayText,
options,
);
@@ -1199,18 +1235,22 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
const location = new Location(request.latitude, request.longitude, {
name: request.title,
});
const options = this.getMessageOptions(request);
return this.whatsapp.sendMessage(
this.ensureSuffix(request.chatId),
location,
options,
const options = await this.getMessageOptions(request);
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'sendLocation',
);
return this.whatsapp.sendMessage(chatId, location, options);
}
@Activity()
async forwardMessage(request: MessageForwardRequest): Promise<WAMessage> {
const forwardMessage = this.recreateMessage(request.messageId);
const msg = await forwardMessage.forward(this.ensureSuffix(request.chatId));
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'forwardMessage',
);
const msg = await forwardMessage.forward(chatId);
// Return "sent: true" for now
// need to research how to get the data from WebJS
// @ts-ignore
@@ -1219,25 +1259,31 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
@Activity()
async sendSeen(request: SendSeenRequest) {
const chat: Chat = await this.whatsapp.getChatById(
this.ensureSuffix(request.chatId),
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'sendSeen',
);
const chat: Chat = await this.whatsapp.getChatById(chatId);
await chat.sendSeen();
}
@Activity()
async startTyping(request: ChatRequest): Promise<void> {
const chat: Chat = await this.whatsapp.getChatById(
this.ensureSuffix(request.chatId),
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'startTyping',
);
const chat: Chat = await this.whatsapp.getChatById(chatId);
await chat.sendStateTyping();
}
@Activity()
async stopTyping(request: ChatRequest) {
const chat: Chat = await this.whatsapp.getChatById(
this.ensureSuffix(request.chatId),
const chatId = await this.hooks.wid.chat.promise(
request.chatId,
'stopTyping',
);
const chat: Chat = await this.whatsapp.getChatById(chatId);
await chat.clearState();
}
@@ -1307,9 +1353,10 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
GetSerialized(chat.id),
false,
);
const lastMessage = chat.lastMessage
? this.toWAMessage(chat.lastMessage)
: null;
let lastMessage = null;
if (chat.lastMessage) {
lastMessage = await this.toWAMessage(chat.lastMessage);
}
return {
id: GetSerialized(chat.id),
name: chat.name || null,
@@ -1330,18 +1377,19 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
);
}
const downloadMedia = query.downloadMedia;
const id = await this.hooks.wid.chat.promise(chatId, 'getChatMessages');
// Test there's chat with id
await this.whatsapp.getChatById(this.ensureSuffix(chatId));
await this.whatsapp.getChatById(id);
const pagination: PaginationParams = query;
const messages = await this.whatsapp.getMessages(
this.ensureSuffix(chatId),
filter,
pagination,
);
const messages = await this.whatsapp.getMessages(id, filter, pagination);
const promises = [];
const params = {
download: query.downloadMedia,
mimetypes: query.downloadMediaMimetypes,
};
const options = lodash.defaults({}, params, this.media.api);
for (const msg of messages) {
promises.push(this.processIncomingMessage(msg, downloadMedia));
promises.push(this.processIncomingMessage(msg, options));
}
let result = await Promise.all(promises);
result = result.filter(Boolean);
@@ -1353,9 +1401,8 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
chatId: string,
request: ReadChatMessagesQuery,
): Promise<ReadChatMessagesResponse> {
const chat: Chat = await this.whatsapp.getChatById(
this.ensureSuffix(chatId),
);
const id = await this.hooks.wid.chat.promise(chatId, 'readChatMessages');
const chat: Chat = await this.whatsapp.getChatById(id);
await chat.sendSeen();
return { ids: null };
}
@@ -1365,7 +1412,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
messageId: string,
query: GetChatMessageQuery,
): Promise<null | WAMessage> {
chatId = this.ensureSuffix(chatId);
chatId = await this.hooks.wid.chat.promise(chatId, 'getChatMessage');
// WEBJS waits the serializer messageId
// {fromMe}_{chatId}_{id}[_{participant}]
@@ -1423,7 +1470,12 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
return null;
});
}
return await this.processIncomingMessage(message, query.downloadMedia);
const params = {
download: query.downloadMedia,
mimetypes: query.downloadMediaMimetypes,
};
const options = lodash.defaults({}, params, this.media.api);
return await this.processIncomingMessage(message, options);
}
@Activity()
@@ -1447,7 +1499,8 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
@Activity()
async deleteChat(chatId) {
const chat = await this.whatsapp.getChatById(this.ensureSuffix(chatId));
const id = await this.hooks.wid.chat.promise(chatId, 'deleteChat');
const chat = await this.whatsapp.getChatById(id);
return chat.delete();
}
@@ -1458,20 +1511,20 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
@Activity()
public chatsArchiveChat(chatId: string): Promise<any> {
const id = this.ensureSuffix(chatId);
public async chatsArchiveChat(chatId: string): Promise<any> {
const id = await this.hooks.wid.chat.promise(chatId, 'chatsArchiveChat');
return this.whatsapp.archiveChat(id);
}
@Activity()
public chatsUnarchiveChat(chatId: string): Promise<any> {
const id = this.ensureSuffix(chatId);
public async chatsUnarchiveChat(chatId: string): Promise<any> {
const id = await this.hooks.wid.chat.promise(chatId, 'chatsUnarchiveChat');
return this.whatsapp.unarchiveChat(id);
}
@Activity()
public chatsUnreadChat(chatId: string): Promise<any> {
const id = this.ensureSuffix(chatId);
public async chatsUnreadChat(chatId: string): Promise<any> {
const id = await this.hooks.wid.chat.promise(chatId, 'chatsUnreadChat');
return this.whatsapp.markChatUnread(id);
}
@@ -1512,7 +1565,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
public async getChatLabels(chatId: string): Promise<Label[]> {
const id = this.ensureSuffix(chatId);
const id = await this.hooks.wid.chat.promise(chatId, 'getChatLabels');
const labels = await this.whatsapp.getChatLabels(id);
return labels.map(this.toLabel);
}
@@ -1520,7 +1573,8 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
@Activity()
public async putLabelsToChat(chatId: string, labels: LabelID[]) {
const labelIds = labels.map((label) => label.id);
const chatIds = [this.ensureSuffix(chatId)];
const id = await this.hooks.wid.chat.promise(chatId, 'putLabelsToChat');
const chatIds = [id];
await this.whatsapp.addOrRemoveLabels(labelIds, chatIds);
}
@@ -1548,10 +1602,12 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
);
}
getContact(query: ContactQuery) {
return this.whatsapp
.getContactById(this.ensureSuffix(query.contactId))
.then(this.toWAContact);
async getContact(query: ContactQuery) {
const contactId = await this.hooks.wid.chat.promise(
query.contactId,
'getContact',
);
return this.whatsapp.getContactById(contactId).then(this.toWAContact);
}
async getContacts(pagination: PaginationParams) {
@@ -1562,32 +1618,42 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
public async getContactAbout(query: ContactQuery) {
const contact = await this.whatsapp.getContactById(
this.ensureSuffix(query.contactId),
const contactId = await this.hooks.wid.chat.promise(
query.contactId,
'getContactAbout',
);
const contact = await this.whatsapp.getContactById(contactId);
return { about: await contact.getAbout() };
}
@Activity()
public async fetchContactProfilePicture(id: string) {
const contact = await this.whatsapp.getContactById(this.ensureSuffix(id));
const contactId = await this.hooks.wid.chat.promise(
id,
'fetchContactProfilePicture',
);
const contact = await this.whatsapp.getContactById(contactId);
const url = await contact.getProfilePicUrl();
return url;
}
@Activity()
public async blockContact(request: ContactRequest) {
const contact = await this.whatsapp.getContactById(
this.ensureSuffix(request.contactId),
const contactId = await this.hooks.wid.chat.promise(
request.contactId,
'blockContact',
);
const contact = await this.whatsapp.getContactById(contactId);
await contact.block();
}
@Activity()
public async unblockContact(request: ContactRequest) {
const contact = await this.whatsapp.getContactById(
this.ensureSuffix(request.contactId),
const contactId = await this.hooks.wid.chat.promise(
request.contactId,
'unblockContact',
);
const contact = await this.whatsapp.getContactById(contactId);
await contact.unblock();
}
@@ -1691,6 +1757,101 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
return groupChat.setAddMembersAdminsOnly(!value);
}
@Activity()
public async getMembershipApprovalMode(
id: string,
): Promise<SettingsMembershipApproval> {
const membershipApprovalMode = await this.whatsapp.pupPage.evaluate(
async (groupId) => {
await window
.require('WAWebGroupQueryJob')
.queryAndUpdateGroupMetadataById({ id: groupId });
const groupWid = window.require('WAWebWidFactory').createWid(groupId);
const group = await window
.require('WAWebCollections')
.Chat.find(groupWid);
return group.groupMetadata.membershipApprovalMode;
},
id,
);
return {
newMembersApprovalRequired: ToGroupMembershipApprovalRequired(
membershipApprovalMode,
),
};
}
@Activity()
public async setMembershipApprovalMode(
id: string,
value: boolean,
): Promise<boolean> {
const groupChat = (await this.whatsapp.getChatById(id)) as GroupChat;
const success = await this.whatsapp.pupPage.evaluate(
async (groupId, enabled) => {
// @ts-ignore
const chat = await window.WWebJS.getChat(groupId, {
getAsModel: false,
});
try {
await window
.require('WAWebSetPropertyGroupAction')
.setGroupProperty(
chat,
'membership_approval_mode',
enabled ? 1 : 0,
);
return true;
} catch (error) {
if (error.name === 'ServerStatusCodeError') {
return false;
}
throw error;
}
},
id,
value,
);
if (success) {
(groupChat as any).groupMetadata.membershipApprovalMode = value;
}
return success;
}
@Activity()
public async getGroupJoinRequests(id: string): Promise<GroupJoinRequest[]> {
const groupChat = (await this.whatsapp.getChatById(id)) as GroupChat;
const requests = await groupChat.getGroupMembershipRequests();
return requests.map(ToGroupJoinRequest);
}
@Activity()
public async approveGroupJoinRequests(
id: string,
request: ParticipantsRequest,
): Promise<GroupJoinRequestResult[]> {
const groupChat = (await this.whatsapp.getChatById(id)) as GroupChat;
const results = await groupChat.approveGroupMembershipRequests({
requesterIds: request.participants.map((participant) => participant.id),
sleep: [250, 500],
});
return results.flatMap(ToGroupJoinRequestResults);
}
@Activity()
public async rejectGroupJoinRequests(
id: string,
request: ParticipantsRequest,
): Promise<GroupJoinRequestResult[]> {
const groupChat = (await this.whatsapp.getChatById(id)) as GroupChat;
const results = await groupChat.rejectGroupMembershipRequests({
requesterIds: request.participants.map((participant) => participant.id),
sleep: [250, 500],
});
return results.flatMap(ToGroupJoinRequestResults);
}
public async getGroups(pagination: PaginationParams) {
const chats = await this.whatsapp.getChats();
const groups = lodash.filter(chats, (chat) => chat.isGroup);
@@ -1862,21 +2023,24 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
query.limit,
);
const promises = [];
const params = {
download: query.downloadMedia,
mimetypes: query.downloadMediaMimetypes,
};
const options = lodash.defaults({}, params, this.media.api);
for (const msg of channelMessages) {
promises.push(
this.WebjsChannelMessageToChannelMessage(msg, query.downloadMedia),
);
promises.push(this.WebjsChannelMessageToChannelMessage(msg, options));
}
return await Promise.all(promises);
}
private async WebjsChannelMessageToChannelMessage(
channelMessage: WebjsChannelMessage,
downloadMedia: boolean,
options: MediaDownloadOptions,
): Promise<ChannelMessage> {
const message = await this.processIncomingMessage(
channelMessage.message,
downloadMedia,
options,
);
const reactions = {};
for (const reaction of channelMessage.reactions.sort((x) => -x.count)) {
@@ -2010,17 +2174,17 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
await this.whatsapp.sendPresenceUnavailable();
break;
case WAHAPresenceStatus.TYPING:
await this.maintainPresenceOnline();
await this.hooks.activity.promise('setPresence');
chat = await this.whatsapp.getChatById(chatId);
await chat.sendStateTyping();
break;
case WAHAPresenceStatus.RECORDING:
await this.maintainPresenceOnline();
await this.hooks.activity.promise('setPresence');
chat = await this.whatsapp.getChatById(chatId);
await chat.sendStateRecording();
break;
case WAHAPresenceStatus.PAUSED:
await this.maintainPresenceOnline();
await this.hooks.activity.promise('setPresence');
chat = await this.whatsapp.getChatById(chatId);
await chat.clearState();
break;
@@ -2144,6 +2308,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
return this.whatsapp.sendMessage(Jid.BROADCAST, status.text, options);
}
@Activity()
public async deleteStatus(request: DeleteStatusRequest) {
this.checkStatusRequest(request);
@@ -2160,7 +2325,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
subscribeEngineEvents2() {
// Save sent message in cache
this.whatsapp.events.on('message.id', (data) => {
this.saveSentMessageId(data.id);
this.hooks.message.sent.call(data.id);
});
//
@@ -2190,7 +2355,13 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
const messageReceived$ = fromEvent(this.whatsapp, Events.MESSAGE_RECEIVED);
const messagesFromOthers$ = messageReceived$.pipe(
filter((msg: Message) => this.jids.include(msg?.id?.remote)),
mergeMap((msg: any) => this.processIncomingMessage(msg, true)),
filter((msg: Message) => this.shouldProcessIncomingMessage(msg)),
mergeMap((msg: any) =>
this.processIncomingMessage(msg, this.media.events),
),
// Deduplicate messages by ID to prevent duplicate webhooks
// @see https://github.com/devlikeapro/waha/issues/1564
DistinctMessages(),
share(),
);
this.events2.get(WAHAEvents.MESSAGE).switch(messagesFromOthers$);
@@ -2198,7 +2369,13 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
const messageCreate$ = fromEvent(this.whatsapp, Events.MESSAGE_CREATE);
const messagesFromAll$ = messageCreate$.pipe(
filter((msg: Message) => this.jids.include(msg?.id?.remote)),
mergeMap((msg: any) => this.processIncomingMessage(msg, true)),
filter((msg: Message) => this.shouldProcessIncomingMessage(msg)),
mergeMap((msg: any) =>
this.processIncomingMessage(msg, this.media.events),
),
// Deduplicate messages by ID to prevent duplicate webhooks
// @see https://github.com/devlikeapro/waha/issues/1564
DistinctMessages(),
share(),
);
this.events2.get(WAHAEvents.MESSAGE_ANY).switch(messagesFromAll$);
@@ -2209,7 +2386,12 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
);
const messagesWaiting$ = messageCiphertext$.pipe(
filter((msg: Message) => this.jids.include(msg?.id?.remote)),
mergeMap((msg: any) => this.processIncomingMessage(msg, false)),
mergeMap((msg: any) =>
this.processIncomingMessage(msg, {
...this.media.events,
download: false,
}),
),
share(),
);
this.events2.get(WAHAEvents.MESSAGE_WAITING).switch(messagesWaiting$);
@@ -2225,11 +2407,15 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
filter((evt: any) =>
this.jids.include(evt?.after?.id?.remote || evt?.before?.id?.remote),
),
map((event): WAMessageRevokedBody => {
const afterMessage = event.after ? this.toWAMessage(event.after) : null;
const beforeMessage = event.before
? this.toWAMessage(event.before)
: null;
concatMap(async (event): Promise<WAMessageRevokedBody> => {
let afterMessage = null;
if (event.after) {
afterMessage = await this.toWAMessage(event.after);
}
let beforeMessage = null;
if (event.before) {
beforeMessage = await this.toWAMessage(event.before);
}
// Extract the revoked message ID from the protocolMessageKey.id field
const revokedMessageId = afterMessage?._data?.protocolMessageKey?.id;
return {
@@ -2244,7 +2430,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
const messageReaction$ = fromEvent(this.whatsapp, 'message_reaction');
const messagesReaction$ = messageReaction$.pipe(
filter((reaction: Reaction) => this.jids.include(reaction?.id?.remote)),
map(this.processMessageReaction.bind(this)),
concatMap((reaction: Reaction) => this.processMessageReaction(reaction)),
filter(Boolean),
);
this.events2.get(WAHAEvents.MESSAGE_REACTION).switch(messagesReaction$);
@@ -2258,8 +2444,8 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
);
const messagesEdit$ = messageEdit$.pipe(
filter((event: any) => this.jids.include(event?.message?.id?.remote)),
map((event): WAMessageEditedBody => {
const message = this.toWAMessage(event.message);
concatMap(async (event): Promise<WAMessageEditedBody> => {
const message = await this.toWAMessage(event.message);
return {
...message,
body: event.newBody,
@@ -2286,7 +2472,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
);
const messagesAckDM$ = messageAckWEBJS$.pipe(
map((event) => event.message),
map<any, WAMessage>(this.toWAMessage.bind(this)),
concatMap((message: any) => this.toWAMessage(message)),
filter((ack) => !isJidGroup(ack.to) && !isJidStatusBroadcast(ack.to)),
filter((ack) => this.jids.include(ack.to)),
);
@@ -2373,6 +2559,14 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
.get(WAHAEvents.GROUP_V2_PARTICIPANTS)
.switch(groupV2Participants);
const groupV2ParticipantsJoinRequest$ = fromEvent<GroupNotification>(
this.whatsapp,
Events.GROUP_MEMBERSHIP_REQUEST,
).pipe(map(ToGroupV2ParticipantsJoinRequestEvent), filter(Boolean));
this.events2
.get(WAHAEvents.GROUP_V2_PARTICIPANTS_JOIN_REQUEST)
.switch(groupV2ParticipantsJoinRequest$);
const groupUpdate$ = fromEvent<GroupNotification>(
this.whatsapp,
Events.GROUP_UPDATE,
@@ -2431,21 +2625,40 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
.switch(this.callRejected$.asObservable());
}
// System messages WhatsApp Web synthesizes on chat creation
// - encryption notice, contact info card, disappearing mode.
private static readonly SYSTEM_NOTIFICATION_TYPES = new Set<string>([
MessageTypes.E2E_NOTIFICATION,
MessageTypes.NOTIFICATION,
MessageTypes.NOTIFICATION_TEMPLATE,
MessageTypes.BROADCAST_NOTIFICATION,
]);
protected shouldProcessIncomingMessage(message: Message): boolean {
if (!message) {
return false;
}
// Ignore system notification messages
const type = message.type as string;
return !WhatsappSessionWebJSCore.SYSTEM_NOTIFICATION_TYPES.has(type);
}
protected async processIncomingMessage(
message: Message,
downloadMedia = true,
options: MediaDownloadOptions,
) {
// Convert
const wamessage = this.toWAMessage(message);
const wamessage = await this.toWAMessage(message);
// Media
if (downloadMedia) {
const media = await this.downloadMediaSafe(message);
wamessage.media = media;
}
if (downloadMedia && wamessage.replyTo?.hasMedia) {
const media = await this.downloadMediaSafe(message, options);
wamessage.media = media;
if (wamessage.replyTo?.hasMedia) {
const quotedMessage = await message.getQuotedMessage().catch(() => null);
if (quotedMessage) {
wamessage.replyTo.media = await this.downloadMediaSafe(quotedMessage);
wamessage.replyTo.media = await this.downloadMediaSafe(
quotedMessage,
options,
);
}
}
return wamessage;
@@ -2468,7 +2681,9 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
};
}
private processMessageReaction(reaction: Reaction): WAMessageReaction {
private async processMessageReaction(
reaction: Reaction,
): Promise<WAMessageReaction> {
if (this.lastQRDate) {
// If it's timestamp before last qr - ignore it
// Fixes: https://github.com/devlikeapro/waha/issues/494
@@ -2485,7 +2700,8 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
return null;
}
const source = this.getMessageSource(reaction.id.id);
let source = await this.hooks.message.source.promise(reaction.id.id);
source = source ?? MessageSource.APP;
return {
id: GetSerialized(reaction.id),
from: normalizeJid(reaction.senderId),
@@ -2579,9 +2795,10 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
return acks;
}
protected toWAMessage(message: Message): WAMessage {
protected async toWAMessage(message: Message): Promise<WAMessage> {
const replyTo = this.extractReplyTo(message);
const source = this.getMessageSource(message.id.id);
let source = await this.hooks.message.source.promise(message.id.id);
source = source ?? MessageSource.APP;
const key = parseMessageIdSerialized(GetSerialized(message.id));
// @ts-ignore
return {
@@ -2663,9 +2880,20 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
return contact;
}
protected async downloadMediaSafe(message): Promise<WAMedia | null> {
protected async downloadMediaSafe(
message: Message,
options: MediaDownloadOptions,
): Promise<WAMedia | null> {
try {
return await this.downloadMedia(message);
let processor: IMediaEngineProcessor<Message> =
new WEBJSEngineMediaProcessor();
processor = new LottieMediaProcessorWrapper(processor, this.logger);
const media = await this.mediaManager.processMedia(
processor,
message,
options,
);
return media;
} catch (e) {
this.logger.error('Failed when tried to download media for a message');
this.logger.error(e, e.stack);
@@ -2673,20 +2901,15 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
return null;
}
protected async downloadMedia(message: Message) {
let processor = new WEBJSEngineMediaProcessor();
processor = new LottieMediaProcessorWrapper(processor, this.logger);
const media = await this.mediaManager.processMedia(
processor,
message,
this.name,
);
return media;
}
protected getMessageOptions(request: any): any {
let mentions = request.mentions;
mentions = mentions ? mentions.map(this.ensureSuffix) : undefined;
protected async getMessageOptions(request: any): Promise<any> {
let mentions: string[] | undefined = request.mentions;
if (mentions) {
mentions = await Promise.all(
mentions.map((mention) =>
this.hooks.wid.mention.promise(mention, 'getMessageOptions'),
),
);
}
const quotedMessageId = request.reply_to || request.replyTo;
@@ -2745,7 +2968,15 @@ export class WEBJSEngineMediaProcessor
return message.rawData.mimetype;
}
async getMediaBuffer(message: Message): Promise<Buffer | null> {
async getMediaContent(message: Message): Promise<MediaContent | null> {
const buffer = await this.getMediaBuffer(message);
if (!buffer) {
return null;
}
return { buffer: buffer };
}
private async getMediaBuffer(message: Message): Promise<Buffer | null> {
return message.downloadMedia().then((media: MessageMedia) => {
if (!media) {
return null;
+23
View File
@@ -8,8 +8,10 @@ import {
GroupParticipantRole,
} from '@waha/structures/groups.dto';
import {
GroupParticipantsJoinRequestAction,
GroupParticipantType,
GroupV2LeaveEvent,
GroupV2ParticipantsJoinRequestEvent,
GroupV2ParticipantsEvent,
GroupV2UpdateEvent,
} from '@waha/structures/groups.events.dto';
@@ -203,6 +205,27 @@ export function WppGp2ToGroupV2Update(msg: any): GroupV2UpdateEvent {
};
}
export function WppGp2ToGroupV2ParticipantsJoinRequest(
msg: any,
): GroupV2ParticipantsJoinRequestEvent | null {
// Same subtype whatsapp-web.js emits 'group_membership_request' for
if (msg.subtype !== 'membership_approval_request') {
return null;
}
if (!msg.chatId || !msg.author) {
return null;
}
return {
group: {
id: toCusFormat(msg.chatId),
},
action: GroupParticipantsJoinRequestAction.CREATED,
requesterId: toCusFormat(msg.author),
timestamp: msg.timestamp,
_data: msg,
};
}
export function WppUpdateLabelToAssociations(
data: WppUpdateLabelEvent,
): LabelChatAssociation[] {
File diff suppressed because it is too large. Load diff
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff. Show more