Compare commits

...
23 Commits
Author SHA1 Message Date
devlikepro 96da96281d [core] 2025.12.1
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2025-12-05 00:15:53 -08:00
devlikepro 50e09c3680 [core] Up dashboard 2025-12-05 00:15:53 -08:00
devlikepro 1da6a16640 [core] for-swagger 2025-12-05 00:15:52 -08:00
devlikepro 10f80132f8 [core] NOWEB - poll.vote iterate over creators/votes
fix #1688
2025-12-05 00:15:52 -08:00
devlikepro 750b2b86b5 [core] WEBJS - WORKING as AUTHENTICATED
fix #1690
close #1695
2025-12-05 00:15:51 -08:00
devlikepro 2fc2caed85 [core] ChatWoot - mark as read flag
fix #1650
2025-12-05 00:15:50 -08:00
devlikepro 1431e8c0f1 [core] Apps allow to remove app if not enabled 2025-12-05 00:15:50 -08:00
devlikepro 01c9c2ca56 [core] session FAILED if apps not started 2025-12-05 00:15:49 -08:00
devlikepro f5d216df6f [core] apps in sessions 2025-12-05 00:15:49 -08:00
devlikepro 7614822d2b [core] Apps - upsert to service 2025-12-05 00:15:49 -08:00
devlikepro d2eb463d32 [core] Apps on and off 2025-12-05 00:15:49 -08:00
devlikepro 07b1e50534 [core] Remove apps by session 2025-12-05 00:15:49 -08:00
devlikepro 41b6b3459e [core] Calls App
fix #1694
2025-12-05 00:13:15 -08:00
devlikepro 0f5fcf60f6 [core] ChatWoot - markdown fix for symbols in the middle of text
fix #1643
2025-12-05 00:13:14 -08:00
devlikepro 18d8330ab3 [core] Up GOWS 2025-12-05 00:13:14 -08:00
devlikepro 7de5f461aa [core] ChatWoot - call messages 2025-12-05 00:13:13 -08:00
devlikepro caa055abed [core] ChatWoot - weaken the type for handler 2025-12-05 00:13:13 -08:00
devlikepro 49af88dea3 [core] ChatWoot - type based on private flag or fromMe 2025-12-05 00:13:13 -08:00
devlikepro 05d856d8ae [core] WEBJS - call.rejected for API rejection 2025-12-05 00:13:12 -08:00
devlikepro c5c99136fa [core] Reject Calls - WEBJS, NOWEB, GOWS
fix #608
fix #1258
2025-12-05 00:13:12 -08:00
devlikepro a8b520012f [core] GOWS - add call events
fix #1673
2025-12-05 00:13:12 -08:00
devlikepro 449df53c19 [core] Add _data to calls event - WEBJS, NOWEB 2025-12-05 00:13:12 -08:00
devlikepro 69ac19b5c0 [core] AGENTS.md 2025-12-05 00:13:12 -08:00
73 changed files with 2396 additions and 255 deletions

No files matched your search

+6
View File
@@ -120,5 +120,11 @@ This guide summarizes how to explore, modify, and validate the WhatsApp HTTP API
- Keep docs and code ASCII unless a file already uses other characters. When
updating documentation, mirror the concise, actionable tone used here.
## Local Engine Sources
- WebJS source: `../whatsapp-web.js`
- Baileys (NoWeb) source: `../WhiskeySockets-Baileys`
- GoWhatsApp (GoWS) source: `../gows` + `../whatsmeow`
Following this playbook keeps contributions aligned with WAHA’s structure,
automation hooks, and release process.
+1 -1
View File
@@ -32,7 +32,7 @@ push:
docker push devlikeapro/waha
for-swagger:
WHATSAPP_SWAGGER_CONFIG_ADVANCED=true WHATSAPP_SWAGGER_PASSWORD=666 . ${NVM_DIR}/nvm.sh && nvm exec yarn start
WHATSAPP_SWAGGER_CONFIG_ADVANCED=true WHATSAPP_SWAGGER_PASSWORD=666 nvm exec yarn start
up-noweb:
yarn up @adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2025-11-21
+34
View File
@@ -0,0 +1,34 @@
import {
Body,
Controller,
Post,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import {
SessionApiParam,
WorkingSessionParam,
} from '@waha/nestjs/params/SessionApiParam';
import { SessionManager } from '../core/abc/manager.abc';
import { WhatsappSession } from '../core/abc/session.abc';
import { RejectCallRequest } from '../structures/calls.dto';
@ApiSecurity('api_key')
@Controller('api/:session/calls')
@ApiTags('📞 Calls')
export class CallsController {
constructor(private manager: SessionManager) {}
@Post('reject')
@SessionApiParam
@ApiOperation({ summary: 'Reject incoming call' })
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
rejectCall(
@WorkingSessionParam session: WhatsappSession,
@Body() request: RejectCallRequest,
) {
return session.rejectCall(request.from, request.id);
}
}
+55 -9
View File
@@ -3,6 +3,7 @@ import {
Controller,
Delete,
Get,
Inject,
NotFoundException,
Param,
Post,
@@ -11,12 +12,16 @@ import {
UsePipes,
} from '@nestjs/common';
import { UnprocessableEntityException } from '@nestjs/common/exceptions/unprocessable-entity.exception';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ApiBody, ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import {
SessionApiParam,
SessionParam,
} from '@waha/nestjs/params/SessionApiParam';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import {
AppsService,
IAppsService,
} from '@waha/apps/app_sdk/services/IAppsService';
import {
SessionLogoutDeprecatedRequest,
SessionStartDeprecatedRequest,
@@ -29,17 +34,23 @@ import { WhatsappSession } from '../core/abc/session.abc';
import {
ListSessionsQuery,
MeInfo,
SessionExpand,
SessionInfoQuery,
SessionCreateRequest,
SessionDTO,
SessionInfo,
SessionUpdateRequest,
} from '../structures/sessions.dto';
import { SessionExamples } from './sessions.examples';
@ApiSecurity('api_key')
@Controller('api/sessions')
@ApiTags('🖥️ Sessions')
class SessionsController {
constructor(private manager: SessionManager) {}
constructor(
private manager: SessionManager,
@Inject(AppsService) private appsService: IAppsService,
) {}
private withLock(name: string, fn: () => any) {
return this.manager.withLock(name, fn);
@@ -47,21 +58,33 @@ class SessionsController {
@Get('/')
@ApiOperation({ summary: 'List all sessions' })
list(
async list(
@Query(new WAHAValidationPipe()) query: ListSessionsQuery,
): Promise<SessionInfo[]> {
return this.manager.getSessions(query.all);
const sessions = await this.manager.getSessions(query.all);
if (query.expand?.includes(SessionExpand.apps)) {
for (const session of sessions) {
session.apps = await this.appsService.list(this.manager, session.name);
}
}
return sessions;
}
@Get('/:session')
@ApiOperation({ summary: 'Get session information' })
@SessionApiParam
@UsePipes(new WAHAValidationPipe())
async get(@Param('session') name: string): Promise<SessionInfo> {
async get(
@Param('session') name: string,
@Query() query: SessionInfoQuery,
): Promise<SessionInfo> {
const session = await this.manager.getSessionInfo(name);
if (session === null) {
throw new NotFoundException('Session not found');
}
if (query.expand?.includes(SessionExpand.apps)) {
session.apps = await this.appsService.list(this.manager, name);
}
return session;
}
@@ -78,6 +101,7 @@ class SessionsController {
description:
'Create session a new session (and start it at the same time if required).',
})
@ApiBody({ type: SessionCreateRequest, examples: SessionExamples })
@UsePipes(new WAHAValidationPipe())
async create(@Body() request: SessionCreateRequest): Promise<SessionDTO> {
const name = request.name || generatePrefixedId('session');
@@ -86,16 +110,26 @@ class SessionsController {
const msg = `Session '${name}' already exists. Use PUT to update it.`;
throw new UnprocessableEntityException(msg);
}
const config = request.config;
const start = request.start || false;
await this.manager.upsert(name, config);
if (request.apps) {
await this.appsService.syncSessionApps(
this.manager,
name,
request.apps,
);
}
if (start) {
await this.manager.assign(name);
await this.manager.start(name);
}
});
return await this.manager.getSessionInfo(name);
const session = await this.manager.getSessionInfo(name);
if (request.apps) {
session.apps = await this.appsService.list(this.manager, name);
}
return session;
}
@Put(':session')
@@ -104,7 +138,8 @@ class SessionsController {
description: '',
})
@SessionApiParam
@UsePipes(new WAHAValidationPipe())
@ApiBody({ type: SessionUpdateRequest, examples: SessionExamples })
@UsePipes(new WAHAValidationPipe({ forbidNonWhitelisted: false }))
async update(
@Param('session') name: string,
@Body() request: SessionUpdateRequest,
@@ -117,11 +152,22 @@ class SessionsController {
const isRunning = this.manager.isRunning(name);
await this.manager.stop(name, true);
await this.manager.upsert(name, config);
if (request.apps) {
await this.appsService.syncSessionApps(
this.manager,
name,
request.apps,
);
}
if (isRunning) {
await this.manager.start(name);
}
});
return await this.manager.getSessionInfo(name);
const session = await this.manager.getSessionInfo(name);
if (request.apps) {
session.apps = await this.appsService.list(this.manager, name);
}
return session;
}
@Delete(':session')
+102
View File
@@ -0,0 +1,102 @@
import { AppName } from '@waha/apps/app_sdk/apps/name';
export const SessionExamples = {
basic: {
summary: 'Basic Session',
value: {
name: 'default',
},
},
withWebhooks: {
summary: 'Session Webhooks',
value: {
name: 'default',
config: {
webhooks: [
{
url: 'https://example.com/webhooks/waha',
events: ['message', 'session.status'],
hmac: {
key: 'super-secret',
},
retries: {
delaySeconds: 2,
attempts: 5,
policy: 'linear',
},
customHeaders: [
{
name: 'X-Request-ID',
value: '123',
},
],
},
],
},
},
},
withApps: {
summary: 'Session Apps',
value: {
name: 'default',
apps: [
{
app: AppName.calls,
id: 'app_unique_id',
session: '',
config: {
dm: {
reject: true,
message:
"📞❌ We don't take calls right now.\n🎤 Please send a voice message or 📝 text — we'll reply ASAP!",
},
group: {
reject: true,
message:
"📞❌ We don't take calls right now.\n🎤 Please send a voice message or 📝 text — we'll reply ASAP!",
},
},
},
],
start: true,
},
},
full: {
summary: 'Full Session Example',
value: {
name: 'default',
start: true,
config: {
metadata: {
'user.id': '123',
},
debug: true,
webhooks: [
{
url: 'https://example.com/webhooks/waha',
events: ['message', 'session.status'],
hmac: {
key: 'super-secret',
},
},
],
},
apps: [
{
app: AppName.calls,
enabled: true,
config: {
dm: {
reject: true,
message:
"📞❌ We don't take calls right now.\n🎤 Please send a voice message or 📝 text — we'll reply ASAP!",
},
group: {
reject: true,
},
},
},
],
},
},
};
+17 -3
View File
@@ -45,7 +45,12 @@ export class AppsController {
@ApiOperation({ summary: 'Create a new app' })
@UsePipes(new WAHAValidationPipe())
async create(@Body() app: App): Promise<App> {
return await this.appsService.create(this.manager, app);
const result = await this.appsService.create(this.manager, app);
const isRunning = this.manager.isRunning(app.session);
if (isRunning && app.enabled) {
await this.manager.restart(app.session);
}
return result;
}
@Get('/:id')
@@ -67,13 +72,22 @@ export class AppsController {
);
}
return await this.appsService.update(this.manager, app);
const result = await this.appsService.upsert(this.manager, app);
const isRunning = this.manager.isRunning(app.session);
if (isRunning) {
await this.manager.restart(app.session);
}
return result;
}
@Delete('/:id')
@ApiOperation({ summary: 'Delete an app' })
@UsePipes(new WAHAValidationPipe())
async delete(@Param('id') id: string): Promise<void> {
await this.appsService.delete(this.manager, id);
const app = await this.appsService.delete(this.manager, id);
const isRunning = this.manager.isRunning(app.session);
if (isRunning) {
await this.manager.restart(app.session);
}
}
}
+57
View File
@@ -0,0 +1,57 @@
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';
class AppRuntimeConfigC {
private constructor(private apps: AppDefinition[] | null) {}
static FromEnv(env: typeof AppEnv) {
if (!env.enabled) {
return new AppRuntimeConfigC(null);
}
let apps = Object.values(APPS);
// Include
if (env.on && env.on.length > 0) {
apps = apps.filter((app) => env.on!.includes(app.name));
}
// Exclude
if (env.off && env.off.length > 0) {
apps = apps.filter((app) => !env.off!.includes(app.name));
}
return new AppRuntimeConfigC(apps);
}
Enabled() {
return this.apps !== null;
}
GetApps() {
return this.apps || [];
}
GetAppsWithMigration() {
return this.GetApps().filter((app) => app.migrations);
}
GetAppsRequiringPlainKey() {
return this.GetApps().filter((app) => app.plainkey);
}
GetAppsRequiringQueue() {
return this.GetApps().filter((app) => app.queue);
}
HasApp(name: AppName) {
return this.GetApps().some((app) => app.name === name);
}
HasAppsRequiringPlainKey() {
return this.GetAppsRequiringPlainKey().length > 0;
}
HasAppsRequiringQueue() {
return this.GetAppsRequiringQueue().length > 0;
}
}
export const AppRuntimeConfig = AppRuntimeConfigC.FromEnv(AppEnv);
+28
View File
@@ -0,0 +1,28 @@
import { AppName } from '@waha/apps/app_sdk/apps/name';
export interface AppDefinition {
// App name
name: AppName;
// If app requires WAHA_API_KEY_PLAIN to work
plainkey: boolean;
// If app requires queue to work
queue: boolean;
// If app has any migrations
migrations: boolean;
}
// All Apps
export const APPS: Record<AppName, AppDefinition> = {
[AppName.calls]: {
name: AppName.calls,
plainkey: false,
queue: false,
migrations: false,
},
[AppName.chatwoot]: {
name: AppName.chatwoot,
plainkey: true,
queue: true,
migrations: true,
},
};
+4
View File
@@ -0,0 +1,4 @@
export enum AppName {
chatwoot = 'chatwoot',
calls = 'calls',
}
+12 -7
View File
@@ -1,4 +1,5 @@
import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto';
import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto';
import { Type } from 'class-transformer';
import {
IsBoolean,
@@ -8,14 +9,11 @@ import {
ValidateNested,
} from 'class-validator';
import { ApiExtraModels, ApiProperty } from '@nestjs/swagger';
import { AppName } from '@waha/apps/app_sdk/apps/name';
export type AllowedAppConfig = ChatWootAppConfig;
export type AllowedAppConfig = ChatWootAppConfig | CallsAppConfig;
export enum AppName {
chatwoot = 'chatwoot',
}
@ApiExtraModels(ChatWootAppConfig)
@ApiExtraModels(ChatWootAppConfig, CallsAppConfig)
export class App<T extends AllowedAppConfig = any> {
@IsString()
id: string;
@@ -43,6 +41,8 @@ export class App<T extends AllowedAppConfig = any> {
switch (options.object.app) {
case AppName.chatwoot:
return ChatWootAppConfig;
case AppName.calls:
return CallsAppConfig;
default:
return Object;
}
@@ -57,4 +57,9 @@ export class ChatWootAppDto extends App<ChatWootAppConfig> {
config: ChatWootAppConfig;
}
export type AppDto = ChatWootAppDto;
export class CallsAppDto extends App<CallsAppConfig> {
@Type(() => CallsAppConfig)
config: CallsAppConfig;
}
export type AppDto = ChatWootAppDto | CallsAppDto;
+14
View File
@@ -0,0 +1,14 @@
import { parseBool } from '@waha/helpers';
function parseCommaSeparatedList(value: string | undefined): string[] {
if (!value) {
return null;
}
return value.split(',').map((item) => item.trim());
}
export const AppEnv = {
enabled: parseBool(process.env.WAHA_APPS_ENABLED),
on: parseCommaSeparatedList(process.env.WAHA_APPS_ON),
off: parseCommaSeparatedList(process.env.WAHA_APPS_OFF),
};
+4 -1
View File
@@ -1,6 +1,7 @@
import * as path from 'node:path';
import { Knex } from 'knex';
import { AppRuntimeConfig } from '@waha/apps/app_sdk/apps/AppRuntime';
function migrateSDK(knex: Knex): Promise<any> {
const config = {
@@ -25,5 +26,7 @@ function migrateApp(knex: Knex, app: string): Promise<any> {
export async function migrate(knex: Knex) {
await migrateSDK(knex);
await migrateApp(knex, 'chatwoot');
for (const app of AppRuntimeConfig.GetAppsWithMigration()) {
await migrateApp(knex, app.name);
}
}
@@ -29,14 +29,22 @@ export class AppsDisabledService implements IAppsService {
throw new AppsIsDisabledError();
}
async upsert(manager: SessionManager, app: App): Promise<App> {
throw new AppsIsDisabledError();
}
async update(manager: SessionManager, app: App): Promise<App> {
throw new AppsIsDisabledError();
}
async delete(manager: SessionManager, appId: string) {
async delete(manager: SessionManager, appId: string): Promise<App> {
throw new AppsIsDisabledError();
}
async removeBySession(manager: SessionManager, session: string) {
return;
}
migrate(knex: Knex<any, any[]>): Promise<void> {
return;
}
@@ -48,4 +56,12 @@ export class AppsDisabledService implements IAppsService {
async afterSessionStart(session: WhatsappSession, store: DataStore) {
return;
}
async syncSessionApps(
manager: SessionManager,
sessionName: string,
apps?: App[] | null,
): Promise<void> {
throw new AppsIsDisabledError();
}
}
+106 -24
View File
@@ -1,8 +1,14 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
Injectable,
NotFoundException,
Optional,
UnprocessableEntityException,
} from '@nestjs/common';
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 { DataStore } from '@waha/core/abc/DataStore';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { WhatsappSession } from '@waha/core/abc/session.abc';
@@ -10,15 +16,26 @@ import { generatePrefixedId } from '@waha/utils/ids';
import { Knex } from 'knex';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
import { App, AppName } from '../dto/app.dto';
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';
export class AppDisableError extends UnprocessableEntityException {
constructor(app: string) {
super(
`App '${app}' is disabled in runtime configuration - adjust WAHA_APPS_ON / WAHA_APPS_OFF environment variables to enable it.`,
);
}
}
@Injectable()
export class AppsEnabledService implements IAppsService {
constructor(
protected readonly chatwootService: ChatWootAppService,
@InjectPinoLogger('AppsService')
protected logger: PinoLogger,
@Optional() protected readonly chatwootService: ChatWootAppService,
@Optional() protected readonly callsAppService: CallsAppService,
) {}
async list(manager: SessionManager, session: string): Promise<App[]> {
@@ -43,9 +60,12 @@ 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 existingApps = await repo.getAllBySession(app.session);
const existingChatwootApp = existingApps.find(
(existingApp) => existingApp.app === AppName.chatwoot,
);
@@ -56,8 +76,23 @@ export class AppsEnabledService implements IAppsService {
);
}
}
// 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}'.`,
);
}
}
const service = this.getAppService(app);
if (!service && !AppRuntimeConfig.HasApp(app.app)) {
throw new AppDisableError(app.app);
}
service.validate(app);
// Only run beforeCreated when app is enabled (default true if omitted)
if (app.enabled !== false) {
@@ -65,9 +100,6 @@ export class AppsEnabledService implements IAppsService {
}
const result = await repo.save(app);
if (app.enabled) {
await this.restartIfRunning(manager, app.session);
}
delete result.pk;
return result;
}
@@ -83,11 +115,23 @@ export class AppsEnabledService implements IAppsService {
return app;
}
async update(manager: SessionManager, app: App): Promise<App> {
async upsert(manager: SessionManager, app: App) {
return await this.update(manager, app, true);
}
async update(
manager: SessionManager,
app: App,
upsert: boolean = false,
): Promise<App> {
await this.checkSessionExists(manager, app.session);
const knex = manager.store.getWAHADatabase();
const repo = new AppRepository(knex);
const savedApp = await repo.getById(app.id);
if (!savedApp && upsert) {
return this.create(manager, app);
}
if (!savedApp) {
throw new NotFoundException(`App '${app.id}' not found`);
}
@@ -103,6 +147,9 @@ export class AppsEnabledService implements IAppsService {
}
const service = this.getAppService(app);
if (!service && !AppRuntimeConfig.HasApp(app.app)) {
throw new AppDisableError(app.app);
}
service.validate(app);
const hasEnabledChange = savedApp.enabled !== app.enabled;
@@ -116,16 +163,13 @@ export class AppsEnabledService implements IAppsService {
} else {
await service.beforeUpdated(savedApp, app);
}
await repo.update(app.id, app);
await this.restartIfRunning(manager, app.session);
const updated = await repo.getById(app.id);
delete (updated as any)?.pk;
return updated!;
}
async delete(manager: SessionManager, appId: string) {
async delete(manager: SessionManager, appId: string): Promise<App> {
const knex = manager.store.getWAHADatabase();
const repo = new AppRepository(knex);
const app = await repo.getById(appId);
@@ -133,10 +177,16 @@ export class AppsEnabledService implements IAppsService {
throw new NotFoundException(`App '${appId}' not found`);
}
const service = this.getAppService(app);
await service.beforeDeleted(app);
await service?.beforeDeleted(app);
await repo.delete(app.id);
await this.restartIfRunning(manager, app.session);
return;
delete app.pk;
return app;
}
async removeBySession(manager: SessionManager, session: string) {
const knex = manager.store.getWAHADatabase();
const repo = new AppRepository(knex);
await repo.deleteBySession(session);
}
async beforeSessionStart(session: WhatsappSession, store: DataStore) {
@@ -145,6 +195,9 @@ export class AppsEnabledService implements IAppsService {
const apps = await repo.getEnabledBySession(session.name);
for (const app of apps) {
const service = this.getAppService(app);
if (!service && !AppRuntimeConfig.HasApp(app.app)) {
throw new AppDisableError(app.app);
}
service.beforeSessionStart(app, session);
}
}
@@ -155,26 +208,55 @@ export class AppsEnabledService implements IAppsService {
const apps = await repo.getEnabledBySession(session.name);
for (const app of apps) {
const service = this.getAppService(app);
if (!service && !AppRuntimeConfig.HasApp(app.app)) {
throw new AppDisableError(app.app);
}
service.afterSessionStart(app, session);
}
}
async syncSessionApps(
manager: SessionManager,
session: string,
apps: App[],
): Promise<void> {
const existing = await this.list(manager, session);
const ids = new Set<string>();
// Upsert provided apps
for (const app of apps) {
if (!app.id) {
// Try to find the app by type
const found = existing.find((a) => a.app === app.app);
if (found) {
app.id = found.id;
}
}
// Force session
app.session = session;
await this.upsert(manager, app);
ids.add(app.id);
}
// Remove apps that are not in the provided list
for (const app of existing) {
if (ids.has(app.id)) {
continue;
}
await this.delete(manager, app.id);
}
}
async migrate(knex: Knex): Promise<void> {
await migrate(knex);
}
private restartIfRunning(manager: SessionManager, session: string) {
const isRunning = manager.isRunning(session);
if (!isRunning) {
return;
}
return manager.restart(session);
}
private getAppService(app: App): IAppService {
private getAppService(app: App): IAppService | null {
switch (app.app) {
case AppName.chatwoot:
return this.chatwootService;
case AppName.calls:
return this.callsAppService;
default:
throw new Error(`App '${app.app}' not supported`);
}
+11 -1
View File
@@ -11,14 +11,24 @@ export interface IAppsService {
create(manager: SessionManager, app: App): Promise<App>;
upsert(manager: SessionManager, app: App): Promise<App>;
update(manager: SessionManager, app: App): Promise<App>;
delete(manager: SessionManager, appId: string): Promise<void>;
delete(manager: SessionManager, appId: string): Promise<App>;
removeBySession(manager: SessionManager, session: string): Promise<void>;
beforeSessionStart(session: WhatsappSession, store: DataStore): Promise<void>;
afterSessionStart(session: WhatsappSession, store: DataStore): Promise<void>;
syncSessionApps(
manager: SessionManager,
sessionName: string,
apps?: App[] | null,
): Promise<void>;
migrate(knex: Knex): Promise<void>;
}
@@ -123,4 +123,11 @@ export class AppRepository {
async delete(id: string): Promise<void> {
await this.knex(this.tableName).where('id', id).delete();
}
/**
* Deletes all apps for a session
*/
async deleteBySession(session: string): Promise<void> {
await this.knex(this.tableName).where('session', session).delete();
}
}
+15
View File
@@ -0,0 +1,15 @@
import { AppsService } from '@waha/apps/app_sdk/services/IAppsService';
import { AppsDisabledService } from '@waha/apps/app_sdk/services/AppsDisabledService';
import { AppsController } from '@waha/apps/app_sdk/api/apps.controller';
import { ChatwootLocalesController } from '@waha/apps/chatwoot/api/chatwoot.locales.controller';
export const AppsDisabled = {
providers: [
{
provide: AppsService,
useClass: AppsDisabledService,
},
],
imports: [],
controllers: [AppsController, ChatwootLocalesController],
};
+127
View File
@@ -0,0 +1,127 @@
import { BullModule } from '@nestjs/bullmq';
import { RedisModule } from '@liaoliaots/nestjs-redis';
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 { 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 { Auth } from '@waha/core/auth/config';
import { AppRuntimeConfig } from '@waha/apps/app_sdk/apps/AppRuntime';
import { AppName } from '@waha/apps/app_sdk/apps/name';
const QUEUES_IMPORTS_REQUIRED = [
BullModule.forRoot({
connection: {
url: process.env.REDIS_URL || 'redis://:redis@localhost:6379',
maxRetriesPerRequest: null,
},
prefix: `waha-${process.env.WAHA_WORKER_ID}`,
}),
RedisModule.forRoot({
closeClient: true,
config: {
url: process.env.REDIS_URL || 'redis://:redis@localhost:6379',
onClientCreated: async (client) => {
try {
await client.ping();
} catch (err) {
console.error('[Redis] Connection failed:', err);
process.exit(1); // Stop the app if Redis is unavailable
}
},
},
}),
RMutexModule,
BullBoardModule.forRoot({
route: '/jobs',
adapter: ExpressAdapter,
middleware: BullAuthMiddleware(),
boardOptions: {
uiConfig: {
boardTitle: 'Jobs | WAHA',
boardLogo: {
path: '/dashboard/layout/images/logo-white.svg',
width: 35,
height: 35,
},
favIcon: {
default: '/dashboard/favicon.ico',
alternative: '/dashboard/favicon.ico',
},
miscLinks: [
{
text: '📊 Dashboard',
url: '/dashboard',
},
{
text: '📚 Swagger (OpenAPI)',
url: '/',
},
],
},
},
}),
];
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;
default:
throw Error(`App module not found for ${name}`);
}
}
export const AppsEnabled = {
imports: [
...QUEUES_IMPORTS,
...getAppModule(AppName.chatwoot).imports,
...getAppModule(AppName.calls).imports,
],
controllers: [
AppsController,
...getAppModule(AppName.chatwoot).controllers,
...getAppModule(AppName.calls).controllers,
],
providers: [
{
provide: AppsService,
useClass: AppsEnabledService,
},
...getAppModule(AppName.calls).providers,
...getAppModule(AppName.chatwoot).providers,
],
};
function checkApiKey() {
const key = Auth.key.value;
if (!key) {
return;
}
const plain = Auth.keyplain.value;
if (!plain) {
throw Error(
'WAHA_API_KEY set, please provide WAHA_API_KEY_PLAIN when WAHA_APPS_ENABLED',
);
}
}
if (AppRuntimeConfig.HasAppsRequiringPlainKey()) {
checkApiKey();
}
+6 -112
View File
@@ -1,113 +1,7 @@
import * as process from 'node:process';
import { AppRuntimeConfig } from '@waha/apps/app_sdk/apps/AppRuntime';
import { AppsDisabled } from '@waha/apps/apps.module.disabled';
import { AppsEnabled } from '@waha/apps/apps.module.enabled';
import { ExpressAdapter } from '@bull-board/express';
import { BullBoardModule } from '@bull-board/nestjs';
import { RedisModule } from '@liaoliaots/nestjs-redis';
import { BullModule } from '@nestjs/bullmq';
import { AppsController } from '@waha/apps/app_sdk/api/apps.controller';
import { BullAuthMiddleware } from '@waha/apps/app_sdk/auth';
import { AppsDisabledService } from '@waha/apps/app_sdk/services/AppsDisabledService';
import { AppsEnabledService } from '@waha/apps/app_sdk/services/AppsEnabledService';
import { AppsService } from '@waha/apps/app_sdk/services/IAppsService';
import { ChatwootLocalesController } from '@waha/apps/chatwoot/api/chatwoot.locales.controller';
import { ChatWootExports } from '@waha/apps/chatwoot/chatwoot.module';
import { parseBool } from '@waha/helpers';
import { RMutexModule } from '@waha/modules/rmutex';
import { Auth } from '@waha/core/auth/config';
const IMPORTS = [
BullModule.forRoot({
connection: {
url: process.env.REDIS_URL || 'redis://:redis@localhost:6379',
maxRetriesPerRequest: null,
},
prefix: `waha-${process.env.WAHA_WORKER_ID}`,
}),
RedisModule.forRoot({
closeClient: true,
config: {
url: process.env.REDIS_URL || 'redis://:redis@localhost:6379',
onClientCreated: async (client) => {
try {
await client.ping();
} catch (err) {
console.error('[Redis] Connection failed:', err);
process.exit(1); // Stop the app if Redis is unavailable
}
},
},
}),
RMutexModule,
BullBoardModule.forRoot({
route: '/jobs',
adapter: ExpressAdapter,
middleware: BullAuthMiddleware(),
boardOptions: {
uiConfig: {
boardTitle: 'Jobs | WAHA',
boardLogo: {
path: '/dashboard/layout/images/logo-white.svg',
width: 35,
height: 35,
},
favIcon: {
default: '/dashboard/favicon.ico',
alternative: '/dashboard/favicon.ico',
},
miscLinks: [
{
text: '📊 Dashboard',
url: '/dashboard',
},
{
text: '📚 Swagger (OpenAPI)',
url: '/',
},
],
},
},
}),
...ChatWootExports.imports,
];
const AppsEnabled = {
imports: IMPORTS,
controllers: [AppsController, ...ChatWootExports.controllers],
providers: [
{
provide: AppsService,
useClass: AppsEnabledService,
},
...ChatWootExports.providers,
],
};
const AppsDisabled = {
providers: [
{
provide: AppsService,
useClass: AppsDisabledService,
},
],
imports: [],
controllers: [AppsController, ChatwootLocalesController],
};
function checkApiKey() {
const key = Auth.key.value;
if (!key) {
return;
}
const plain = Auth.keyplain.value;
if (!plain) {
throw Error(
'WAHA_API_KEY set, please provide WAHA_API_KEY_PLAIN when WAHA_APPS_ENABLED',
);
}
}
const enabled = parseBool(process.env.WAHA_APPS_ENABLED);
if (enabled) {
checkApiKey();
}
export const AppsModuleExports = enabled ? AppsEnabled : AppsDisabled;
export const AppsModuleExports = AppRuntimeConfig.Enabled()
? AppsEnabled
: AppsDisabled;
+7
View File
@@ -0,0 +1,7 @@
import { CallsAppService } from '@waha/apps/calls/services/CallsAppService';
export const CallsAppExports = {
providers: [CallsAppService],
imports: [],
controllers: [],
};
+44
View File
@@ -0,0 +1,44 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator';
export class CallsAppChannelConfig {
@ApiProperty({
description: 'Reject incoming calls for this chat type',
default: true,
})
@IsBoolean()
reject: boolean = true;
@ApiProperty({
description:
'Optional auto-reply message sent after the call is rejected. If empty, no message is sent.',
required: false,
})
@IsOptional()
@IsString()
message?: string;
}
export class CallsAppConfig {
@ApiProperty({
description: 'Rules applied to direct messages (non-group calls)',
type: CallsAppChannelConfig,
})
@ValidateNested()
@Type(() => CallsAppChannelConfig)
dm: CallsAppChannelConfig = new CallsAppChannelConfig();
@ApiProperty({
description: 'Rules applied to group calls',
type: CallsAppChannelConfig,
})
@ValidateNested()
@Type(() => CallsAppChannelConfig)
group: CallsAppChannelConfig = new CallsAppChannelConfig();
}
@@ -0,0 +1,67 @@
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 { CallsAppConfig } from '@waha/apps/calls/dto/config.dto';
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;
return;
}
async beforeCreated(app: App<CallsAppConfig>): Promise<void> {
void app;
return;
}
async beforeEnabled(
savedApp: App<CallsAppConfig>,
newApp: App<CallsAppConfig>,
): Promise<void> {
// Enabling behaves the same as creating for this lightweight app.
void savedApp;
void newApp;
return;
}
async beforeDisabled(
savedApp: App<CallsAppConfig>,
newApp: App<CallsAppConfig>,
): Promise<void> {
void newApp;
void savedApp;
}
async beforeUpdated(
savedApp: App<CallsAppConfig>,
newApp: App<CallsAppConfig>,
): Promise<void> {
void savedApp;
void newApp;
}
async beforeDeleted(app: App<CallsAppConfig>): Promise<void> {
void app;
}
beforeSessionStart(app: App<CallsAppConfig>, session: WhatsappSession): void {
const listener = new CallsListener(app, session, this.logger);
listener.attach();
}
afterSessionStart(app: App<CallsAppConfig>, session: WhatsappSession): void {
void app;
void session;
return;
}
}
+151
View File
@@ -0,0 +1,151 @@
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 { CallData } from '@waha/structures/calls.dto';
import { MessageTextRequest } from '@waha/structures/chatting.dto';
import { sleep } from '@waha/utils/promiseTimeout';
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 },
'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 {
return call?.isGroup ? this.config?.group : this.config?.dm;
}
private async handleCall(call: CallData): Promise<void> {
if (!call.from) {
this.log.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');
return;
}
const config = this.configFor(call);
if (!config) {
this.log.warn({ callId: call.id }, 'No calls config found, skipping');
return;
}
const message = (config.message || '').trim();
const shouldReject = !!config.reject;
const shouldMessage = message.length > 0;
if (!shouldReject && !shouldMessage) {
this.log.debug(
{ callId: call.id, chatId: call.from },
'No actions configured for this call',
);
return;
}
if (shouldReject) {
await this.rejectCall(call);
}
if (shouldMessage) {
await this.replyWithTyping(call.from, message);
}
}
private async rejectCall(call: CallData): Promise<void> {
this.log.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');
}
private async replyWithTyping(
chatId: string,
message: string,
): Promise<void> {
this.log.info(
{ chatId: chatId },
'Sending auto-response for rejected call',
);
await this.setTyping(chatId);
await this.setPaused(chatId);
await this.session.sendText({
session: this.session.name,
chatId: chatId,
text: message,
} as MessageTextRequest);
}
private async setTyping(chatId: string): Promise<void> {
try {
await this.session.setPresence(WAHAPresenceStatus.TYPING, chatId);
} catch (error) {
this.log.warn(
{ err: error, chatId: chatId },
'Failed to set typing presence before reply',
);
return;
}
await sleep(2000);
}
private async setPaused(chatId: string): Promise<void> {
try {
await this.session.setPresence(WAHAPresenceStatus.PAUSED, chatId);
} catch (error) {
this.log.warn(
{ err: error, chatId: chatId },
'Failed to clear typing presence after reply',
);
}
}
}
+18
View File
@@ -23,6 +23,9 @@ import { WAHAMessageReactionConsumer } from './consumers/waha/message.reaction';
import { WAHAMessageRevokedConsumer } from './consumers/waha/message.revoked';
import { WAHAMessageAckConsumer } from './consumers/waha/message.ack';
import { WAHASessionStatusConsumer } from './consumers/waha/session.status';
import { WAHACallAcceptedConsumer } from './consumers/waha/call.accepted';
import { WAHACallReceivedConsumer } from './consumers/waha/call.received';
import { WAHACallRejectedConsumer } from './consumers/waha/call.rejected';
import { ChatWootQueueService } from './services/ChatWootQueueService';
import { ChatWootScheduleService } from './services/ChatWootScheduleService';
import { ChatWootWAHAQueueService } from './services/ChatWootWAHAQueueService';
@@ -76,6 +79,18 @@ const IMPORTS = lodash.flatten([
name: QueueName.WAHA_MESSAGE_ACK,
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
}),
RegisterAppQueue({
name: QueueName.WAHA_CALL_RECEIVED,
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
}),
RegisterAppQueue({
name: QueueName.WAHA_CALL_ACCEPTED,
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
}),
RegisterAppQueue({
name: QueueName.WAHA_CALL_REJECTED,
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
}),
RegisterAppQueue({
name: QueueName.WAHA_SESSION_STATUS,
defaultJobOptions: merge(ExponentialRetriesJobOptions, JobRemoveOptions),
@@ -124,6 +139,9 @@ const PROVIDERS = [
WAHAMessageEditedConsumer,
WAHAMessageRevokedConsumer,
WAHAMessageAckConsumer,
WAHACallReceivedConsumer,
WAHACallAcceptedConsumer,
WAHACallRejectedConsumer,
// Scheduled
MessageCleanupConsumer,
CheckVersionConsumer,
+3
View File
@@ -20,6 +20,9 @@ export enum QueueName {
WAHA_MESSAGE_EDITED = 'chatwoot.waha | message.edited',
WAHA_MESSAGE_REVOKED = 'chatwoot.waha | message.revoked',
WAHA_MESSAGE_ACK = 'chatwoot.waha | message.ack',
WAHA_CALL_RECEIVED = 'chatwoot.waha | call.received',
WAHA_CALL_ACCEPTED = 'chatwoot.waha | call.accepted',
WAHA_CALL_REJECTED = 'chatwoot.waha | call.rejected',
//
// ChatWoot Events - Real
//
+29 -8
View File
@@ -24,7 +24,7 @@ import { SessionManager } from '@waha/core/abc/manager.abc';
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import { WAHAEvents } from '@waha/structures/enums.dto';
import { MessageSource, WAMessageBase } from '@waha/structures/responses.dto';
import { MessageSource } from '@waha/structures/responses.dto';
import { sleep } from '@waha/utils/promiseTimeout';
import { Job } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
@@ -34,16 +34,26 @@ import { isJidBroadcast, isJidGroup, toCusFormat } from '@waha/core/utils/jids';
import { EngineHelper } from '@waha/apps/chatwoot/waha';
import { EnsureSeconds } from '@waha/utils/timehelper';
import { CHATWOOT_MESSAGE_CALENDAR_THRESHOLD_SECONDS } from '@waha/apps/chatwoot/env';
import {
ChatWootAppConfig,
ChatWootConfig,
} from '@waha/apps/chatwoot/dto/config.dto';
export function ListenEventsForChatWoot() {
return [
export function ListenEventsForChatWoot(config: ChatWootConfig) {
const events = [
WAHAEvents.MESSAGE_ANY,
WAHAEvents.MESSAGE_REACTION,
WAHAEvents.MESSAGE_EDITED,
WAHAEvents.MESSAGE_REVOKED,
WAHAEvents.MESSAGE_ACK,
WAHAEvents.SESSION_STATUS,
WAHAEvents.CALL_RECEIVED,
WAHAEvents.CALL_ACCEPTED,
WAHAEvents.CALL_REJECTED,
];
if (config.conversations.markAsRead) {
events.push(WAHAEvents.MESSAGE_ACK);
}
return events;
}
/**
@@ -195,7 +205,17 @@ export interface ChatWootMessagePartial {
private?: boolean;
}
export abstract class MessageBaseHandler<Payload extends WAMessageBase> {
export interface MessageBaseHandlerPayload {
id: string;
timestamp: number;
from?: string;
fromMe?: boolean;
source?: MessageSource;
}
export abstract class MessageBaseHandler<
Payload extends MessageBaseHandlerPayload,
> {
constructor(
protected job: Job,
protected mappingService: MessageMappingService,
@@ -305,7 +325,7 @@ export abstract class MessageBaseHandler<Payload extends WAMessageBase> {
private async saveMapping(
chatwootMessage: generic_id & message,
whatsappMessage: WAMessageBase,
whatsappMessage: MessageBaseHandlerPayload,
) {
const chatwoot: Omit<ChatwootMessage, 'id'> = {
timestamp: new Date(chatwootMessage.created_at * 1000),
@@ -369,12 +389,13 @@ export abstract class MessageBaseHandler<Payload extends WAMessageBase> {
},
);
const type = payload.fromMe ? MessageType.OUTGOING : MessageType.INCOMING;
const private_ = message.private ?? payload.fromMe;
const type = private_ ? MessageType.OUTGOING : MessageType.INCOMING;
content = this.finalizeContent(content, payload);
return {
content: content,
message_type: type,
private: message.private ?? payload.fromMe,
private: private_,
attachments: message.attachments as any,
content_attributes: {
in_reply_to: replyTo,
@@ -0,0 +1,58 @@
import { CallData } from '@waha/structures/calls.dto';
import { MessageSource } from '@waha/structures/responses.dto';
import { SerializeMessageKey } from '@waha/core/utils/ids';
import { WAHAEvents } from '@waha/structures/enums.dto';
import { MessageBaseHandlerPayload } from '@waha/apps/chatwoot/consumers/waha/base';
import { EngineHelper } from '@waha/apps/chatwoot/waha';
export function ShouldProcessCall(event: { payload: CallData }): boolean {
if (event.payload.isGroup) {
// No group calls for now
// GroupJID (group-jid tag) is empty in all cases
// and the "call" appears in the 1-1 chat
// with the participant who started the call
return false;
}
return true;
}
// The MessageBaseHandler works on top of "MessageBase",
// but we need the call original properties, so we keep it here
export type CallMessagePayload = CallData &
MessageBaseHandlerPayload & { callId: string };
const SUFFIX = {
[WAHAEvents.CALL_RECEIVED]: '0',
[WAHAEvents.CALL_ACCEPTED]: '1',
[WAHAEvents.CALL_REJECTED]: '2',
};
type Events = keyof typeof SUFFIX;
/**
* So we can reference from "accepted" or "rejected" to the first one, "received"
* and "reply" to it
*/
export function BuildFakeCallMessageId(id: string, event: Events) {
return id + SUFFIX[event];
}
export function BuildCallMessagePayload(
call: CallData,
event: Events,
): CallMessagePayload {
const key = {
fromMe: false,
remoteJid: EngineHelper.CallChatID(call),
id: BuildFakeCallMessageId(call.id, event),
};
const id = SerializeMessageKey(key);
return {
...call,
callId: call.id,
id: id,
// Use the "Creator" one if there's any
from: EngineHelper.CallChatID(call),
fromMe: false,
source: MessageSource.APP,
};
}
@@ -0,0 +1,88 @@
import { Processor } from '@nestjs/bullmq';
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
import { EventData } from '@waha/apps/chatwoot/consumers/types';
import {
ChatWootMessagePartial,
ChatWootWAHABaseConsumer,
IMessageInfo,
MessageBaseHandler,
} from '@waha/apps/chatwoot/consumers/waha/base';
import {
BuildCallMessagePayload,
BuildFakeCallMessageId,
CallMessagePayload,
ShouldProcessCall,
} from '@waha/apps/chatwoot/consumers/waha/call.0.base';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { EngineHelper } from '@waha/apps/chatwoot/waha';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import { WAHAEvents } from '@waha/structures/enums.dto';
import { WAHAWebhookCallAccepted } from '@waha/structures/webhooks.dto';
import { Job } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { WhatsappToMarkdown } from '@waha/apps/chatwoot/messages/to/chatwoot/utils/markdown';
@Processor(QueueName.WAHA_CALL_ACCEPTED, { concurrency: JOB_CONCURRENCY })
export class WAHACallAcceptedConsumer extends ChatWootWAHABaseConsumer {
constructor(
protected readonly manager: SessionManager,
log: PinoLogger,
rmutex: RMutexService,
) {
super(manager, log, rmutex, 'WAHACallAcceptedConsumer');
}
ShouldProcess(event: WAHAWebhookCallAccepted): boolean {
return ShouldProcessCall(event);
}
GetChatId(event: WAHAWebhookCallAccepted): string {
return EngineHelper.CallChatID(event.payload as any);
}
async Process(
job: Job<EventData, any, WAHAEvents>,
info: IMessageInfo,
): Promise<any> {
const container = await this.DIContainer(job, job.data.app);
const event = job.data.event as WAHAWebhookCallAccepted;
const locale = container.Locale();
const handler = new CallAcceptedMessageHandler(
job,
container.MessageMappingService(),
container.ContactConversationService(),
container.Logger(),
info,
new WAHASessionAPI(event.session, container.WAHASelf()),
locale,
container.WAHASelf(),
);
const msg = BuildCallMessagePayload(
event.payload,
WAHAEvents.CALL_ACCEPTED,
);
return await handler.handle(msg);
}
}
export class CallAcceptedMessageHandler extends MessageBaseHandler<CallMessagePayload> {
protected async getMessage(
payload: CallMessagePayload,
): Promise<ChatWootMessagePartial> {
const content = this.l
.key(TKey.WA_TO_CW_CALL_ACCEPTED)
.render({ call: payload });
return {
content: WhatsappToMarkdown(content),
attachments: [],
private: true,
};
}
getReplyToWhatsAppID(payload: CallMessagePayload): string | undefined {
return BuildFakeCallMessageId(payload.callId, WAHAEvents.CALL_RECEIVED);
}
}
@@ -0,0 +1,92 @@
import { Processor } from '@nestjs/bullmq';
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
import { EventData } from '@waha/apps/chatwoot/consumers/types';
import {
ChatWootMessagePartial,
ChatWootWAHABaseConsumer,
IMessageInfo,
MessageBaseHandler,
MessageBaseHandlerPayload,
} from '@waha/apps/chatwoot/consumers/waha/base';
import {
BuildCallMessagePayload,
CallMessagePayload,
ShouldProcessCall,
} from '@waha/apps/chatwoot/consumers/waha/call.0.base';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { EngineHelper } from '@waha/apps/chatwoot/waha';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import { WAHAEvents } from '@waha/structures/enums.dto';
import { WAHAWebhookCallReceived } from '@waha/structures/webhooks.dto';
import { Job } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { CallData } from '@waha/structures/calls.dto';
import { WhatsappToMarkdown } from '@waha/apps/chatwoot/messages/to/chatwoot/utils/markdown';
@Processor(QueueName.WAHA_CALL_RECEIVED, { concurrency: JOB_CONCURRENCY })
export class WAHACallReceivedConsumer extends ChatWootWAHABaseConsumer {
constructor(
protected readonly manager: SessionManager,
log: PinoLogger,
rmutex: RMutexService,
) {
super(manager, log, rmutex, 'WAHACallReceivedConsumer');
}
ShouldProcess(event: WAHAWebhookCallReceived): boolean {
return ShouldProcessCall(event);
}
GetChatId(event: WAHAWebhookCallReceived): string {
return EngineHelper.CallChatID(event.payload as any);
}
async Process(
job: Job<EventData, any, WAHAEvents>,
info: IMessageInfo,
): Promise<any> {
const container = await this.DIContainer(job, job.data.app);
const event = job.data.event as WAHAWebhookCallReceived;
const locale = container.Locale();
const handler = new CallReceivedMessageHandler(
job,
container.MessageMappingService(),
container.ContactConversationService(),
container.Logger(),
info,
new WAHASessionAPI(event.session, container.WAHASelf()),
locale,
container.WAHASelf(),
);
const msg = BuildCallMessagePayload(
event.payload,
event.event as WAHAEvents.CALL_RECEIVED,
);
return await handler.handle(msg);
}
}
export class CallReceivedMessageHandler extends MessageBaseHandler<CallMessagePayload> {
protected async getMessage(
payload: MessageBaseHandlerPayload & CallData,
): Promise<ChatWootMessagePartial> {
const content = this.l
.key(TKey.WA_TO_CW_CALL_RECEIVED)
.render({ call: payload });
return {
content: WhatsappToMarkdown(content),
attachments: [],
private: false,
};
}
getReplyToWhatsAppID(
payload: MessageBaseHandlerPayload & CallData,
): string | undefined {
// No reply required for the first call received
return undefined;
}
}
@@ -0,0 +1,102 @@
import { Processor } from '@nestjs/bullmq';
import { JOB_CONCURRENCY } from '@waha/apps/app_sdk/constants';
import { WAHASessionAPI } from '@waha/apps/app_sdk/waha/WAHASelf';
import { QueueName } from '@waha/apps/chatwoot/consumers/QueueName';
import { EventData } from '@waha/apps/chatwoot/consumers/types';
import {
ChatWootMessagePartial,
ChatWootWAHABaseConsumer,
IMessageInfo,
MessageBaseHandler,
} from '@waha/apps/chatwoot/consumers/waha/base';
import {
BuildCallMessagePayload,
BuildFakeCallMessageId,
CallMessagePayload,
ShouldProcessCall,
} from '@waha/apps/chatwoot/consumers/waha/call.0.base';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { EngineHelper } from '@waha/apps/chatwoot/waha';
import { RMutexService } from '@waha/modules/rmutex/rmutex.service';
import { WAHAEvents } from '@waha/structures/enums.dto';
import { WAHAWebhookCallRejected } from '@waha/structures/webhooks.dto';
import { Job } from 'bullmq';
import { PinoLogger } from 'nestjs-pino';
import { TKey } from '@waha/apps/chatwoot/i18n/templates';
import { WhatsappToMarkdown } from '@waha/apps/chatwoot/messages/to/chatwoot/utils/markdown';
@Processor(QueueName.WAHA_CALL_REJECTED, { concurrency: JOB_CONCURRENCY })
export class WAHACallRejectedConsumer extends ChatWootWAHABaseConsumer {
constructor(
protected readonly manager: SessionManager,
log: PinoLogger,
rmutex: RMutexService,
) {
super(manager, log, rmutex, 'WAHACallRejectedConsumer');
}
ShouldProcess(event: WAHAWebhookCallRejected): boolean {
return ShouldProcessCall(event);
}
GetChatId(event: WAHAWebhookCallRejected): string {
return EngineHelper.CallChatID(event.payload as any);
}
async Process(
job: Job<EventData, any, WAHAEvents>,
info: IMessageInfo,
): Promise<any> {
const container = await this.DIContainer(job, job.data.app);
const event = job.data.event as WAHAWebhookCallRejected;
const locale = container.Locale();
const handler = new CallRejectedMessageHandler(
job,
container.MessageMappingService(),
container.ContactConversationService(),
container.Logger(),
info,
new WAHASessionAPI(event.session, container.WAHASelf()),
locale,
container.WAHASelf(),
);
const msg = BuildCallMessagePayload(
event.payload,
WAHAEvents.CALL_REJECTED,
);
return await handler.handle(msg);
}
}
export class CallRejectedMessageHandler extends MessageBaseHandler<CallMessagePayload> {
protected async getMessage(
payload: CallMessagePayload,
): Promise<ChatWootMessagePartial> {
const content = this.l
.key(TKey.WA_TO_CW_CALL_REJECTED)
.render({ call: payload });
let private_ = false;
// GOWS
if (payload._data.Data?.Tag == 'reject') {
private_ = true;
}
if (payload._data.Reason == 'rejected_elsewhere') {
private_ = true;
}
// WEBJS
if (payload._data.api) {
private_ = true;
}
return {
content: WhatsappToMarkdown(content),
attachments: [],
private: private_,
};
}
getReplyToWhatsAppID(payload: CallMessagePayload): string | undefined {
return BuildFakeCallMessageId(payload.callId, WAHAEvents.CALL_RECEIVED);
}
}
+23 -13
View File
@@ -207,18 +207,28 @@ export class DIContainer {
@CacheSync()
public ChatWootConfig(): ChatWootConfig {
const defaults: ChatWootConfig = {
templates: {},
linkPreview: LinkPreview.OFF,
commands: {
server: true,
queue: false,
},
conversations: {
sort: ConversationSort.created_newest,
status: null,
},
};
return lodash.defaults({}, this.config, defaults);
return ChatWootConfigDefaults(this.config);
}
}
/**
* Applies default values to the ChatWoot configuration
*/
export function ChatWootConfigDefaults(
config: ChatWootAppConfig,
): ChatWootConfig {
const defaults: ChatWootConfig = {
templates: {},
linkPreview: LinkPreview.OFF,
commands: {
server: true,
queue: false,
},
conversations: {
sort: ConversationSort.created_newest,
status: null,
markAsRead: true,
},
};
return lodash.defaultsDeep({}, config, defaults);
}
+11 -4
View File
@@ -7,12 +7,10 @@ import {
IsString,
ValidateNested,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsDynamicObject } from '@waha/nestjs/validation/IsDynamicObject';
import {
ConversationSelectorConfig,
ConversationSort,
} from '@waha/apps/chatwoot/services/ConversationSelector';
import { ConversationSort } from '@waha/apps/chatwoot/services/ConversationSelector';
import { ConversationStatus } from '@waha/apps/chatwoot/client/types';
export const DEFAULT_LOCALE = 'en-US';
@@ -39,6 +37,15 @@ export class ChatWootConversationsConfig {
@IsOptional()
@IsEnum(ConversationStatus, { each: true })
status: Array<ConversationStatus> | null;
@ApiPropertyOptional({
description:
'Process message.ack events to mark ChatWoot conversations as read. Enabled by default.',
default: true,
})
@IsOptional()
@IsBoolean()
markAsRead?: boolean = true;
}
export interface ChatWootConfig {
+12
View File
@@ -522,6 +522,18 @@ whatsapp.to.chatwoot.message.facebook.ad: |-
---
💬 **رسالة العميل:**
{{{payload.body}}}
whatsapp.to.chatwoot.call.received: |-
📞🔔 *مكالمة {{#call.isVideo}}فيديو{{/call.isVideo}}{{^call.isVideo}}صوتية{{/call.isVideo}} واردة*
📱 يرجى فتح **واتساب** للرد.
whatsapp.to.chatwoot.call.accepted: |-
📞✅ *تم الرد على المكالمة*
📱 تم الرد على المكالمة من جهازك الآخر.
whatsapp.to.chatwoot.call.rejected: |-
📞❌ *تم إنهاء المكالمة*
whatsapp.history.message.wrapper: |-
{{{content}}}
`{{#history}}📥{{/history}}🗓️ {{{timestamp}}}`
+12
View File
@@ -522,6 +522,18 @@ whatsapp.to.chatwoot.message.facebook.ad: |-
---
💬 **গ্রাহকের বার্তা:**
{{{payload.body}}}
whatsapp.to.chatwoot.call.received: |-
📞🔔 *ইনকামিং {{#call.isVideo}}ভিডিও{{/call.isVideo}}{{^call.isVideo}}ভয়েস{{/call.isVideo}} কল*
📱 উত্তর দিতে দয়া করে **WhatsApp** খুলুন।
whatsapp.to.chatwoot.call.accepted: |-
📞✅ *কল রিসিভ করা হয়েছে*
📱 কলটি আপনার অন্য ডিভাইসে রিসিভ করা হয়েছে।
whatsapp.to.chatwoot.call.rejected: |-
📞❌ *কল শেষ হয়েছে*
whatsapp.history.message.wrapper: |-
{{{content}}}
`{{#history}}📥{{/history}}🗓️ {{{timestamp}}}`
+12
View File
@@ -525,6 +525,18 @@ whatsapp.to.chatwoot.message.facebook.ad: |-
---
💬 **Kundennachricht:**
{{{payload.body}}}
whatsapp.to.chatwoot.call.received: |-
📞🔔 *Eingehender {{#call.isVideo}}Video{{/call.isVideo}}{{^call.isVideo}}Sprach{{/call.isVideo}}anruf*
📱 Bitte öffne **WhatsApp**, um anzunehmen.
whatsapp.to.chatwoot.call.accepted: |-
📞✅ *Anruf angenommen*
📱 Der Anruf wurde auf deinem anderen Gerät angenommen.
whatsapp.to.chatwoot.call.rejected: |-
📞❌ *Anruf beendet*
whatsapp.history.message.wrapper: |-
{{{content}}}
`{{#history}}📥{{/history}}🗓️ {{{timestamp}}}`
+11
View File
@@ -316,6 +316,17 @@ whatsapp.to.chatwoot.message.facebook.ad: |-
💬 **CUSTOMER MESSAGE:**
{{{payload.body}}}
whatsapp.to.chatwoot.call.received: |-
📞🔔 *Incoming {{#call.isVideo}}Video{{/call.isVideo}}{{^call.isVideo}}Voice{{/call.isVideo}} Call*
📱 Please open **WhatsApp** to answer.
whatsapp.to.chatwoot.call.accepted: |-
📞✅ *Call Answered*
📱 The call was picked up on your other device.
whatsapp.to.chatwoot.call.rejected: |-
📞❌ *Call Ended*
app.connected.message: |-
🔗 **CONNECTED: WhatsApp Session to Inbox**
+12
View File
@@ -522,6 +522,18 @@ whatsapp.to.chatwoot.message.facebook.ad: |-
---
💬 **Mensaje del cliente:**
{{{payload.body}}}
whatsapp.to.chatwoot.call.received: |-
📞🔔 *Llamada {{#call.isVideo}}de video{{/call.isVideo}}{{^call.isVideo}}de voz{{/call.isVideo}} entrante*
📱 Abre **WhatsApp** para contestar.
whatsapp.to.chatwoot.call.accepted: |-
📞✅ *Llamada respondida*
📱 La llamada fue contestada en tu otro dispositivo.
whatsapp.to.chatwoot.call.rejected: |-
📞❌ *Llamada finalizada*
whatsapp.history.message.wrapper: |-
{{{content}}}
`{{#history}}📥{{/history}}🗓️ {{{timestamp}}}`
+12
View File
@@ -522,6 +522,18 @@ whatsapp.to.chatwoot.message.facebook.ad: |-
---
💬 **پیام مشتری:**
{{{payload.body}}}
whatsapp.to.chatwoot.call.received: |-
📞🔔 *تماس {{#call.isVideo}}تصویری{{/call.isVideo}}{{^call.isVideo}}صوتی{{/call.isVideo}} ورودی*
📱 برای پاسخ دادن لطفاً **واتساپ** را باز کنید.
whatsapp.to.chatwoot.call.accepted: |-
📞✅ *تماس پاسخ داده شد*
📱 تماس روی دستگاه دیگر شما پاسخ داده شد.
whatsapp.to.chatwoot.call.rejected: |-
📞❌ *تماس پایان یافت*
whatsapp.history.message.wrapper: |-
{{{content}}}
`{{#history}}📥{{/history}}🗓️ {{{timestamp}}}`
+12
View File
@@ -522,6 +522,18 @@ whatsapp.to.chatwoot.message.facebook.ad: |-
---
💬 **Message du client :**
{{{payload.body}}}
whatsapp.to.chatwoot.call.received: |-
📞🔔 *Appel {{#call.isVideo}}vidéo{{/call.isVideo}}{{^call.isVideo}}vocal{{/call.isVideo}} entrant*
📱 Ouvre **WhatsApp** pour répondre.
whatsapp.to.chatwoot.call.accepted: |-
📞✅ *Appel décroché*
📱 L'appel a été pris sur ton autre appareil.
whatsapp.to.chatwoot.call.rejected: |-
📞❌ *Appel terminé*
whatsapp.history.message.wrapper: |-
{{{content}}}
`{{#history}}📥{{/history}}🗓️ {{{timestamp}}}`
+12
View File
@@ -522,6 +522,18 @@ whatsapp.to.chatwoot.message.facebook.ad: |-
---
💬 **הודעת הלקוח:**
{{{payload.body}}}
whatsapp.to.chatwoot.call.received: |-
📞🔔 *שיחת {{#call.isVideo}}וידאו{{/call.isVideo}}{{^call.isVideo}}קול{{/call.isVideo}} נכנסת*
📱 פתח/י את **WhatsApp** כדי לענות.
whatsapp.to.chatwoot.call.accepted: |-
📞✅ *השיחה נענתה*
📱 השיחה נענתה במכשיר אחר שלך.
whatsapp.to.chatwoot.call.rejected: |-
📞❌ *השיחה הסתיימה*
whatsapp.history.message.wrapper: |-
{{{content}}}
`{{#history}}📥{{/history}}🗓️ {{{timestamp}}}`
+12
View File
@@ -522,6 +522,18 @@ whatsapp.to.chatwoot.message.facebook.ad: |-
---
💬 **ग्राहक संदेश:**
{{{payload.body}}}
whatsapp.to.chatwoot.call.received: |-
📞🔔 *आने वाली {{#call.isVideo}}वीडियो{{/call.isVideo}}{{^call.isVideo}}वॉयस{{/call.isVideo}} कॉल*
📱 जवाब देने के लिए कृपया **WhatsApp** खोलें।
whatsapp.to.chatwoot.call.accepted: |-
📞✅ *कॉल का जवाब दिया गया*
📱 कॉल आपके दूसरे डिवाइस पर उठाई गई।
whatsapp.to.chatwoot.call.rejected: |-
📞❌ *कॉल समाप्त हुई*
whatsapp.history.message.wrapper: |-
{{{content}}}
`{{#history}}📥{{/history}}🗓️ {{{timestamp}}}`
+12
View File
@@ -522,6 +522,18 @@ whatsapp.to.chatwoot.message.facebook.ad: |-
---
💬 **Pesan Pelanggan:**
{{{payload.body}}}
whatsapp.to.chatwoot.call.received: |-
📞🔔 *Panggilan {{#call.isVideo}}Video{{/call.isVideo}}{{^call.isVideo}}Suara{{/call.isVideo}} Masuk*
📱 Silakan buka **WhatsApp** untuk menjawab.
whatsapp.to.chatwoot.call.accepted: |-
📞✅ *Panggilan Dijawab*
📱 Panggilan dijawab di perangkat lainmu.
whatsapp.to.chatwoot.call.rejected: |-
📞❌ *Panggilan Berakhir*
whatsapp.history.message.wrapper: |-
{{{content}}}
`{{#history}}📥{{/history}}🗓️ {{{timestamp}}}`
+12
View File
@@ -522,6 +522,18 @@ whatsapp.to.chatwoot.message.facebook.ad: |-
---
💬 **ਗਾਹਕ ਦਾ ਸੁਨੇਹਾ:**
{{{payload.body}}}
whatsapp.to.chatwoot.call.received: |-
📞🔔 *ਆ ਰਹੀ {{#call.isVideo}}ਵੀਡੀਓ{{/call.isVideo}}{{^call.isVideo}}ਵਾਇਸ{{/call.isVideo}} ਕਾਲ*
📱 ਜਵਾਬ ਦੇਣ ਲਈ ਕਿਰਪਾ ਕਰਕੇ **WhatsApp** ਖੋਲ੍ਹੋ।
whatsapp.to.chatwoot.call.accepted: |-
📞✅ *ਕਾਲ ਦਾ ਜਵਾਬ ਦੇ ਦਿੱਤਾ ਗਿਆ ਹੈ*
📱 ਕਾਲ ਤੁਹਾਡੇ ਹੋਰ ਜੰਤਰ ਤੇ ਉਠਾਈ ਗਈ ਸੀ।
whatsapp.to.chatwoot.call.rejected: |-
📞❌ *ਕਾਲ ਸਮਾਪਤ ਹੋ ਗਈ*
whatsapp.history.message.wrapper: |-
{{{content}}}
`{{#history}}📥{{/history}}🗓️ {{{timestamp}}}`
+12
View File
@@ -527,6 +527,18 @@ whatsapp.to.chatwoot.message.facebook.ad: |-
---
💬 **MENSAGEM DO CLIENTE:**
{{{payload.body}}}
whatsapp.to.chatwoot.call.received: |-
📞🔔 *Chamada {{#call.isVideo}}de vídeo{{/call.isVideo}}{{^call.isVideo}}de voz{{/call.isVideo}} recebida*
📱 Abra o **WhatsApp** para atender.
whatsapp.to.chatwoot.call.accepted: |-
📞✅ *Chamada atendida*
📱 A chamada foi atendida no seu outro dispositivo.
whatsapp.to.chatwoot.call.rejected: |-
📞❌ *Chamada encerrada*
whatsapp.history.message.wrapper: |-
{{{content}}}
`{{#history}}📥{{/history}}🗓️ {{{timestamp}}}`
+12
View File
@@ -522,6 +522,18 @@ whatsapp.to.chatwoot.message.facebook.ad: |-
---
💬 **Сообщение клиента:**
{{{payload.body}}}
whatsapp.to.chatwoot.call.received: |-
📞🔔 *Входящий {{#call.isVideo}}видеозвонок{{/call.isVideo}}{{^call.isVideo}}голосовой звонок{{/call.isVideo}}*
📱 Откройте **WhatsApp**, чтобы ответить.
whatsapp.to.chatwoot.call.accepted: |-
📞✅ *Звонок принят*
📱 Звонок был принят на другом вашем устройстве.
whatsapp.to.chatwoot.call.rejected: |-
📞❌ *Звонок завершен*
whatsapp.history.message.wrapper: |-
{{{content}}}
`{{#history}}📥{{/history}}🗓️ {{{timestamp}}}`
+12
View File
@@ -522,6 +522,18 @@ whatsapp.to.chatwoot.message.facebook.ad: |-
---
💬 **Müşteri Mesajı:**
{{{payload.body}}}
whatsapp.to.chatwoot.call.received: |-
📞🔔 *Gelen {{#call.isVideo}}Görüntülü{{/call.isVideo}}{{^call.isVideo}}Sesli{{/call.isVideo}} Arama*
📱 Yanıtlamak için lütfen **WhatsApp**'ı açın.
whatsapp.to.chatwoot.call.accepted: |-
📞✅ *Arama Yanıtlandı*
📱 Arama diğer cihazınızda açıldı.
whatsapp.to.chatwoot.call.rejected: |-
📞❌ *Arama Sonlandı*
whatsapp.history.message.wrapper: |-
{{{content}}}
`{{#history}}📥{{/history}}🗓️ {{{timestamp}}}`
+12
View File
@@ -522,6 +522,18 @@ whatsapp.to.chatwoot.message.facebook.ad: |-
---
💬 **Повідомлення клієнта:**
{{{payload.body}}}
whatsapp.to.chatwoot.call.received: |-
📞🔔 *Вхідний {{#call.isVideo}}відеодзвінок{{/call.isVideo}}{{^call.isVideo}}голосовий дзвінок{{/call.isVideo}}*
📱 Відкрийте **WhatsApp**, щоб відповісти.
whatsapp.to.chatwoot.call.accepted: |-
📞✅ *Дзвінок прийнято*
📱 Дзвінок прийняли на вашому іншому пристрої.
whatsapp.to.chatwoot.call.rejected: |-
📞❌ *Дзвінок завершено*
whatsapp.history.message.wrapper: |-
{{{content}}}
`{{#history}}📥{{/history}}🗓️ {{{timestamp}}}`
+12
View File
@@ -522,6 +522,18 @@ whatsapp.to.chatwoot.message.facebook.ad: |-
---
💬 **گاہک کا پیغام:**
{{{payload.body}}}
whatsapp.to.chatwoot.call.received: |-
📞🔔 *آنے والی {{#call.isVideo}}ویڈیو{{/call.isVideo}}{{^call.isVideo}}آواز{{/call.isVideo}} کال*
📱 جواب دینے کے لیے براہ کرم **WhatsApp** کھولیں۔
whatsapp.to.chatwoot.call.accepted: |-
📞✅ *کال کا جواب دے دیا گیا*
📱 کال آپ کے دوسرے ڈیوائس پر اٹھائی گئی۔
whatsapp.to.chatwoot.call.rejected: |-
📞❌ *کال ختم ہو گئی*
whatsapp.history.message.wrapper: |-
{{{content}}}
`{{#history}}📥{{/history}}🗓️ {{{timestamp}}}`
+12
View File
@@ -522,6 +522,18 @@ whatsapp.to.chatwoot.message.facebook.ad: |-
---
💬 **客户消息:**
{{{payload.body}}}
whatsapp.to.chatwoot.call.received: |-
📞🔔 *收到{{#call.isVideo}}视频{{/call.isVideo}}{{^call.isVideo}}语音{{/call.isVideo}}来电*
📱 请打开 **WhatsApp** 接听。
whatsapp.to.chatwoot.call.accepted: |-
📞✅ *已接听来电*
📱 来电已在你的其他设备上接听。
whatsapp.to.chatwoot.call.rejected: |-
📞❌ *通话已结束*
whatsapp.history.message.wrapper: |-
{{{content}}}
`{{#history}}📥{{/history}}🗓️ {{{timestamp}}}`
+7
View File
@@ -1,6 +1,7 @@
import type { proto } from '@adiwajshing/baileys';
import { WAMessage } from '@waha/structures/responses.dto';
import { SimpleVCardInfo } from '@waha/core/vcard';
import { CallData } from '@waha/structures/calls.dto';
export enum TKey {
LOCALE_NAME = 'locale.name',
@@ -57,6 +58,9 @@ export enum TKey {
WA_TO_CW_MESSAGE_FACEBOOK_AD = 'whatsapp.to.chatwoot.message.facebook.ad',
WA_TO_CW_MESSAGE_LIST = 'whatsapp.to.chatwoot.message.list',
WA_TO_CW_MESSAGE_ALBUM = 'whatsapp.to.chatwoot.message.album',
WA_TO_CW_CALL_RECEIVED = 'whatsapp.to.chatwoot.call.received',
WA_TO_CW_CALL_ACCEPTED = 'whatsapp.to.chatwoot.call.accepted',
WA_TO_CW_CALL_REJECTED = 'whatsapp.to.chatwoot.call.rejected',
//
// App Inbox
@@ -195,6 +199,9 @@ export type TemplatePayloads = {
expectedVideoCount: number;
totalCount: number;
};
[TKey.WA_TO_CW_CALL_RECEIVED]: { call: CallData };
[TKey.WA_TO_CW_CALL_ACCEPTED]: { call: CallData };
[TKey.WA_TO_CW_CALL_REJECTED]: { call: CallData };
[TKey.JOB_SCHEDULED_ERROR_HEADER]: void;
[TKey.JOB_REPORT_ERROR]: {
header: string;
@@ -108,15 +108,15 @@ describe('PixMessage - pt-BR', () => {
const result = converter.convert(payload, protoMessage);
// Assert
const expected = `💳 ****PIX - Copia e Cola****
const expected = `💳 **PIX - Copia e Cola**
****Merchant Name Here****
**Merchant Name Here**
****Chave PIX:**** +5000000000000
****Tipo:**** PHONE
****Referência:**** 4TB11111111
**Chave PIX:** +5000000000000
**Tipo:** PHONE
**Referência:** 4TB11111111
💳 ****PIX Copia e Cola enviado****`;
💳 **PIX Copia e Cola enviado**`;
expect(protoMessage).not.toBeNull();
expect(result).not.toBeNull();
@@ -227,16 +227,16 @@ describe('PixMessage - pt-BR', () => {
const result = converter.convert(payload, protoMessage);
// Assert
const expected = `💳 ****PIX - Copia e Cola****
const expected = `💳 **PIX - Copia e Cola**
****Merchant Name Here****
**Merchant Name Here**
****Chave PIX:**** +5000000000000
****Tipo:**** PHONE
****Valor:**** R$\u00A025,12
****Referência:**** 4TB11111111
**Chave PIX:** +5000000000000
**Tipo:** PHONE
**Valor:** R$\u00A025,12
**Referência:** 4TB11111111
💳 ****PIX Copia e Cola enviado****`;
💳 **PIX Copia e Cola enviado**`;
expect(protoMessage).not.toBeNull();
expect(result).not.toBeNull();
@@ -53,4 +53,40 @@ describe('WhatsappToMarkdown', () => {
const expected = 'URL: https://example.com/test_page e texto *em itálico*';
expect(WhatsappToMarkdown(input)).toBe(expected);
});
it('keeps already bold markdown intact', () => {
const input = '**PIX - Copia e Cola**';
expect(WhatsappToMarkdown(input)).toBe(input);
});
it('keeps already strikethrough markdown intact', () => {
const input = '~~removed~~';
expect(WhatsappToMarkdown(input)).toBe(input);
});
it('keeps underscores inside identifiers intact', () => {
const input = 'api key: abc_test_key';
expect(WhatsappToMarkdown(input)).toBe(input);
});
it('does not convert underscores inside words', () => {
const input = 'Instance name: customer_prod_instance';
expect(WhatsappToMarkdown(input)).toBe(input);
});
it('does not convert when markers are stuck to word characters', () => {
const input = 'My name is*Rafael* and email is test_user@example.com';
expect(WhatsappToMarkdown(input)).toBe(input);
});
it('keeps formatting when markers touch punctuation boundaries', () => {
const input = 'Hello *Rafael*, welcome to _WAHA_!';
const expected = 'Hello **Rafael**, welcome to *WAHA*!';
expect(WhatsappToMarkdown(input)).toBe(expected);
});
it('ignores markers with inner spaces next to the marker', () => {
const input = '* text* and _ text_ should stay the same';
expect(WhatsappToMarkdown(input)).toBe(input);
});
});
@@ -1,5 +1,18 @@
import { UrlProtection } from '@waha/apps/chatwoot/messages/to/markdown';
const boldPattern = new RegExp(
`(?<![\\p{L}\\p{N}_*])\\*(?!\\*)(?=\\S)(.+?)(?<=\\S)\\*(?![\\p{L}\\p{N}_*])`,
'gu',
);
const italicPattern = new RegExp(
`(?<![\\p{L}\\p{N}_])_(?!_)(?=\\S)(.+?)(?<=\\S)_(?![\\p{L}\\p{N}_])`,
'gu',
);
const strikePattern = new RegExp(
`(?<![\\p{L}\\p{N}_~])~(?!~)(?=\\S)(.+?)(?<=\\S)~(?![\\p{L}\\p{N}_~])`,
'gu',
);
export function WhatsappToMarkdown(text: string): string {
if (!text) {
return text;
@@ -16,11 +29,11 @@ export function WhatsappToMarkdown(text: string): string {
// Apply markdown transformations to "clean" text
let result = text
// Bold: *bold* → **bold**
.replace(/\*(.*?)\*/g, '**$1**')
.replace(boldPattern, '**$1**')
// Strikethrough: ~strike~ → ~~strike~~
.replace(/~(.*?)~/g, '~~$1~~')
.replace(strikePattern, '~~$1~~')
// Italic: _italic_ → *italic*
.replace(/_(.*?)_/g, '*$1*');
.replace(italicPattern, '*$1*');
// Restore original URLs after all transformations
result = urls.restore(result);
@@ -120,7 +120,7 @@ export class ChatWootAppService implements IAppService {
}
beforeSessionStart(app: App<ChatWootAppConfig>, session: WhatsappSession) {
this.chatWootWAHAQueueService.listenEvents(app.id, session);
this.chatWootWAHAQueueService.listenEvents(app, session);
}
afterSessionStart(app: App<ChatWootAppConfig>, session: WhatsappSession) {
@@ -7,6 +7,9 @@ import { Queue } from 'bullmq';
import { QueueName } from '../consumers/QueueName';
import { QueueRegistry } from './QueueRegistry';
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto';
import { ChatWootConfigDefaults } from '@waha/apps/chatwoot/di/DIContainer';
/**
* Service for managing ChatWoot queues for WAHA events
@@ -35,6 +38,12 @@ export class ChatWootWAHAQueueService {
return this.queueRegistry.queue(QueueName.WAHA_MESSAGE_ACK);
case WAHAEvents.SESSION_STATUS:
return this.queueRegistry.queue(QueueName.WAHA_SESSION_STATUS);
case WAHAEvents.CALL_RECEIVED:
return this.queueRegistry.queue(QueueName.WAHA_CALL_RECEIVED);
case WAHAEvents.CALL_ACCEPTED:
return this.queueRegistry.queue(QueueName.WAHA_CALL_ACCEPTED);
case WAHAEvents.CALL_REJECTED:
return this.queueRegistry.queue(QueueName.WAHA_CALL_REJECTED);
default:
return null;
}
@@ -57,13 +66,14 @@ export class ChatWootWAHAQueueService {
/**
* Configure ChatWoot event handling for a session
*/
listenEvents(appId: string, session: WhatsappSession): void {
const events = ListenEventsForChatWoot();
listenEvents(app: App<ChatWootAppConfig>, session: WhatsappSession): void {
const config = ChatWootConfigDefaults(app.config);
const events = ListenEventsForChatWoot(config);
for (const event of events) {
const obs$ = session.getEventObservable(event);
obs$.subscribe(async (payload) => {
const data = populateSessionInfo(event, session)(payload);
await this.addJobToQueue(event, data, appId);
await this.addJobToQueue(event, data, app.id);
});
}
}
@@ -27,6 +27,9 @@ export class QueueManager {
[QueueName.WAHA_MESSAGE_EDITED]: Managable,
[QueueName.WAHA_MESSAGE_REVOKED]: Managable,
[QueueName.WAHA_MESSAGE_ACK]: Managable,
[QueueName.WAHA_CALL_RECEIVED]: Managable,
[QueueName.WAHA_CALL_ACCEPTED]: Managable,
[QueueName.WAHA_CALL_REJECTED]: Managable,
[QueueName.INBOX_MESSAGE_CREATED]: Managable,
[QueueName.INBOX_MESSAGE_UPDATED]: Managable,
[QueueName.INBOX_CONVERSATION_CREATED]: Managable,
@@ -32,6 +32,12 @@ export class QueueRegistry {
private readonly wahaMessageRevokedQueue: Queue,
@InjectQueue(QueueName.WAHA_MESSAGE_ACK)
private readonly wahaMessageAckQueue: Queue,
@InjectQueue(QueueName.WAHA_CALL_RECEIVED)
private readonly wahaCallReceivedQueue: Queue,
@InjectQueue(QueueName.WAHA_CALL_ACCEPTED)
private readonly wahaCallAcceptedQueue: Queue,
@InjectQueue(QueueName.WAHA_CALL_REJECTED)
private readonly wahaCallRejectedQueue: Queue,
@InjectQueue(QueueName.INBOX_MESSAGE_CREATED)
private readonly inboxMessageCreatedQueue: Queue,
@InjectQueue(QueueName.INBOX_MESSAGE_UPDATED)
@@ -57,6 +63,9 @@ export class QueueRegistry {
[QueueName.WAHA_MESSAGE_EDITED]: this.wahaMessageEditedQueue,
[QueueName.WAHA_MESSAGE_REVOKED]: this.wahaMessageRevokedQueue,
[QueueName.WAHA_MESSAGE_ACK]: this.wahaMessageAckQueue,
[QueueName.WAHA_CALL_RECEIVED]: this.wahaCallReceivedQueue,
[QueueName.WAHA_CALL_ACCEPTED]: this.wahaCallAcceptedQueue,
[QueueName.WAHA_CALL_REJECTED]: this.wahaCallRejectedQueue,
[QueueName.INBOX_MESSAGE_CREATED]: this.inboxMessageCreatedQueue,
[QueueName.INBOX_MESSAGE_UPDATED]: this.inboxMessageUpdatedQueue,
[QueueName.INBOX_CONVERSATION_CREATED]:
+15
View File
@@ -5,10 +5,13 @@ import { getEngineName } from '@waha/version';
import { Message as MessageInstance } from 'whatsapp-web.js/src/structures';
import { isLidUser, isPnUser, toCusFormat } from '@waha/core/utils/jids';
import { WAMessage } from '@waha/structures/responses.dto';
import { CallData } from '@waha/structures/calls.dto';
interface IEngineHelper {
ChatID(message: WAMessage | any): string;
CallChatID(call: CallData | any): string;
WhatsAppMessageKeys(message: any): WhatsAppMessage;
IterateMessages<T extends { timestamp: number }>(
@@ -27,6 +30,10 @@ class NOWEBHelper implements IEngineHelper {
return message.from;
}
CallChatID(call: CallData): string {
return call.from;
}
WhatsAppMessageKeys(message: any): WhatsAppMessage {
const timestamp = parseInt(message.messageTimestamp) * 1000;
return {
@@ -62,6 +69,10 @@ class GOWSHelper implements IEngineHelper {
return message.from;
}
CallChatID(call: CallData): string {
return call._data?.CallCreator || call.from;
}
/**
* Parse API response and get the data
* API Response depends on engine right now
@@ -102,6 +113,10 @@ class WEBJSHelper implements IEngineHelper {
return message._data?.id?.remote || message.from;
}
CallChatID(call: CallData): string {
return call.from;
}
/**
* Parse API response and get the data for WEBJS engine
*/
+1
View File
@@ -79,6 +79,7 @@ export class SwaggerConfiguratorCore {
.addTag('🆔 Profile', 'Your profile information')
.addTag('🖼️ Screenshot', 'Get screenshot of WhatsApp and show QR code')
.addTag('📤 Chatting', 'Chatting methods')
.addTag('📞 Calls', 'Call handling methods')
.addTag('📢 Channels', 'Channels (newsletters) methods')
.addTag('🟢 Status', 'Status (aka stories) methods')
.addTag('💬 Chats', `Chats methods`)
+5 -1
View File
@@ -321,7 +321,7 @@ export abstract class WhatsappSession {
return this.events2.get(event);
}
protected set status(value: WAHASessionStatus) {
public set status(value: WAHASessionStatus) {
if (this.unpairing && value !== WAHASessionStatus.STOPPED) {
// In case of unpairing
// wait for STOPPED event, ignore the rest
@@ -664,6 +664,10 @@ export abstract class WhatsappSession {
throw new NotImplementedByEngineError();
}
public rejectCall(from: string, id: string): Promise<void> {
throw new NotImplementedByEngineError();
}
/**
* Chats methods
*/
+2
View File
@@ -40,6 +40,7 @@ import { join } from 'path';
import { Logger } from 'pino';
import { AuthController } from '../api/auth.controller';
import { CallsController } from '../api/calls.controller';
import { ChatsController } from '../api/chats.controller';
import { ChattingController } from '../api/chatting.controller';
import { ContactsController } from '../api/contacts.controller';
@@ -143,6 +144,7 @@ export const CONTROLLERS = [
ProfileController,
ChattingController,
ChatsController,
CallsController,
ChannelsController,
StatusController,
LabelsController,
+129
View File
@@ -10105,6 +10105,122 @@ export namespace messages {
return GetLidsRequest.deserialize(bytes);
}
}
export class RejectCallRequest extends pb_1.Message {
#one_of_decls: number[][] = [];
constructor(data?: any[] | {
session?: Session;
from?: string;
id?: string;
}) {
super();
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls);
if (!Array.isArray(data) && typeof data == "object") {
if ("session" in data && data.session != undefined) {
this.session = data.session;
}
if ("from" in data && data.from != undefined) {
this.from = data.from;
}
if ("id" in data && data.id != undefined) {
this.id = data.id;
}
}
}
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 from() {
return pb_1.Message.getFieldWithDefault(this, 2, "") as string;
}
set from(value: string) {
pb_1.Message.setField(this, 2, value);
}
get id() {
return pb_1.Message.getFieldWithDefault(this, 3, "") as string;
}
set id(value: string) {
pb_1.Message.setField(this, 3, value);
}
static fromObject(data: {
session?: ReturnType<typeof Session.prototype.toObject>;
from?: string;
id?: string;
}): RejectCallRequest {
const message = new RejectCallRequest({});
if (data.session != null) {
message.session = Session.fromObject(data.session);
}
if (data.from != null) {
message.from = data.from;
}
if (data.id != null) {
message.id = data.id;
}
return message;
}
toObject() {
const data: {
session?: ReturnType<typeof Session.prototype.toObject>;
from?: string;
id?: string;
} = {};
if (this.session != null) {
data.session = this.session.toObject();
}
if (this.from != null) {
data.from = this.from;
}
if (this.id != null) {
data.id = this.id;
}
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.from.length)
writer.writeString(2, this.from);
if (this.id.length)
writer.writeString(3, this.id);
if (!w)
return writer.getResultBuffer();
}
static deserialize(bytes: Uint8Array | pb_1.BinaryReader): RejectCallRequest {
const reader = bytes instanceof pb_1.BinaryReader ? bytes : new pb_1.BinaryReader(bytes), message = new RejectCallRequest();
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.from = reader.readString();
break;
case 3:
message.id = reader.readString();
break;
default: reader.skipField();
}
}
return message;
}
serializeBinary(): Uint8Array {
return this.serialize();
}
static deserializeBinary(bytes: Uint8Array): RejectCallRequest {
return RejectCallRequest.deserialize(bytes);
}
}
export class PollMessage extends pb_1.Message {
#one_of_decls: number[][] = [];
constructor(data?: any[] | {
@@ -10933,6 +11049,15 @@ export namespace messages {
responseSerialize: (message: DownloadMediaResponse) => Buffer.from(message.serialize()),
responseDeserialize: (bytes: Buffer) => DownloadMediaResponse.deserialize(new Uint8Array(bytes))
},
RejectCall: {
path: "/messages.MessageService/RejectCall",
requestStream: false,
responseStream: false,
requestSerialize: (message: RejectCallRequest) => Buffer.from(message.serialize()),
requestDeserialize: (bytes: Buffer) => RejectCallRequest.deserialize(new Uint8Array(bytes)),
responseSerialize: (message: Empty) => Buffer.from(message.serialize()),
responseDeserialize: (bytes: Buffer) => Empty.deserialize(new Uint8Array(bytes))
},
GetMessageById: {
path: "/messages.MessageService/GetMessageById",
requestStream: false,
@@ -11022,6 +11147,7 @@ export namespace messages {
abstract GetContactById(call: grpc_1.ServerUnaryCall<EntityByIdRequest, Json>, callback: grpc_1.sendUnaryData<Json>): void;
abstract CancelEventMessage(call: grpc_1.ServerUnaryCall<CancelEventMessageRequest, MessageResponse>, callback: grpc_1.sendUnaryData<MessageResponse>): void;
abstract DownloadMedia(call: grpc_1.ServerUnaryCall<DownloadMediaRequest, DownloadMediaResponse>, callback: grpc_1.sendUnaryData<DownloadMediaResponse>): void;
abstract RejectCall(call: grpc_1.ServerUnaryCall<RejectCallRequest, Empty>, callback: grpc_1.sendUnaryData<Empty>): void;
abstract GetMessageById(call: grpc_1.ServerUnaryCall<EntityByIdRequest, Json>, callback: grpc_1.sendUnaryData<Json>): void;
abstract GetMessages(call: grpc_1.ServerUnaryCall<GetMessagesRequest, JsonList>, callback: grpc_1.sendUnaryData<JsonList>): void;
abstract GetChats(call: grpc_1.ServerUnaryCall<GetChatsRequest, JsonList>, callback: grpc_1.sendUnaryData<JsonList>): void;
@@ -11210,6 +11336,9 @@ export namespace messages {
DownloadMedia: GrpcUnaryServiceInterface<DownloadMediaRequest, DownloadMediaResponse> = (message: DownloadMediaRequest, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback<DownloadMediaResponse>, options?: grpc_1.CallOptions | grpc_1.requestCallback<DownloadMediaResponse>, callback?: grpc_1.requestCallback<DownloadMediaResponse>): grpc_1.ClientUnaryCall => {
return super.DownloadMedia(message, metadata, options, callback);
};
RejectCall: GrpcUnaryServiceInterface<RejectCallRequest, Empty> = (message: RejectCallRequest, 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.RejectCall(message, metadata, options, callback);
};
GetMessageById: GrpcUnaryServiceInterface<EntityByIdRequest, Json> = (message: EntityByIdRequest, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback<Json>, options?: grpc_1.CallOptions | grpc_1.requestCallback<Json>, callback?: grpc_1.requestCallback<Json>): grpc_1.ClientUnaryCall => {
return super.GetMessageById(message, metadata, options, callback);
};
@@ -543,6 +543,17 @@ function deserialize_messages_ProfileStatusRequest(buffer_arg) {
return gows_pb.ProfileStatusRequest.deserializeBinary(new Uint8Array(buffer_arg));
}
function serialize_messages_RejectCallRequest(arg) {
if (!(arg instanceof gows_pb.RejectCallRequest)) {
throw new Error('Expected argument of type messages.RejectCallRequest');
}
return Buffer.from(arg.serializeBinary());
}
function deserialize_messages_RejectCallRequest(buffer_arg) {
return gows_pb.RejectCallRequest.deserializeBinary(new Uint8Array(buffer_arg));
}
function serialize_messages_RevokeMessageRequest(arg) {
if (!(arg instanceof gows_pb.RevokeMessageRequest)) {
throw new Error('Expected argument of type messages.RevokeMessageRequest');
@@ -1391,6 +1402,20 @@ downloadMedia: {
responseDeserialize: deserialize_messages_DownloadMediaResponse,
},
//
// Calls
//
rejectCall: {
path: '/messages.MessageService/RejectCall',
requestStream: false,
responseStream: false,
requestType: gows_pb.RejectCallRequest,
responseType: gows_pb.Empty,
requestSerialize: serialize_messages_RejectCallRequest,
requestDeserialize: deserialize_messages_RejectCallRequest,
responseSerialize: serialize_messages_Empty,
responseDeserialize: deserialize_messages_Empty,
},
//
// Storage
//
getMessageById: {
+233
View File
@@ -94,6 +94,7 @@ goog.exportSymbol('proto.messages.ProfilePictureRequest', null, global);
goog.exportSymbol('proto.messages.ProfilePictureResponse', null, global);
goog.exportSymbol('proto.messages.ProfileStatusRequest', null, global);
goog.exportSymbol('proto.messages.ReceiptType', null, global);
goog.exportSymbol('proto.messages.RejectCallRequest', null, global);
goog.exportSymbol('proto.messages.RevokeMessageRequest', null, global);
goog.exportSymbol('proto.messages.Row', null, global);
goog.exportSymbol('proto.messages.SearchNewslettersByTextRequest', null, global);
@@ -1966,6 +1967,27 @@ if (goog.DEBUG && !COMPILED) {
*/
proto.messages.GetLidsRequest.displayName = 'proto.messages.GetLidsRequest';
}
/**
* 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.RejectCallRequest = function(opt_data) {
jspb.Message.initialize(this, opt_data, 0, -1, null, null);
};
goog.inherits(proto.messages.RejectCallRequest, jspb.Message);
if (goog.DEBUG && !COMPILED) {
/**
* @public
* @override
*/
proto.messages.RejectCallRequest.displayName = 'proto.messages.RejectCallRequest';
}
/**
* Generated by JsPbCodeGenerator.
* @param {Array=} opt_data Optional initial data array, typically from a
@@ -20854,6 +20876,217 @@ proto.messages.GetLidsRequest.prototype.hasSession = function() {
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.RejectCallRequest.prototype.toObject = function(opt_includeInstance) {
return proto.messages.RejectCallRequest.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.RejectCallRequest} msg The msg instance to transform.
* @return {!Object}
* @suppress {unusedLocalVariables} f is only used for nested messages
*/
proto.messages.RejectCallRequest.toObject = function(includeInstance, msg) {
var f, obj = {
session: (f = msg.getSession()) && proto.messages.Session.toObject(includeInstance, f),
from: jspb.Message.getFieldWithDefault(msg, 2, ""),
id: jspb.Message.getFieldWithDefault(msg, 3, "")
};
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.RejectCallRequest}
*/
proto.messages.RejectCallRequest.deserializeBinary = function(bytes) {
var reader = new jspb.BinaryReader(bytes);
var msg = new proto.messages.RejectCallRequest;
return proto.messages.RejectCallRequest.deserializeBinaryFromReader(msg, reader);
};
/**
* Deserializes binary data (in protobuf wire format) from the
* given reader into the given message object.
* @param {!proto.messages.RejectCallRequest} msg The message object to deserialize into.
* @param {!jspb.BinaryReader} reader The BinaryReader to use.
* @return {!proto.messages.RejectCallRequest}
*/
proto.messages.RejectCallRequest.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.setFrom(value);
break;
case 3:
var value = /** @type {string} */ (reader.readString());
msg.setId(value);
break;
default:
reader.skipField();
break;
}
}
return msg;
};
/**
* Serializes the message to binary data (in protobuf wire format).
* @return {!Uint8Array}
*/
proto.messages.RejectCallRequest.prototype.serializeBinary = function() {
var writer = new jspb.BinaryWriter();
proto.messages.RejectCallRequest.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.RejectCallRequest} message
* @param {!jspb.BinaryWriter} writer
* @suppress {unusedLocalVariables} f is only used for nested messages
*/
proto.messages.RejectCallRequest.serializeBinaryToWriter = function(message, writer) {
var f = undefined;
f = message.getSession();
if (f != null) {
writer.writeMessage(
1,
f,
proto.messages.Session.serializeBinaryToWriter
);
}
f = message.getFrom();
if (f.length > 0) {
writer.writeString(
2,
f
);
}
f = message.getId();
if (f.length > 0) {
writer.writeString(
3,
f
);
}
};
/**
* optional Session session = 1;
* @return {?proto.messages.Session}
*/
proto.messages.RejectCallRequest.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.RejectCallRequest} returns this
*/
proto.messages.RejectCallRequest.prototype.setSession = function(value) {
return jspb.Message.setWrapperField(this, 1, value);
};
/**
* Clears the message field making it undefined.
* @return {!proto.messages.RejectCallRequest} returns this
*/
proto.messages.RejectCallRequest.prototype.clearSession = function() {
return this.setSession(undefined);
};
/**
* Returns whether this field is set.
* @return {boolean}
*/
proto.messages.RejectCallRequest.prototype.hasSession = function() {
return jspb.Message.getField(this, 1) != null;
};
/**
* optional string from = 2;
* @return {string}
*/
proto.messages.RejectCallRequest.prototype.getFrom = function() {
return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 2, ""));
};
/**
* @param {string} value
* @return {!proto.messages.RejectCallRequest} returns this
*/
proto.messages.RejectCallRequest.prototype.setFrom = function(value) {
return jspb.Message.setProto3StringField(this, 2, value);
};
/**
* optional string id = 3;
* @return {string}
*/
proto.messages.RejectCallRequest.prototype.getId = function() {
return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 3, ""));
};
/**
* @param {string} value
* @return {!proto.messages.RejectCallRequest} returns this
*/
proto.messages.RejectCallRequest.prototype.setId = function(value) {
return jspb.Message.setProto3StringField(this, 3, value);
};
/**
* List of repeated fields within this message type.
* @private {!Array<number>}
+117 -1
View File
@@ -123,6 +123,7 @@ import {
WAMessage,
WAMessageReaction,
} from '@waha/structures/responses.dto';
import { CallData } from '@waha/structures/calls.dto';
import { MeInfo, ProxyConfig } from '@waha/structures/sessions.dto';
import {
BROADCAST_ID,
@@ -142,6 +143,7 @@ import { onlyEvent } from '@waha/utils/reactive/ops/onlyEvent';
import * as NodeCache from 'node-cache';
import {
filter,
groupBy,
merge,
mergeMap,
Observable,
@@ -150,7 +152,7 @@ import {
share,
Subject,
} from 'rxjs';
import { map } from 'rxjs/operators';
import { map, debounceTime } from 'rxjs/operators';
import { promisify } from 'util';
import * as gows from './types';
@@ -208,6 +210,12 @@ enum WhatsMeowEvent {
EVENT_MESSAGE_RESPONSE = 'gows.EventMessageResponse',
// Polls
POLL_VOTE_EVENT = 'gows.PollVoteEvent',
// Calls
CALL_OFFER = 'events.CallOffer',
CALL_ACCEPT = 'events.CallAccept',
CALL_REJECT = 'events.CallReject',
CALL_TERMINATE = 'events.CallTerminate',
CALL_OFFER_NOTICE = 'events.CallOfferNotice',
}
export interface GowsConfig {
@@ -542,6 +550,52 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
);
this.events2.get(WAHAEvents.MESSAGE_REACTION).switch(messageReactions$);
//
// Calls
//
const callOffer$ = all$.pipe(
onlyEvent(WhatsMeowEvent.CALL_OFFER),
filter(this.shouldProcessCallEvent.bind(this)),
map(this.toCallData.bind(this)),
);
const callOfferNotice$ = all$.pipe(
onlyEvent(WhatsMeowEvent.CALL_OFFER_NOTICE),
filter(this.shouldProcessCallEvent.bind(this)),
map(this.toCallData.bind(this)),
);
this.events2
.get(WAHAEvents.CALL_RECEIVED)
.switch(merge(callOffer$, callOfferNotice$));
const callAccept$ = all$.pipe(
onlyEvent(WhatsMeowEvent.CALL_ACCEPT),
filter(this.shouldProcessCallEvent.bind(this)),
map(this.toCallData.bind(this)),
);
this.events2.get(WAHAEvents.CALL_ACCEPTED).switch(callAccept$);
const callReject$ = all$.pipe(
onlyEvent(WhatsMeowEvent.CALL_REJECT),
filter(this.shouldProcessCallEvent.bind(this)),
);
const callTerminate$ = all$.pipe(
onlyEvent(WhatsMeowEvent.CALL_TERMINATE),
filter(this.shouldProcessCallEvent.bind(this)),
// Skip terminate events that only mean the call was accepted or rejected on another device
filter(
(call: any) =>
call?.Reason !== 'accepted_elsewhere' &&
call?.Reason !== 'rejected_elsewhere',
),
);
// Debounce per call to collapse duplicate reject/terminate events
const callRejected$ = merge(callReject$, callTerminate$).pipe(
groupBy((call: any) => this.getCallId(call) || 'unknown'),
mergeMap((group$) => group$.pipe(debounceTime(1_000))),
map(this.toCallData.bind(this)),
);
this.events2.get(WAHAEvents.CALL_REJECTED).switch(callRejected$);
const presence$ = all$.pipe(
onlyEvent(WhatsMeowEvent.PRESENCE),
filter((event: any) => this.jids.include(event?.From)),
@@ -808,6 +862,15 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return data.id;
}
async rejectCall(from: string, id: string): Promise<void> {
const request = new messages.RejectCallRequest({
session: this.session,
from: toJID(this.ensureSuffix(from)),
id: id,
});
await promisify(this.client.RejectCall)(request);
}
@Activity()
async sendText(request: MessageTextRequest) {
const jid = toJID(this.ensureSuffix(request.chatId));
@@ -2165,6 +2228,59 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
};
}
private getCallId(call: any): string | null {
return call?.CallID || call?.Data?.Attrs?.['call-id'] || null;
}
private shouldProcessCallEvent(call: any): boolean {
if (!call) {
return false;
}
if (call.GroupJID && this.jids.include(call.GroupJID)) {
return true;
}
return this.jids.include(call.From);
}
private toCallData(call: any): CallData {
const date = call?.Timestamp ? new Date(call.Timestamp) : new Date();
const timestamp = date.getTime() / 1000;
const isVideo = this.isVideoCall(call);
const isGroup = this.isGroupCall(call);
const from = call?.From || call?.GroupJID;
return {
id: this.getCallId(call),
from: from ? toCusFormat(from) : undefined,
timestamp: timestamp,
isVideo: Boolean(isVideo),
isGroup: isGroup,
_data: call,
};
}
private isVideoCall(call: any): boolean {
if (!call) return false;
if (call?.Media === 'video') return true;
const attrsMedia = call?.Data?.Attrs?.media || call?.Data?.Attrs?.type;
if (attrsMedia === 'video') return true;
const content = call?.Data?.Content;
if (Array.isArray(content)) {
const hasVideoTag = content.some((item: any) => item?.Tag === 'video');
if (hasVideoTag) {
return true;
}
}
return false;
}
private isGroupCall(call: any): boolean {
if (!call) return false;
if (call?.GroupJID) return true;
const attrs = call?.Data?.Attrs;
if (!attrs) return false;
return attrs.type === 'group' || Boolean(attrs['group-jid']);
}
private toEventResponsePayload(event: any): EventResponsePayload {
const msg = this.toWAMessage(event);
let response: EventResponse | null = null;
+106 -39
View File
@@ -67,6 +67,7 @@ import type { Agent } from 'https';
import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor';
import { QR } from '@waha/core/QR';
import { AckToStatus, StatusToAck } from '@waha/core/utils/acks';
import { pairs } from '@waha/utils/pairs';
import { ExtractMessageKeysForRead } from '@waha/core/utils/convertors';
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
import { isJidNewsletter, toCusFormat, toJID } from '@waha/core/utils/jids';
@@ -186,9 +187,11 @@ import {
mergeMap,
Observable,
partition,
groupBy,
share,
tap,
} from 'rxjs';
import { map } from 'rxjs/operators';
import { debounceTime, map } from 'rxjs/operators';
import { INowebStore } from './store/INowebStore';
import { NowebPersistentStore } from './store/NowebPersistentStore';
@@ -631,10 +634,14 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
private issueMessageUpdateOnPoll() {
// Fix for https://github.com/devlikeapro/waha/issues/960
this.sock.ev.on('messages.upsert', async ({ messages }) => {
const meId = this.getSessionMeInfo().id;
if (!meId) {
const me = this.getSessionMeInfo();
if (!me) {
this.logger.warn(
'Cannot issue poll updates, session "me" info not found',
);
return;
}
for (const message of messages) {
const content = normalizeMessageContent(message.message);
if (!content?.pollUpdateMessage) {
@@ -653,38 +660,75 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
continue;
}
const meIdNormalised = jidNormalizedUser(meId);
const pollCreatorJid = getKeyAuthor(creationMsgKey, meIdNormalised);
const voterJid = getKeyAuthor(message.key, meIdNormalised);
const pollEncKey = pollMsg.messageContextInfo?.messageSecret;
try {
const voteMsg = decryptPollVote(content.pollUpdateMessage.vote, {
pollEncKey,
pollCreatorJid,
pollMsgId: creationMsgKey.id,
voterJid,
});
this.sock.ev.emit('messages.update', [
{
key: creationMsgKey,
update: {
pollUpdates: [
{
pollUpdateMessageKey: message.key,
vote: voteMsg,
senderTimestampMs: (
content.pollUpdateMessage.senderTimestampMs as Long
).toNumber(),
},
],
// Because of new @lid system it's hard to detect exactly how
// the vote has been encrypted, so we'll iterator over all possible
// not null combinations
const key = message.key;
const myIds = [jidNormalizedUser(me.id), jidNormalizedUser(me.lid)];
const participantIds = [
key?.participantAlt,
key?.remoteJidAlt,
key?.participant,
key?.remoteJid,
];
let creators: string[] = creationMsgKey.fromMe
? [...myIds, ...participantIds]
: [...participantIds, ...myIds];
let votes: string[] = key.fromMe
? [...myIds, ...participantIds]
: [...participantIds, ...myIds];
creators = lodash.uniq(creators.filter(Boolean));
votes = lodash.uniq(votes.filter(Boolean));
let found = false;
for (const [pollCreatorJid, voterJid] of pairs(creators, votes)) {
try {
const pollEncKey = pollMsg.messageContextInfo?.messageSecret;
const voteMsg = decryptPollVote(content.pollUpdateMessage.vote, {
pollCreatorJid: pollCreatorJid,
pollMsgId: creationMsgKey.id,
pollEncKey: pollEncKey,
voterJid: voterJid,
});
this.sock.ev.emit('messages.update', [
{
key: creationMsgKey,
update: {
pollUpdates: [
{
pollUpdateMessageKey: message.key,
vote: voteMsg,
senderTimestampMs: (
content.pollUpdateMessage.senderTimestampMs as Long
).toNumber(),
},
],
},
},
},
]);
} catch (err) {
]);
found = true;
break;
} catch (err) {
this.logger.trace(
{
err: err.message,
key: key,
creationsMsgKey: creationMsgKey,
pollCreatorJid: pollCreatorJid,
voterJid: voterJid,
},
'failed to decrypt poll vote using creator and voter',
);
}
}
if (!found) {
this.logger.warn(
{ err, creationMsgKey },
'failed to decrypt poll vote',
{
key: key,
creationsMsgKey: creationMsgKey,
creators: creators,
voters: votes,
},
'failed to decrypt poll vote with any combination of creator/voter',
);
}
}
@@ -850,6 +894,11 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
return this.generateMessageID();
}
async rejectCall(from: string, id: string): Promise<void> {
const jid = toJID(this.ensureSuffix(from));
await this.sock.rejectCall(id, jid);
}
@Activity()
async sendText(request: MessageTextRequest) {
const chatId = toJID(this.ensureSuffix(request.chatId));
@@ -2123,6 +2172,8 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
),
share(),
);
const acceptedCallIds = new Set<string>();
this.events2.get(WAHAEvents.CALL_RECEIVED).switch(
call$.pipe(
filter((call: WACallEvent) => call.status === 'offer'),
@@ -2132,18 +2183,33 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
this.events2.get(WAHAEvents.CALL_ACCEPTED).switch(
call$.pipe(
filter((call: WACallEvent) => call.status === 'accept'),
tap((call: WACallEvent) => acceptedCallIds.add(call.id)),
map(this.toCallData.bind(this)),
),
);
this.events2.get(WAHAEvents.CALL_REJECTED).switch(
calls$.pipe(
// Filter out if there's any "accept" events.
// Meaning it's been accepted on one device, but rejected on another
exclude((calls) => calls.some((call) => call.status === 'accept')),
mergeAll(),
filter((call: WACallEvent) => call.status === 'reject'),
call$.pipe(
filter(
(call: WACallEvent) =>
call.status === 'reject' || call.status === 'terminate',
),
// Skip rejections when the call was accepted earlier (local or other device)
exclude((call: WACallEvent) => {
const shouldSkip = acceptedCallIds.has(call.id);
if (call.status === 'terminate') {
acceptedCallIds.delete(call.id);
}
return shouldSkip;
}),
// We get two "reject" events, one with null isGroup property, ignore it
exclude((call: WACallEvent) => call.isGroup == null),
groupBy((call: WACallEvent) => call.id || 'unknown'),
mergeMap((group$) =>
group$.pipe(
debounceTime(1_000),
tap((call: WACallEvent) => acceptedCallIds.delete(call.id)),
),
),
map(this.toCallData.bind(this)),
),
);
@@ -2540,6 +2606,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
timestamp: timestamp,
isVideo: call.isVideo,
isGroup: call.isGroup,
_data: call,
};
}
+46 -3
View File
@@ -131,7 +131,15 @@ import { TmpDir } from '@waha/utils/tmpdir';
import * as lodash from 'lodash';
import * as path from 'path';
import { ProtocolError } from 'puppeteer';
import { filter, fromEvent, merge, mergeMap, Observable, share } from 'rxjs';
import {
filter,
fromEvent,
merge,
mergeMap,
Observable,
share,
Subject,
} from 'rxjs';
import { map } from 'rxjs/operators';
import {
AuthStrategy,
@@ -150,7 +158,10 @@ import {
Reaction,
WAState,
} from 'whatsapp-web.js';
import { Message as MessageInstance } from 'whatsapp-web.js/src/structures';
import {
Message as MessageInstance,
Call as CallInstance,
} from 'whatsapp-web.js/src/structures';
import { WAJSPresenceChatStateType, WebJSPresence } from './types';
import {
@@ -160,6 +171,7 @@ import {
toCusFormat,
} from '@waha/core/utils/jids';
import { Activity } from '@waha/core/abc/activity';
import { CallData } from '@waha/structures/calls.dto';
export interface WebJSConfig {
webVersion?: string;
@@ -180,6 +192,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
whatsapp: WebjsClientCore;
protected qr: QR;
private callRejected$ = new Subject<CallData>();
public constructor(config) {
super(config);
@@ -475,7 +488,6 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
});
this.whatsapp.on(Events.READY, () => {
this.status = WAHASessionStatus.WORKING;
this.qr.save('');
this.logger.info(`Session '${this.name}' is ready!`);
});
@@ -497,6 +509,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
});
this.whatsapp.on(Events.AUTHENTICATED, (args) => {
this.status = WAHASessionStatus.WORKING;
this.qr.save('');
this.logger.info({ args: args }, `Session has been authenticated!`);
});
@@ -651,6 +664,15 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
/**
* Other methods
*/
async rejectCall(from: string, id: string): Promise<void> {
const peerJid = normalizeJid(this.ensureSuffix(from));
const call = new CallInstance(this.whatsapp, null);
call.id = id;
call.from = peerJid;
await call.reject();
this.callRejected$.next(this.toRejectedCallData(peerJid, id));
}
@Activity()
sendText(request: MessageTextRequest) {
const options = this.getMessageOptions(request);
@@ -1770,10 +1792,14 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
timestamp: call.timestamp,
isVideo: call.isVideo,
isGroup: call.isGroup,
_data: call,
};
}),
);
this.events2.get(WAHAEvents.CALL_RECEIVED).switch(calls$);
this.events2
.get(WAHAEvents.CALL_REJECTED)
.switch(this.callRejected$.asObservable());
}
protected async processIncomingMessage(
@@ -1790,6 +1816,23 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
return wamessage;
}
private toRejectedCallData(peerJid: string, id: string): CallData {
const timestamp = Math.floor(Date.now() / 1000);
return {
id: id,
from: peerJid,
timestamp: timestamp,
isVideo: false,
isGroup: isJidGroup(peerJid),
_data: {
id: id,
from: peerJid,
status: 'reject',
api: true,
},
};
}
private processMessageReaction(reaction: Reaction): WAMessageReaction {
if (this.lastQRDate) {
// If it's timestamp before last qr - ignore it
+13 -3
View File
@@ -218,11 +218,20 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
webhook.configure(session, webhooks);
// Apps
await this.appsService.beforeSessionStart(session, this.store);
try {
await this.appsService.beforeSessionStart(session, this.store);
} catch (e) {
logger.error(`Apps Error: ${e}`);
session.status = WAHASessionStatus.FAILED;
}
// start session
await session.start();
logger.info('Session has been started.');
if (session.status !== WAHASessionStatus.FAILED) {
await session.start();
logger.info('Session has been started.');
// Apps
await this.appsService.afterSessionStart(session, this.store);
}
// Apps
await this.appsService.afterSessionStart(session, this.store);
@@ -295,6 +304,7 @@ export class SessionManagerCore extends SessionManager implements OnModuleInit {
async delete(name: string): Promise<void> {
this.onlyDefault(name);
await this.appsService.removeBySession(this, name);
this.session = DefaultSessionStatus.REMOVED;
this.updateSession();
this.sessionConfig = undefined;
+15
View File
@@ -3,6 +3,7 @@
*/
import { ApiProperty } from '@nestjs/swagger';
import { ChatIdProperty } from '@waha/structures/properties.dto';
import { IsNotEmpty, IsString } from 'class-validator';
function CallIdProperty() {
return ApiProperty({
@@ -11,6 +12,18 @@ function CallIdProperty() {
});
}
export class RejectCallRequest {
@ChatIdProperty()
@IsString()
@IsNotEmpty()
from: string;
@CallIdProperty()
@IsString()
@IsNotEmpty()
id: string;
}
export class CallData {
@CallIdProperty()
id: string;
@@ -23,4 +36,6 @@ export class CallData {
isVideo: boolean;
isGroup: boolean;
_data: any;
}
+59 -1
View File
@@ -1,10 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { BooleanString } from '@waha/nestjs/validation/BooleanString';
import { IsDynamicObject } from '@waha/nestjs/validation/IsDynamicObject';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsEnum,
IsOptional,
IsString,
Matches,
@@ -19,7 +21,26 @@ import { WebhookConfig } from './webhooks.config.dto';
/**
* Queries
*/
export class ListSessionsQuery {
export enum SessionExpand {
apps = 'apps',
}
export class SessionExpandQuery {
@ApiProperty({
required: false,
type: String,
enum: SessionExpand,
isArray: true,
description: 'Expand additional session details.',
})
@Transform(({ value }) => (Array.isArray(value) ? value : [value]))
@IsArray()
@IsEnum(SessionExpand, { each: true })
@IsOptional()
expand?: SessionExpand[];
}
export class ListSessionsQuery extends SessionExpandQuery {
@ApiProperty({
example: false,
required: false,
@@ -32,6 +53,8 @@ export class ListSessionsQuery {
all?: boolean;
}
export class SessionInfoQuery extends SessionExpandQuery {}
/**
* Requests
*/
@@ -242,6 +265,15 @@ export class SessionInfo extends SessionDTO {
timestamps: {
activity: number | null;
};
@ApiProperty({
description: 'Apps configured for the session.',
required: false,
isArray: true,
type: App,
nullable: true,
})
apps?: App[];
}
export class SessionDetailedInfo extends SessionInfo {
@@ -272,6 +304,19 @@ export class SessionCreateRequest {
@IsOptional()
config?: SessionConfig;
@ApiProperty({
description: 'Apps to be synchronized for this session.',
required: false,
isArray: true,
type: App,
nullable: true,
})
@ValidateNested({ each: true })
@Type(() => App)
@IsArray()
@IsOptional()
apps?: App[] | null;
@ApiProperty({
description: 'Start session after creation',
example: true,
@@ -287,4 +332,17 @@ export class SessionUpdateRequest {
@Type(() => SessionConfig)
@IsOptional()
config?: SessionConfig;
@ApiProperty({
description: 'Apps to be synchronized for this session.',
required: false,
isArray: true,
type: App,
nullable: true,
})
@ValidateNested({ each: true })
@Type(() => App)
@IsArray()
@IsOptional()
apps?: App[] | null;
}
+10
View File
@@ -0,0 +1,10 @@
export function* pairs<T, U>(
iter1: Iterable<T>,
iter2: Iterable<U>,
): Generator<[T, U]> {
for (const id1 of iter1) {
for (const id2 of iter2) {
yield [id1, id2];
}
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ export function getWAHAVersion(): WAHAVersion {
}
export const VERSION: WAHAEnvironment = {
version: '2025.11.4',
version: '2025.12.1',
engine: getEngineName(),
tier: getWAHAVersion(),
browser:
+2 -2
View File
@@ -2,11 +2,11 @@
"waha": {
"gows": {
"repo": "devlikeapro/gows",
"ref": "v1.0.24"
"ref": "v1.0.25"
},
"dashboard": {
"repo": "devlikeapro/dashboard",
"ref": "d982212d20191adab7065216cad45ff064b483d8"
"ref": "e29fd9300cfae9cabe8fbb66c1d30009c06081f3"
}
}
}