Compare commits

..
21 Commits
Author SHA1 Message Date
devlikepro 79233e09e3 fix(NOWEB): Add WAHA_NOWEB_WA_VERSION / WAHA_NOWEB_WA_VERSION_FORCE - closes #2193
Release / WEBJS - chrome - amd64 - chrome (push) Waiting to run
Release / WEBJS - chromium - amd64 - latest (push) Waiting to run
Release / WEBJS - chromium - linux/arm64 - arm (push) Waiting to run
Release / GOWS - none - amd64 - gows (push) Waiting to run
Release / GOWS - none - linux/arm64 - gows-arm (push) Waiting to run
Release / NOWEB - none - amd64 - noweb (push) Waiting to run
Release / NOWEB - none - linux/arm64 - noweb-arm (push) Waiting to run
2026-07-29 15:18:03 +07:00
devlikepro a96a4b2161 up(NOWEB): fix version - fix #2191 2026-07-29 14:31:41 +07:00
devlikepro f736105873 up(dashboard): session info, copy button in Event Monitor 2026-07-22 17:42:41 +07:00
devlikepro 2c1ea2c7c1 feat: /settings/security/member-add-mode - fix #2165 2026-07-22 17:35:49 +07:00
Ali White 216e00cc25 [core] feat(NOWEB): expose group member-add-mode as a settable endpoint close #2172 2026-07-22 17:19:34 +07:00
devlikeproandBerg Pinheiro ac1a015046 [core] Fix restartStoppedSessions aborting on one stuck session, closes #2169
Co-Authored-By: Berg Pinheiro <berg.pinheiro2009@gmail.com>
2026-07-22 17:17:35 +07:00
devlikepro 8f4af6bb08 feat: expose Reachout Timelock data - WORKING status and /me info fix #2166 2026-07-22 16:56:44 +07:00
devlikepro acee1b7d79 chore: timehelper docs 2026-07-22 16:12:21 +07:00
devlikepro 99241c3089 chore: SessionName to validation 2026-07-22 16:00:28 +07:00
devlikepro 8e764372d2 fix: LongTimeout and unref() for not keeping the process alive 2026-07-22 15:53:05 +07:00
devlikepro fa21a60cf3 fix(ChatWoot): safe read, typing when sending messages - fix #2173 2026-07-22 13:20:18 +07:00
devlikepro 02fa0ea06b ci: build dev nightly 2026-07-22 13:20:18 +07:00
devlikepro 8d1ad04625 fix(NOWEB): fix empty message.edited body - fix #2168 2026-07-22 13:20:18 +07:00
devlikepro a639baad8d fix(NOWEB): up engine 2026-07-22 13:20:18 +07:00
devlikepro 2e24107018 chore: up yarn@4.17.1 2026-07-22 13:20:18 +07:00
devlikepro 750ce208b3 chore(WEBJS): Up chrome 140.0.7339.207-1
The previous version is not found
2026-07-22 13:20:18 +07:00
devlikepro 057e0a0e70 up(GOWS): fix channels link preview - mention #2163, fix unknown field "faviconMMSMetadata" - fix #2172 2026-07-22 13:20:18 +07:00
devlikepro 255f84a12d fix(NOWEB): sending preview in channels mention #2163 2026-07-17 19:00:38 +07:00
devlikepro ac6d14394a chore: publish dev on core 2026-07-17 19:00:38 +07:00
devlikepro cb77c2fc49 fix(WEBJS): up engine. Fix sending link preview to channels fix #2163 2026-07-17 19:00:38 +07:00
devlikepro 5c279c5240 [core] 2026.7.2 2026-07-17 14:55:42 +07:00
35 changed files with 973 additions and 81 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
SHARP_IGNORE_GLOBAL_LIBVIPS=1
+2 -1
View File
@@ -35,7 +35,7 @@ jobs:
# run: yarn test:unit
docker-build:
if: github.repository == 'devlikeapro/waha-plus'
if: github.repository == 'devlikeapro/waha'
# needs: unit-tests
runs-on: ${{ matrix.runner }}
name:
@@ -73,6 +73,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v3
with:
ref: dev
fetch-depth: 0
- name: Check recent changes
+7
View File
@@ -1,9 +1,16 @@
approvedGitRepositories:
- '**'
compressionLevel: mixed
enableGlobalCache: false
enableScripts: true
nodeLinker: node-modules
npmMinimalAgeGate: 0
supportedArchitectures:
cpu:
- arm
+2 -2
View File
@@ -16,10 +16,10 @@ RUN apt-get update && \
WORKDIR /git
COPY package.json .
COPY yarn.lock .
COPY .yarnrc.yml .
ENV YARN_CHECKSUM_BEHAVIOR=update
RUN npm install -g corepack && corepack enable
RUN yarn set version 4.9.2
RUN yarn install
# App
@@ -179,7 +179,7 @@ RUN if [ "$USE_BROWSER" = "chromium" ]; then \
# Install Chrome
# Available versions:
# https://www.ubuntuupdates.org/package/google_chrome/stable/main/base/google-chrome-stable
ARG CHROME_VERSION="140.0.7339.80-1"
ARG CHROME_VERSION="140.0.7339.207-1"
ARG OPUSTAGS_VERSION="1.10.1"
RUN if [ "$USE_BROWSER" = "chrome" ]; then \
wget --no-verbose -O /tmp/chrome.deb https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb \
+1 -1
View File
@@ -205,5 +205,5 @@
}
]
},
"packageManager": "yarn@4.9.2"
"packageManager": "yarn@4.17.1"
}
+34
View File
@@ -38,6 +38,7 @@ import {
JoinGroupRequest,
JoinGroupResponse,
ParticipantsRequest,
SettingsMemberAddMode,
SettingsSecurityChangeInfo,
SubjectRequest,
} from '../structures/groups.dto';
@@ -309,6 +310,39 @@ export class GroupsController {
return session.getMessagesAdminsOnly(id);
}
@Put(':id/settings/security/member-add-mode')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Send, FromParam('session')))
@ApiOperation({
summary: 'Update settings - who can add new members',
description:
'Updates the group settings for who can add new members to the group - all members or admins only.',
})
setMemberAddMode(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
@Body() request: SettingsMemberAddMode,
) {
return session.setMemberAddMode(id, request.membersCanAddNewMember);
}
@Get(':id/settings/security/member-add-mode')
@SessionApiParam
@GroupIdApiParam
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
@ApiOperation({
summary: 'Get settings - who can add new members',
description:
'The group settings for who can add new members to the group - all members or admins only.',
})
getMemberAddMode(
@WorkingSessionParam session: WhatsappSession,
@Param('id') id: string,
): Promise<SettingsMemberAddMode> {
return session.getMemberAddMode(id);
}
@Get(':id/invite-code')
@SessionApiParam
@GroupIdApiParam
@@ -221,10 +221,23 @@ export class MessageHandler {
mentions: mentions,
};
const session = this.session;
await session.readMessages(chatId);
await session.startTyping({ chatId: chatId, session: '' });
// Best effort - do not block sending on read-receipt failure
await session.readMessages(chatId).catch((err) => {
this.logger.warn(
`ChatWoot => WhatsApp: error reading messages in chat '${chatId}': ${err}`,
);
});
await session.startTyping({ chatId: chatId, session: '' }).catch((err) => {
this.logger.warn(
`ChatWoot => WhatsApp: error starting typing in chat '${chatId}': ${err}`,
);
});
await sleep(2000);
await session.stopTyping({ chatId: chatId, session: '' });
await session.stopTyping({ chatId: chatId, session: '' }).catch((err) => {
this.logger.warn(
`ChatWoot => WhatsApp: error stopping typing in chat '${chatId}': ${err}`,
);
});
const msg = await session.sendText(request);
return msg;
}
+68
View File
@@ -0,0 +1,68 @@
import { ReachoutTimelockData } from '@waha/structures/sessions.dto';
import { LongTimeout, setLongTimeout } from '@waha/utils/promiseTimeout';
import { EnsureMilliseconds } from '@waha/utils/timehelper';
import * as lodash from 'lodash';
import { Logger } from 'pino';
import { Observable, Subject } from 'rxjs';
/**
* WhatsApp "reachout timelock"
* The account is restricted from messaging new contacts for a period of time (the cause of 463 errors on send).
* Keeps the latest known state, expires it when the enforcement ends and emits changes.
*/
export class ReachoutTimelockTracker {
private timelock: ReachoutTimelockData | null = null;
private timeout?: LongTimeout;
private changes: Subject<ReachoutTimelockData | null> = new Subject();
constructor(private logger: Logger) {}
get value(): ReachoutTimelockData | null {
return this.timelock;
}
get changes$(): Observable<ReachoutTimelockData | null> {
return this.changes;
}
update(timelock: ReachoutTimelockData | null) {
if (timelock && !timelock.isActive && !this.timelock) {
// No enforcement has been seen for the account - keep it null instead of storing an inactive record
// (startup fetch on a clean account lands here)
return;
}
if (lodash.isEqual(this.timelock, timelock)) {
return;
}
this.timeout?.clear();
if (timelock?.isActive && timelock.timeEnforcementEnds) {
const endsAtMs = EnsureMilliseconds(timelock.timeEnforcementEnds);
const delay = Math.max(endsAtMs - Date.now(), 0);
this.timeout = setLongTimeout(() => this.expire(), delay).unref();
}
this.apply(timelock);
}
/**
* Stop the expiry timer, keeping the last known state.
*/
stop() {
this.timeout?.clear();
}
private expire() {
if (!this.timelock?.isActive) {
return;
}
this.apply({ ...this.timelock, isActive: false });
}
private apply(timelock: ReachoutTimelockData | null) {
this.timelock = timelock;
this.logger.info(
{ reachoutTimelock: timelock },
'Reachout timelock updated',
);
this.changes.next(timelock);
}
}
+163
View File
@@ -0,0 +1,163 @@
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { WAHAEvents, WAHASessionStatus } from '@waha/structures/enums.dto';
import {
MeInfo,
ReachoutTimelockData,
ReachoutTimelockEnforcementType,
} from '@waha/structures/sessions.dto';
import { WASessionStatusBody } from '@waha/structures/webhooks.dto';
const logger: any = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
};
logger.child = () => logger;
const BaseSession = WhatsappSession as unknown as new (params: any) => any;
class TestSession extends BaseSession {
public getSessionMeInfo(): MeInfo | null {
// pushName and id must be set so the SESSION_STATUS pipeline does not delay WORKING statuses
return { id: '123@c.us', pushName: 'Test' };
}
public setStatusPublic(status: WAHASessionStatus) {
this.setStatus(status);
}
public updateReachoutTimelockPublic(timelock: ReachoutTimelockData | null) {
this.reachoutTimelock.update(timelock);
}
}
function buildSession(): TestSession {
return new TestSession({
name: 'test',
printQR: false,
loggerBuilder: { child: () => logger },
sessionStore: null,
mediaManager: null,
sessionConfig: null,
engineConfig: null,
ignore: {},
});
}
const ACTIVE_TIMELOCK: ReachoutTimelockData = {
enforcementType: ReachoutTimelockEnforcementType.RESTRICT_ALL_COMPANIONS,
isActive: true,
timeEnforcementEnds: Math.floor(Date.now() / 1000) + 3600,
};
describe('WhatsappSession reachout timelock', () => {
let session: TestSession;
let statuses: WASessionStatusBody[];
beforeEach(() => {
session = buildSession();
statuses = [];
session
.getEventObservable(WAHAEvents.SESSION_STATUS)
.subscribe((body: WASessionStatusBody) => statuses.push(body));
});
it('re-issues WORKING with data when a timelock arrives', () => {
session.setStatusPublic(WAHASessionStatus.WORKING);
session.updateReachoutTimelockPublic(ACTIVE_TIMELOCK);
expect(statuses).toHaveLength(2);
expect(statuses[1].status).toEqual(WAHASessionStatus.WORKING);
expect(statuses[1].data).toEqual({ reachoutTimelock: ACTIVE_TIMELOCK });
});
it('keeps attaching the timelock on plain WORKING assignments', () => {
session.setStatusPublic(WAHASessionStatus.WORKING);
session.updateReachoutTimelockPublic(ACTIVE_TIMELOCK);
// Reconnect flows assign 'status = WORKING' with no data
session.setStatusPublic(WAHASessionStatus.STARTING);
session.setStatusPublic(WAHASessionStatus.WORKING);
const last = statuses.at(-1);
expect(last.status).toEqual(WAHASessionStatus.WORKING);
expect(last.data).toEqual({ reachoutTimelock: ACTIVE_TIMELOCK });
});
it('collapses consecutive WORKING statuses with the same data', () => {
session.setStatusPublic(WAHASessionStatus.WORKING);
session.updateReachoutTimelockPublic(ACTIVE_TIMELOCK);
session.setStatusPublic(WAHASessionStatus.WORKING);
session.setStatusPublic(WAHASessionStatus.WORKING);
expect(statuses).toHaveLength(2);
});
it('ignores duplicate timelock updates', () => {
session.setStatusPublic(WAHASessionStatus.WORKING);
session.updateReachoutTimelockPublic(ACTIVE_TIMELOCK);
session.updateReachoutTimelockPublic({ ...ACTIVE_TIMELOCK });
expect(statuses).toHaveLength(2);
});
it('ignores an inactive timelock when none has been seen', () => {
session.setStatusPublic(WAHASessionStatus.WORKING);
session.updateReachoutTimelockPublic({
enforcementType: ReachoutTimelockEnforcementType.DEFAULT,
isActive: false,
timeEnforcementEnds: null,
});
expect(statuses).toHaveLength(1);
expect(statuses[0].data).toBeNull();
});
it('re-issues WORKING when the timelock is lifted by an event', () => {
session.setStatusPublic(WAHASessionStatus.WORKING);
session.updateReachoutTimelockPublic(ACTIVE_TIMELOCK);
const lifted = { ...ACTIVE_TIMELOCK, isActive: false };
session.updateReachoutTimelockPublic(lifted);
expect(statuses).toHaveLength(3);
expect(statuses[2].data).toEqual({ reachoutTimelock: lifted });
});
it('marks the timelock inactive when the enforcement expires', () => {
jest.useFakeTimers();
try {
const endsAt = Math.floor(Date.now() / 1000) + 600;
session.setStatusPublic(WAHASessionStatus.WORKING);
session.updateReachoutTimelockPublic({
...ACTIVE_TIMELOCK,
timeEnforcementEnds: endsAt,
});
jest.advanceTimersByTime(601 * 1000);
const last = statuses.at(-1);
expect(last.data.reachoutTimelock.isActive).toBe(false);
} finally {
jest.useRealTimers();
}
});
it('does not fire the expiry timer after the session stops', () => {
jest.useFakeTimers();
try {
session.setStatusPublic(WAHASessionStatus.WORKING);
session.updateReachoutTimelockPublic({
...ACTIVE_TIMELOCK,
timeEnforcementEnds: Math.floor(Date.now() / 1000) + 600,
});
session.setStatusPublic(WAHASessionStatus.STOPPED);
const count = statuses.length;
jest.advanceTimersByTime(601 * 1000);
expect(statuses).toHaveLength(count);
} finally {
jest.useRealTimers();
}
});
});
+37 -2
View File
@@ -1,3 +1,4 @@
import { ReachoutTimelockTracker } from '@waha/core/abc/ReachoutTimelockTracker';
import { getBrowserExecutablePath as getBrowserExecutablePathAutodetect } from '@waha/core/abc/session.browser';
import { IMediaConverter } from '@waha/core/media/IConverter';
import { Ffmpeg } from '@waha/core/utils/ffmpeg';
@@ -107,6 +108,7 @@ import {
GroupParticipant,
GroupsListFields,
ParticipantsRequest,
SettingsMemberAddMode,
SettingsSecurityChangeInfo,
} from '../../structures/groups.dto';
import { WAHAChatPresences } from '../../structures/presence.dto';
@@ -196,6 +198,7 @@ export abstract class WhatsappSession {
private _status: WAHASessionStatus;
private _statusData: any = null;
protected reachoutTimelock: ReachoutTimelockTracker;
private _presence:
| WAHAPresenceStatus.ONLINE
| WAHAPresenceStatus.OFFLINE
@@ -242,6 +245,15 @@ export abstract class WhatsappSession {
this.loggerBuilder = loggerBuilder;
this.logger = loggerBuilder.child({ name: 'WhatsappSession' });
this.mediaConverter = new Ffmpeg(this.name, this.logger);
this.reachoutTimelock = new ReachoutTimelockTracker(this.logger);
this.reachoutTimelock.changes$.subscribe((timelock) => {
if (this.status === WAHASessionStatus.WORKING) {
// Re-issue WORKING so 'session.status' consumers get the update
this.setStatus(WAHASessionStatus.WORKING, {
reachoutTimelock: timelock,
});
}
});
this.events2 = new DefaultMap<WAHAEvents, SwitchObservable<any>>(
(key) =>
new SwitchObservable((obs$) => {
@@ -280,11 +292,12 @@ export abstract class WhatsappSession {
}
return of(update);
}),
// Remove consecutive duplicate WORKING statuses
// Remove consecutive duplicate WORKING statuses, but let through WORKING re-issued with new data
distinctUntilChanged(
(prev, curr) =>
prev.status === curr.status &&
curr.status === WAHASessionStatus.WORKING,
curr.status === WAHASessionStatus.WORKING &&
lodash.isEqual(prev.data, curr.data),
),
// attach current time (ms)
timestamp(),
@@ -349,6 +362,20 @@ export abstract class WhatsappSession {
// wait for STOPPED event, ignore the rest
return;
}
if (
status === WAHASessionStatus.WORKING &&
data == null &&
this.reachoutTimelock.value?.isActive
) {
// Plain 'status = WORKING' assignments (reconnects) must keep carrying the active timelock info
data = { reachoutTimelock: this.reachoutTimelock.value };
}
if (
status === WAHASessionStatus.STOPPED ||
status === WAHASessionStatus.FAILED
) {
this.reachoutTimelock?.stop();
}
this._status = status;
this._statusData = data;
this.status$.next({ status: status, data: data });
@@ -1005,6 +1032,14 @@ export abstract class WhatsappSession {
throw new NotImplementedByEngineError();
}
public getMemberAddMode(id): Promise<SettingsMemberAddMode> {
throw new NotImplementedByEngineError();
}
public setMemberAddMode(id, value) {
throw new NotImplementedByEngineError();
}
public deleteGroup(id) {
throw new NotImplementedByEngineError();
}
+2
View File
@@ -22,6 +22,7 @@ import { ApiKeyAuthMiddleware } from '@waha/core/auth/api-key-auth.middleware';
import { BasicAuthFunction } from '@waha/core/auth/basicAuth';
import { WebSocketAuth } from '@waha/core/auth/WebSocketAuth';
import { GowsEngineConfigService } from '@waha/core/config/GowsEngineConfigService';
import { NowebEngineConfigService } from '@waha/core/config/NowebEngineConfigService';
import { WPPEngineConfigService } from '@waha/core/config/WPPEngineConfigService';
import { WebJSEngineConfigService } from '@waha/core/config/WebJSEngineConfigService';
import { MediaLocalStorageModule } from '@waha/core/media/local/media.local.storage.module';
@@ -205,6 +206,7 @@ export const PROVIDERS_BASE: Provider[] = [
WebJSEngineConfigService,
WPPEngineConfigService,
GowsEngineConfigService,
NowebEngineConfigService,
WhatsappConfigService,
EngineConfigService,
WebsocketGatewayCore,
@@ -0,0 +1,76 @@
import type { WAVersion } from '@adiwajshing/baileys';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NowebConfig } from '@waha/core/engines/noweb/session.noweb.core';
import {
isWaVersionHigher,
parseWaVersion,
} from '@waha/core/engines/noweb/waversion';
import { parseBool } from '@waha/helpers';
import esm from '@waha/vendor/esm';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
@Injectable()
export class NowebEngineConfigService {
private config: NowebConfig | null = null;
constructor(
protected configService: ConfigService,
@InjectPinoLogger('NowebEngineConfigService')
protected logger: PinoLogger,
) {}
getConfig(): NowebConfig {
if (!this.config) {
const version = this.resolveWaVersion();
this.config = { waVersion: version };
}
return this.config;
}
/**
* Resolves the WhatsApp Web version to use for NOWEB engine.
* Returns undefined when the built-in Baileys version should be used.
*/
private resolveWaVersion(): WAVersion {
const value = this.configService.get<string>('WAHA_NOWEB_WA_VERSION', '');
const envVersion = parseWaVersion(value);
const version = esm.b.DEFAULT_CONNECTION_CONFIG.version as WAVersion;
const force = parseBool(
this.configService.get('WAHA_NOWEB_WA_VERSION_FORCE', 'false'),
);
if (!envVersion && value) {
this.logger.warn(
`WAHA_NOWEB_WA_VERSION='${value}' - invalid format, expected 'x.y.z'.`,
);
return version;
}
if (!envVersion && force) {
this.logger.warn(
`WAHA_NOWEB_WA_VERSION_FORCE is set, but WAHA_NOWEB_WA_VERSION is not.`,
);
return version;
}
if (!envVersion) {
return version;
}
if (force && envVersion) {
this.logger.debug(
`Using WAHA_NOWEB_WA_VERSION because of WAHA_NOWEB_WA_VERSION_FORCE.`,
);
return envVersion;
}
if (isWaVersionHigher(version, envVersion)) {
this.logger.debug(
`Built-in wa.version is higher than WAHA_NOWEB_WA_VERSION, using built-in version.`,
);
return version;
}
return envVersion;
}
}
+13
View File
@@ -11362,6 +11362,15 @@ export namespace messages {
responseSerialize: (message: Empty) => Buffer.from(message.serialize()),
responseDeserialize: (bytes: Buffer) => Empty.deserialize(new Uint8Array(bytes))
},
SetGroupMemberAddMode: {
path: "/messages.MessageService/SetGroupMemberAddMode",
requestStream: false,
responseStream: false,
requestSerialize: (message: JidBoolRequest) => Buffer.from(message.serialize()),
requestDeserialize: (bytes: Buffer) => JidBoolRequest.deserialize(new Uint8Array(bytes)),
responseSerialize: (message: Empty) => Buffer.from(message.serialize()),
responseDeserialize: (bytes: Buffer) => Empty.deserialize(new Uint8Array(bytes))
},
UpdateGroupParticipants: {
path: "/messages.MessageService/UpdateGroupParticipants",
requestStream: false,
@@ -11734,6 +11743,7 @@ export namespace messages {
abstract SetGroupPicture(call: grpc_1.ServerUnaryCall<SetPictureRequest, Empty>, callback: grpc_1.sendUnaryData<Empty>): void;
abstract SetGroupLocked(call: grpc_1.ServerUnaryCall<JidBoolRequest, Empty>, callback: grpc_1.sendUnaryData<Empty>): void;
abstract SetGroupAnnounce(call: grpc_1.ServerUnaryCall<JidBoolRequest, Empty>, callback: grpc_1.sendUnaryData<Empty>): void;
abstract SetGroupMemberAddMode(call: grpc_1.ServerUnaryCall<JidBoolRequest, Empty>, callback: grpc_1.sendUnaryData<Empty>): void;
abstract UpdateGroupParticipants(call: grpc_1.ServerUnaryCall<UpdateParticipantsRequest, JsonList>, callback: grpc_1.sendUnaryData<JsonList>): void;
abstract GetProfilePicture(call: grpc_1.ServerUnaryCall<ProfilePictureRequest, ProfilePictureResponse>, callback: grpc_1.sendUnaryData<ProfilePictureResponse>): void;
abstract SendPresence(call: grpc_1.ServerUnaryCall<PresenceRequest, Empty>, callback: grpc_1.sendUnaryData<Empty>): void;
@@ -11861,6 +11871,9 @@ export namespace messages {
SetGroupAnnounce: GrpcUnaryServiceInterface<JidBoolRequest, Empty> = (message: JidBoolRequest, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback<Empty>, options?: grpc_1.CallOptions | grpc_1.requestCallback<Empty>, callback?: grpc_1.requestCallback<Empty>): grpc_1.ClientUnaryCall => {
return super.SetGroupAnnounce(message, metadata, options, callback);
};
SetGroupMemberAddMode: GrpcUnaryServiceInterface<JidBoolRequest, Empty> = (message: JidBoolRequest, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback<Empty>, options?: grpc_1.CallOptions | grpc_1.requestCallback<Empty>, callback?: grpc_1.requestCallback<Empty>): grpc_1.ClientUnaryCall => {
return super.SetGroupMemberAddMode(message, metadata, options, callback);
};
UpdateGroupParticipants: GrpcUnaryServiceInterface<UpdateParticipantsRequest, JsonList> = (message: UpdateParticipantsRequest, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback<JsonList>, options?: grpc_1.CallOptions | grpc_1.requestCallback<JsonList>, callback?: grpc_1.requestCallback<JsonList>): grpc_1.ClientUnaryCall => {
return super.UpdateGroupParticipants(message, metadata, options, callback);
};
@@ -1050,6 +1050,18 @@ setGroupAnnounce: {
responseDeserialize: deserialize_messages_Empty,
},
// send messages only by admins
setGroupMemberAddMode: {
path: '/messages.MessageService/SetGroupMemberAddMode',
requestStream: false,
responseStream: false,
requestType: gows_pb.JidBoolRequest,
responseType: gows_pb.Empty,
requestSerialize: serialize_messages_JidBoolRequest,
requestDeserialize: deserialize_messages_JidBoolRequest,
responseSerialize: serialize_messages_Empty,
responseDeserialize: deserialize_messages_Empty,
},
// who can add members - true - all members, false - admins only
updateGroupParticipants: {
path: '/messages.MessageService/UpdateGroupParticipants',
requestStream: false,
@@ -0,0 +1,48 @@
import { parseGowsReachoutTimelock } from '@waha/core/engines/gows/reachouttimelock';
describe('parseGowsReachoutTimelock', () => {
it('parses a full payload', () => {
const timelock = parseGowsReachoutTimelock({
enforcement_type: 'RESTRICT_ALL_COMPANIONS',
is_active: true,
time_enforcement_ends: '1784477333',
});
expect(timelock).toEqual({
enforcementType: 'RESTRICT_ALL_COMPANIONS',
isActive: true,
timeEnforcementEnds: 1784477333,
});
});
it('treats absent is_active as inactive (omitempty)', () => {
const timelock = parseGowsReachoutTimelock({
enforcement_type: 'RESTRICT_ALL_COMPANIONS',
});
expect(timelock.isActive).toBe(false);
});
it('treats absent time_enforcement_ends as null (omitzero)', () => {
const timelock = parseGowsReachoutTimelock({
enforcement_type: 'RESTRICT_ALL_COMPANIONS',
is_active: true,
});
expect(timelock.timeEnforcementEnds).toBeNull();
});
it('defaults enforcement type on an empty payload', () => {
const timelock = parseGowsReachoutTimelock({});
expect(timelock).toEqual({
enforcementType: 'DEFAULT',
isActive: false,
timeEnforcementEnds: null,
});
});
it('returns null for a non numeric time_enforcement_ends', () => {
const timelock = parseGowsReachoutTimelock({
is_active: true,
time_enforcement_ends: 'not-a-number',
});
expect(timelock.timeEnforcementEnds).toBeNull();
});
});
+25
View File
@@ -0,0 +1,25 @@
import {
ReachoutTimelockData,
ReachoutTimelockEnforcementType,
} from '@waha/structures/sessions.dto';
import { EnsureSeconds } from '@waha/utils/timehelper';
/**
* Normalize whatsmeow's events.NotifyAccountReachoutTimelock payload.
*/
export function parseGowsReachoutTimelock(data: any): ReachoutTimelockData {
// 'time_enforcement_ends' is a unix seconds string, absent when zero (Go's omitzero)
let timeEnforcementEnds: number | null = null;
if (data?.time_enforcement_ends) {
const parsed = parseInt(data.time_enforcement_ends, 10);
timeEnforcementEnds = Number.isNaN(parsed) ? null : EnsureSeconds(parsed);
}
const enforcementType =
data?.enforcement_type ?? ReachoutTimelockEnforcementType.DEFAULT;
return {
enforcementType: enforcementType as ReachoutTimelockEnforcementType,
// 'is_active' is absent when false (Go's omitempty)
isActive: data?.is_active === true,
timeEnforcementEnds: timeEnforcementEnds,
};
}
+28 -1
View File
@@ -28,6 +28,7 @@ import {
parseJsonList,
statusToAck,
} from '@waha/core/engines/gows/helpers';
import { parseGowsReachoutTimelock } from '@waha/core/engines/gows/reachouttimelock';
import { GowsAuthFactoryCore } from '@waha/core/engines/gows/store/GowsAuthFactoryCore';
import {
extractBody,
@@ -118,6 +119,7 @@ import {
GroupSortField,
Participant,
ParticipantsRequest,
SettingsMemberAddMode,
SettingsSecurityChangeInfo,
} from '@waha/structures/groups.dto';
import { ReplyToMessage } from '@waha/structures/message.dto';
@@ -235,6 +237,7 @@ enum WhatsMeowEvent {
CHAT_PRESENCE = 'events.ChatPresence',
PUSH_NAME_SETTING = 'events.PushNameSetting',
LOGGED_OUT = 'events.LoggedOut',
NOTIFY_ACCOUNT_REACHOUT_TIMELOCK = 'events.NotifyAccountReachoutTimelock',
// Groups
GROUP_INFO = 'events.GroupInfo',
JOINED_GROUP = 'events.JoinedGroup',
@@ -473,6 +476,9 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
this.logger.error('Logged out');
this.status = WAHASessionStatus.FAILED;
});
events.on(WhatsMeowEvent.NOTIFY_ACCOUNT_REACHOUT_TIMELOCK, (data) => {
this.reachoutTimelock.update(parseGowsReachoutTimelock(data));
});
events.on(WhatsMeowEvent.PRESENCE, (event: gows.Presence) => {
if (isJidGroup(event.From)) {
// So group is not "online"
@@ -918,7 +924,10 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
}
public getSessionMeInfo(): MeInfo | null {
return this.me;
if (!this.me) {
return null;
}
return { ...this.me, reachoutTimelock: this.reachoutTimelock.value };
}
/**
@@ -1642,6 +1651,24 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return;
}
public async getMemberAddMode(id): Promise<SettingsMemberAddMode> {
const group = await this.getGroup(id);
return {
membersCanAddNewMember: group.MemberAddMode === 'all_member_add',
};
}
@Activity()
public async setMemberAddMode(id, value) {
const req = new messages.JidBoolRequest({
session: this.session,
jid: id,
value: value,
});
await promisify(this.client.SetGroupMemberAddMode)(req);
return;
}
public deleteGroup(id) {
throw new NotImplementedByEngineError();
}
+133 -28
View File
@@ -23,6 +23,7 @@ import makeWASocket, {
WAMessageContent,
WAMessageKey,
WAMessageUpdate,
WAVersion,
} from '@adiwajshing/baileys';
import { WACallEvent } from '@adiwajshing/baileys/lib/Types/Call';
import { BaileysEventMap } from '@adiwajshing/baileys/lib/Types/Events';
@@ -73,7 +74,12 @@ 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';
import {
isJidNewsletter,
jidsFromKey,
toCusFormat,
toJID,
} from '@waha/core/utils/jids';
import { DistinctAck, DistinctMessages } from '@waha/core/utils/reactive';
import {
flipObject,
@@ -149,6 +155,7 @@ import {
CreateGroupRequest,
GroupParticipant,
ParticipantsRequest,
SettingsMemberAddMode,
SettingsSecurityChangeInfo,
} from '@waha/structures/groups.dto';
import {
@@ -166,7 +173,11 @@ import {
WAHAPresenceData,
} from '@waha/structures/presence.dto';
import { WAMessage, WAMessageReaction } from '@waha/structures/responses.dto';
import { MeInfo } from '@waha/structures/sessions.dto';
import {
MeInfo,
ReachoutTimelockEnforcementType,
} from '@waha/structures/sessions.dto';
import { EnsureSeconds } from '@waha/utils/timehelper';
import {
BROADCAST_ID,
DeleteStatusRequest,
@@ -235,6 +246,7 @@ import { detectMimetype } from '@waha/utils/files';
import esm from '@waha/vendor/esm';
import axios from 'axios';
import axiosRetry from 'axios-retry';
import { formatWaVersion } from '@waha/core/engines/noweb/waversion';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const promiseRetry = require('promise-retry');
@@ -259,11 +271,16 @@ const PresenceStatuses = {
};
const ToEnginePresenceStatus = flipObject(PresenceStatuses);
export interface NowebConfig {
waVersion: WAVersion;
}
export class WhatsappSessionNoWebCore extends WhatsappSession {
private START_ATTEMPT_DELAY_SECONDS = 2;
private AUTO_RESTART_AFTER_SECONDS = 28 * 60;
engine = WAHAEngine.NOWEB;
protected engineConfig: NowebConfig;
authFactory = new NowebAuthFactoryCore();
storageFactory = new NowebStorageFactoryCore();
private startDelayedJob: SingleDelayedJobRunner;
@@ -381,7 +398,9 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
if (markOnlineOnConnect == undefined) {
markOnlineOnConnect = true;
}
const version = this.engineConfig?.waVersion;
return {
version: version,
agent: agents?.socket,
// Baileys media upload uses Node https.request in Node runtime.
fetchAgent: agents?.fetch as Agent,
@@ -419,6 +438,9 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
agents,
state,
) as SocketConfig;
this.logger.info(
`Connecting using wa.version = ${formatWaVersion(socketConfig.version)}`,
);
const sock = makeWASocket(socketConfig);
sock.ev.on('creds.update', saveCreds);
return sock;
@@ -542,11 +564,32 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
this.logger.debug(`Start listening ${BaileysEvents.CONNECTION_UPDATE}...`);
this.sock.ev.on('connection.update', async (update) => {
const { connection, lastDisconnect, qr, isNewLogin } = update;
if (update.reachoutTimeLock) {
const timelock = update.reachoutTimeLock;
const enforcementType =
timelock.enforcementType ?? ReachoutTimelockEnforcementType.DEFAULT;
let timeEnforcementEnds: number | null = null;
if (timelock.timeEnforcementEnds) {
timeEnforcementEnds = EnsureSeconds(
timelock.timeEnforcementEnds.getTime(),
);
}
this.reachoutTimelock.update({
enforcementType: enforcementType as ReachoutTimelockEnforcementType,
isActive: timelock.isActive === true,
timeEnforcementEnds: timeEnforcementEnds,
});
}
if (isNewLogin) {
this.restartClient();
} else if (connection === 'open') {
this.qr.save('');
this.status = WAHASessionStatus.WORKING;
// Ask WhatsApp for the current reachout timelock state, so a restarted session learns about
// an ongoing timelock without waiting for a push. The result arrives via 'connection.update'
this.sock?.fetchAccountReachoutTimelock?.().catch((error) => {
this.logger.warn(`Failed to fetch reachout timelock: ${error}`);
});
// Do we need to resubscribe?
// Ideally not, we need to explicitly call interesting
// jids every 1 minute
@@ -849,6 +892,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
id: toCusFormat(meId),
pushName: me.name,
lid: jidNormalizedUser(me.lid),
reachoutTimelock: this.reachoutTimelock.value,
};
}
@@ -1205,12 +1249,20 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
urlInfo.jpegThumbnail = thumbnail.buffer;
} else {
// upload HQ thumbnail
// newsletters need the thumbnail uploaded unencrypted (plaintext path),
// otherwise clients can not render it (white/blurred preview image)
const uploadToServer = this.sock.waUploadToServer;
const uploadThumbnail = async (encFilePath, opts) => {
opts.newsletter = isJidNewsletter(chatId);
return await uploadToServer(encFilePath, opts);
};
const { imageMessage } = await esm.b.prepareWAMessageMedia(
{ image: content },
{
upload: this.sock.waUploadToServer,
upload: uploadThumbnail,
mediaTypeOverride: 'thumbnail-link',
options: { signal: AbortSignal.timeout(10_000) },
jid: chatId,
},
);
urlInfo.jpegThumbnail = imageMessage?.jpegThumbnail
@@ -1936,6 +1988,17 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
return await this.sock.groupSettingUpdate(id, setting);
}
public async getMemberAddMode(id): Promise<SettingsMemberAddMode> {
const group = await this.getGroup(id);
return { membersCanAddNewMember: group.memberAddMode };
}
@Activity()
public async setMemberAddMode(id, value) {
const mode = value ? 'all_member_add' : 'admin_add';
return await this.sock.groupMemberAddMode(id, mode);
}
@Activity()
public async leaveGroup(id) {
return this.sock.groupLeave(id);
@@ -2882,9 +2945,12 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
if (!origMsgId) {
return '';
}
const jidsToTry = [targetKey.remoteJid, editMessage.key?.remoteJid].filter(
Boolean,
);
const editKey = editMessage.key as WAMessageKey | undefined;
const jidsToTry = [
targetKey.remoteJid,
editKey?.remoteJid,
editKey?.remoteJidAlt,
].filter(Boolean);
let stored: proto.IWebMessageInfo | undefined;
for (const jid of jidsToTry) {
stored = await this.store?.loadMessage(jid, origMsgId);
@@ -2915,40 +2981,75 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
if (!encPayload || !encIv) {
return '';
}
const editInfo = {
Chat: editMessage.key?.remoteJid,
Sender:
editMessage.key?.participant ||
(editMessage.key?.fromMe ? undefined : editMessage.key?.remoteJid),
};
const modificationSenderJid = jidToNonAD(editInfo.Sender || '');
const primaryOrig = getOrigSenderJidForMsgSecret(editInfo, {
fromMe: targetKey.fromMe,
remoteJID: targetKey.remoteJid,
participant: targetKey.participant,
});
const candidates: string[] = [primaryOrig];
// The editor ("modification sender").
let modificationSenderJids: Array<string | null | undefined>;
if (editKey?.fromMe) {
// Try both user lid and c.us
modificationSenderJids = [this.sock?.user?.lid, this.sock?.user?.id];
} else {
const editJids = editKey ? jidsFromKey(editKey) : null;
modificationSenderJids = [
editKey?.participant || editKey?.remoteJid,
editJids?.lid,
editJids?.pn,
];
}
modificationSenderJids = lodash
.chain(modificationSenderJids)
.filter(Boolean)
.map(jidToNonAD)
.uniq()
.value();
if (modificationSenderJids.length === 0) {
modificationSenderJids.push('');
}
const remoteNonAD = targetKey.remoteJid
? jidToNonAD(targetKey.remoteJid)
: '';
if (remoteNonAD && !candidates.includes(remoteNonAD)) {
candidates.push(remoteNonAD);
}
const participantNonAD = targetKey.participant
? jidToNonAD(targetKey.participant)
: '';
if (participantNonAD && !candidates.includes(participantNonAD)) {
candidates.push(participantNonAD);
// Map dedupes by key and keeps the first-insertion order
const attempts = new Map<
string,
{ origSenderJid: string; modificationSenderJid: string }
>();
for (const modificationSenderJid of modificationSenderJids) {
const primaryOrigSenderJid = getOrigSenderJidForMsgSecret(
{ Chat: editKey?.remoteJid, Sender: modificationSenderJid },
{
fromMe: targetKey.fromMe,
remoteJID: targetKey.remoteJid,
participant: targetKey.participant,
},
);
const origSenderJids = [
primaryOrigSenderJid,
remoteNonAD,
participantNonAD,
].filter(Boolean);
for (const origSenderJid of origSenderJids) {
// Avoid duplicates in attemps
const attemptKey = `${origSenderJid}|${modificationSenderJid}`;
attempts.set(attemptKey, {
origSenderJid: origSenderJid,
modificationSenderJid: modificationSenderJid,
});
}
}
let lastErr: unknown;
for (const origSenderJid of candidates) {
for (const attempt of attempts.values()) {
try {
const decoded = decryptSecretEncryptedMessageEditProto({
encPayload: encPayload,
encIv: encIv,
origMsgId: origMsgId,
origSenderJid: origSenderJid,
modificationSenderJid: modificationSenderJid,
origSenderJid: attempt.origSenderJid,
modificationSenderJid: attempt.modificationSenderJid,
origMsgSecret: origSecret,
});
const text = extractBody(decoded) || '';
@@ -2960,7 +3061,11 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
}
}
this.logger.debug(
{ err: lastErr, origMsgId: origMsgId, candidates: candidates },
{
err: lastErr,
origMsgId: origMsgId,
attempts: Array.from(attempts.keys()),
},
'NOWEB message edit decrypt: AES-GCM or protobuf decode failed',
);
return '';
+44
View File
@@ -0,0 +1,44 @@
import {
formatWaVersion,
isWaVersionHigher,
parseWaVersion,
} from '@waha/core/engines/noweb/waversion';
describe('parseWaVersion', () => {
it('parses a valid version', () => {
expect(parseWaVersion('2.3000.1043857760')).toEqual([2, 3000, 1043857760]);
});
it('trims whitespace', () => {
expect(parseWaVersion(' 2.3000.1 ')).toEqual([2, 3000, 1]);
});
it.each(['', '2.3000', '2.3000.1.2', '2.3000.abc', 'latest'])(
"returns null for invalid value '%s'",
(value) => {
expect(parseWaVersion(value)).toBeNull();
},
);
});
describe('formatWaVersion', () => {
it('formats a version', () => {
expect(formatWaVersion([2, 3000, 1043857760])).toBe('2.3000.1043857760');
});
});
describe('isWaVersionHigher', () => {
it('compares by the last part', () => {
expect(isWaVersionHigher([2, 3000, 2], [2, 3000, 1])).toBe(true);
expect(isWaVersionHigher([2, 3000, 1], [2, 3000, 2])).toBe(false);
});
it('compares by the middle part first', () => {
expect(isWaVersionHigher([2, 3001, 1], [2, 3000, 999])).toBe(true);
expect(isWaVersionHigher([2, 3000, 999], [2, 3001, 1])).toBe(false);
});
it('returns false for equal versions', () => {
expect(isWaVersionHigher([2, 3000, 1], [2, 3000, 1])).toBe(false);
});
});
+22
View File
@@ -0,0 +1,22 @@
import type { WAVersion } from '@adiwajshing/baileys';
export function parseWaVersion(value: string): WAVersion | null {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value.trim());
if (!match) {
return null;
}
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
export function formatWaVersion(version: WAVersion): string {
return version.join('.');
}
export function isWaVersionHigher(left: WAVersion, right: WAVersion): boolean {
for (let index = 0; index < 3; index++) {
if (left[index] !== right[index]) {
return left[index] > right[index];
}
}
return false;
}
+1 -1
View File
@@ -34,7 +34,7 @@ function ToGroupInfo(
subject: group.name,
description: group.description,
invite: invite,
membersCanAddNewMember: groupMetadata.restrict,
membersCanAddNewMember: groupMetadata.memberAddMode === 'all_member_add',
membersCanSendMessages: groupMetadata.announce,
newMembersApprovalRequired: groupMetadata.membershipApprovalMode,
participants: participants,
+45 -1
View File
@@ -108,6 +108,7 @@ import {
GroupParticipant,
GroupSortField,
ParticipantsRequest,
SettingsMemberAddMode,
SettingsSecurityChangeInfo,
} from '@waha/structures/groups.dto';
import { Label, LabelDTO, LabelID } from '@waha/structures/labels.dto';
@@ -125,7 +126,11 @@ import {
WAMessageReaction,
} from '@waha/structures/responses.dto';
import { BrowserTraceQuery } from '@waha/structures/server.debug.dto';
import { MeInfo } from '@waha/structures/sessions.dto';
import {
MeInfo,
ReachoutTimelockEnforcementType,
} from '@waha/structures/sessions.dto';
import { EnsureSeconds } from '@waha/utils/timehelper';
import {
BROADCAST_ID,
DeleteStatusRequest,
@@ -535,6 +540,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
id: GetSerialized(wid),
lid: GetSerialized(clientInfo.lid),
pushName: clientInfo?.pushname,
reachoutTimelock: this.reachoutTimelock.value,
};
}
@@ -551,6 +557,28 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
protected listenConnectionEvents() {
this.whatsapp.on(Events.REACHOUT_TIMELOCK_UPDATE, (record: any) => {
if (!record) {
// The app removes the stored record when the enforcement is lifted (or there is none)
const current = this.reachoutTimelock.value;
if (current?.isActive) {
this.reachoutTimelock.update({ ...current, isActive: false });
}
return;
}
const enforcementType =
record.enforcement_type ?? ReachoutTimelockEnforcementType.DEFAULT;
this.reachoutTimelock.update({
enforcementType: enforcementType as ReachoutTimelockEnforcementType,
// The record only exists while the enforcement is active
isActive: true,
// The app stores 'time_enforcement_ends' as unix milliseconds
timeEnforcementEnds: record.time_enforcement_ends
? EnsureSeconds(record.time_enforcement_ends)
: null,
});
});
this.whatsapp.on(Events.QR_RECEIVED, async (qr) => {
this.logger.debug('QR received');
// Convert to image and save
@@ -1508,6 +1536,22 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
return groupChat.setMessagesAdminsOnly(value);
}
public async getMemberAddMode(id): Promise<SettingsMemberAddMode> {
const groupChat = (await this.whatsapp.getChatById(id)) as GroupChat;
return {
membersCanAddNewMember:
// @ts-ignore
groupChat.groupMetadata.memberAddMode === 'all_member_add',
};
}
@Activity()
public async setMemberAddMode(id, value) {
const groupChat = (await this.whatsapp.getChatById(id)) as GroupChat;
// The library setter is inverted - it takes "adminsOnly"
return groupChat.setAddMembersAdminsOnly(!value);
}
public async getGroups(pagination: PaginationParams) {
const chats = await this.whatsapp.getChats();
const groups = lodash.filter(chats, (chat) => chat.isGroup);
+23 -1
View File
@@ -165,6 +165,7 @@ import {
GroupParticipantRole,
GroupSortField,
ParticipantsRequest,
SettingsMemberAddMode,
SettingsSecurityChangeInfo,
} from '@waha/structures/groups.dto';
import { ContactQuery, ContactRequest } from '@waha/structures/contacts.dto';
@@ -1412,6 +1413,26 @@ export class WhatsappSessionWPPCore extends WhatsappSession {
);
}
public async getMemberAddMode(id): Promise<SettingsMemberAddMode> {
const group = await this.wpp!.getChatById(this.ensureSuffix(id));
// Undocumented property - 'all_member_add' or 'admin_add'
const memberAddMode = group?.groupMetadata?.memberAddMode;
return {
membersCanAddNewMember: memberAddMode === 'all_member_add',
};
}
@Activity()
public setMemberAddMode(id: string, value: boolean): Promise<boolean> {
// Not in the GroupProperty enum, but wa-js passes the property through
// to WhatsApp's setGroupProperty - 1 - all members, 0 - admins only
return this.wpp!.setGroupProperty(
this.ensureSuffix(id),
'member_add_mode' as GroupProperty,
value,
);
}
@Activity()
public deleteGroup(id: string): Promise<boolean> {
return this.wpp!.deleteChat(this.ensureSuffix(id));
@@ -1874,7 +1895,8 @@ export class WhatsappSessionWPPCore extends WhatsappSession {
subject: group?.name || '',
description: group?.groupMetadata?.desc || '',
invite: null,
membersCanAddNewMember: !group?.groupMetadata?.restrict,
membersCanAddNewMember:
group?.groupMetadata?.memberAddMode === 'all_member_add',
membersCanSendMessages: !group?.groupMetadata?.announce,
newMembersApprovalRequired:
!!group?.groupMetadata?.membershipApprovalMode,
+12 -2
View File
@@ -11,6 +11,7 @@ import {
} from '@waha/apps/app_sdk/services/IAppsService';
import { EngineBootstrap } from '@waha/core/abc/EngineBootstrap';
import { GowsEngineConfigService } from '@waha/core/config/GowsEngineConfigService';
import { NowebEngineConfigService } from '@waha/core/config/NowebEngineConfigService';
import { WPPEngineConfigService } from '@waha/core/config/WPPEngineConfigService';
import { WebJSEngineConfigService } from '@waha/core/config/WebJSEngineConfigService';
import { WhatsappSessionGoWSCore } from '@waha/core/engines/gows/session.gows.core';
@@ -96,6 +97,7 @@ export class SessionManagerCore
private engineConfigService: EngineConfigService,
private webjsEngineConfigService: WebJSEngineConfigService,
private wppEngineConfigService: WPPEngineConfigService,
private nowebEngineConfigService: NowebEngineConfigService,
gowsConfigService: GowsEngineConfigService,
log: PinoLogger,
private mediaStorageFactory: MediaStorageFactory,
@@ -235,14 +237,20 @@ export class SessionManagerCore
this.log.info(`Restarting sessions with delay of ${sleepS} seconds...`);
const sleepMs = this.config.autoStartDelaySeconds * 1000;
for (const sessionName of sessions) {
const log = this.log.logger.child({ session: sessionName });
await this.withLock(sessionName, async () => {
const log = this.log.logger.child({ session: sessionName });
log.info(`Restarting STOPPED session...`);
await this.start(sessionName).catch((error) => {
log.error(`Failed to start STOPPED session: ${error}`);
log.error(error.stack);
});
});
})
// withLock() itself can reject (e.g. "Maximum execution time is exceeded"),
// catch it so one stuck session doesn't abort restarting the rest
.catch((error) => {
log.error(`Failed to restart STOPPED session: ${error}`);
log.error(error.stack);
});
await sleep(sleepMs);
}
this.log.info(`STOPPED sessions have been restarted.`);
@@ -358,6 +366,8 @@ export class SessionManagerCore
sessionConfig.engineConfig = this.wppEngineConfigService.getConfig();
} else if (this.EngineClass === WhatsappSessionGoWSCore) {
sessionConfig.engineConfig = this.gowsConfigService.getConfig();
} else if (this.EngineClass === WhatsappSessionNoWebCore) {
sessionConfig.engineConfig = this.nowebEngineConfigService.getConfig();
}
// @ts-ignore
const session = new this.EngineClass(sessionConfig);
+1 -1
View File
@@ -108,7 +108,7 @@ export class MediaLocalStorage implements IMediaStorage {
this.log.debug(`File ${filepath} was removed`);
}),
delay,
);
).unref();
}
async close() {
+17
View File
@@ -0,0 +1,17 @@
import { applyDecorators } from '@nestjs/common';
import { IsString, Matches, MaxLength } from 'class-validator';
// Affect almost all Databases - Sqlite, MongoDB, Postgres.
const DB_NAME_LIMIT = 64;
const DB_NAME_MAX_PREFIX_LEN = 'waha_noweb'.length;
export function SessionName() {
return applyDecorators(
IsString(),
MaxLength(DB_NAME_LIMIT - DB_NAME_MAX_PREFIX_LEN),
Matches(/^[a-zA-Z0-9_-]*$/, {
message:
'Session name can only contain alphanumeric characters, hyphens, and underscores (a-z, A-Z, 0-9, -, _) or be empty',
}),
);
}
+1 -1
View File
@@ -8,7 +8,7 @@ import {
ValidateNested,
} from 'class-validator';
import { SessionActions } from '@waha/core/auth/casl.types';
import { SessionName } from '@waha/structures/sessions.dto';
import { SessionName } from '@waha/nestjs/validation/SessionName';
export class SessionActionsDTO implements SessionActions {
@ApiProperty({
+4
View File
@@ -26,6 +26,10 @@ export class SettingsSecurityChangeInfo {
adminsOnly: boolean = true;
}
export class SettingsMemberAddMode {
membersCanAddNewMember: boolean = true;
}
/**
* Queries
*/
+61 -17
View File
@@ -1,8 +1,8 @@
import { applyDecorators } from '@nestjs/common';
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 { SessionName } from '@waha/nestjs/validation/SessionName';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
@@ -10,8 +10,6 @@ import {
IsEnum,
IsOptional,
IsString,
Matches,
MaxLength,
ValidateNested,
} from 'class-validator';
@@ -327,6 +325,56 @@ export class SessionDTO {
config?: SessionConfig;
}
/**
* Enforcement types as listed in the WhatsApp Web app (WAWebUserPrefsTypes.ReachoutTimelockEnforcementType).
* WhatsApp may introduce new values at any time - treat it as an open set.
*/
export enum ReachoutTimelockEnforcementType {
// No restriction
DEFAULT = 'DEFAULT',
BIZ_QUALITY = 'BIZ_QUALITY',
BIZ_COMMERCE_VIOLATION_ADULT = 'BIZ_COMMERCE_VIOLATION_ADULT',
BIZ_COMMERCE_VIOLATION_ALCOHOL = 'BIZ_COMMERCE_VIOLATION_ALCOHOL',
BIZ_COMMERCE_VIOLATION_ANIMALS = 'BIZ_COMMERCE_VIOLATION_ANIMALS',
BIZ_COMMERCE_VIOLATION_BODY_PARTS_FLUIDS = 'BIZ_COMMERCE_VIOLATION_BODY_PARTS_FLUIDS',
BIZ_COMMERCE_VIOLATION_DATING = 'BIZ_COMMERCE_VIOLATION_DATING',
BIZ_COMMERCE_VIOLATION_DIGITAL_SERVICES_PRODUCTS = 'BIZ_COMMERCE_VIOLATION_DIGITAL_SERVICES_PRODUCTS',
BIZ_COMMERCE_VIOLATION_DRUGS = 'BIZ_COMMERCE_VIOLATION_DRUGS',
BIZ_COMMERCE_VIOLATION_DRUGS_ONLY_OTC = 'BIZ_COMMERCE_VIOLATION_DRUGS_ONLY_OTC',
BIZ_COMMERCE_VIOLATION_GAMBLING = 'BIZ_COMMERCE_VIOLATION_GAMBLING',
BIZ_COMMERCE_VIOLATION_HEALTHCARE = 'BIZ_COMMERCE_VIOLATION_HEALTHCARE',
BIZ_COMMERCE_VIOLATION_REAL_FAKE_CURRENCY = 'BIZ_COMMERCE_VIOLATION_REAL_FAKE_CURRENCY',
BIZ_COMMERCE_VIOLATION_SUPPLEMENTS = 'BIZ_COMMERCE_VIOLATION_SUPPLEMENTS',
BIZ_COMMERCE_VIOLATION_TOBACCO = 'BIZ_COMMERCE_VIOLATION_TOBACCO',
BIZ_COMMERCE_VIOLATION_VIOLENT_CONTENT = 'BIZ_COMMERCE_VIOLATION_VIOLENT_CONTENT',
BIZ_COMMERCE_VIOLATION_WEAPONS = 'BIZ_COMMERCE_VIOLATION_WEAPONS',
WEB_COMPANION_ONLY = 'WEB_COMPANION_ONLY',
RESTRICT_ALL_COMPANIONS = 'RESTRICT_ALL_COMPANIONS',
}
export class ReachoutTimelockData {
@ApiProperty({
example: ReachoutTimelockEnforcementType.RESTRICT_ALL_COMPANIONS,
enum: ReachoutTimelockEnforcementType,
description:
'Raw WhatsApp enforcement type. Informational only - it does not change what is blocked. ' +
'WhatsApp may introduce new values, so treat it as an open set.',
})
enforcementType: ReachoutTimelockEnforcementType;
@ApiProperty({
example: true,
})
isActive: boolean;
@ApiProperty({
example: 1784477333,
nullable: true,
description: 'Unix timestamp (seconds) when the enforcement ends.',
})
timeEnforcementEnds: number | null;
}
export class MeInfo {
@ChatIdProperty()
id: string;
@@ -343,6 +391,15 @@ export class MeInfo {
jid?: string;
pushName: string;
@ApiProperty({
required: false,
nullable: true,
description:
'WhatsApp reachout timelock (account restriction) info. ' +
'Null if no enforcement has been seen for the account.',
})
reachoutTimelock?: ReachoutTimelockData | null;
}
export class SessionInfo extends SessionDTO {
@@ -368,20 +425,7 @@ export class SessionDetailedInfo extends SessionInfo {
engine?: any;
}
// Affect almost all Databases - Sqlite, MongoDB, Postgres.
const DB_NAME_LIMIT = 64;
const DB_NAME_MAX_PREFIX_LEN = 'waha_noweb'.length;
export function SessionName() {
return applyDecorators(
IsString(),
MaxLength(DB_NAME_LIMIT - DB_NAME_MAX_PREFIX_LEN),
Matches(/^[a-zA-Z0-9_-]*$/, {
message:
'Session name can only contain alphanumeric characters, hyphens, and underscores (a-z, A-Z, 0-9, -, _) or be empty',
}),
);
}
export { SessionName };
export class SessionCreateRequest {
@ApiProperty({
+4 -1
View File
@@ -133,7 +133,10 @@ export class WASessionStatusBody {
'Extra info that belongs to the current status, null for most of them.\n' +
'PASSKEY_REQUIRED - the WebAuthn challenge, ' +
'pass it to navigator.credentials.get({ publicKey: data }).\n' +
'PASSKEY_CONFIRMATION_REQUIRED - { code } - the code to verify against the phone.',
'PASSKEY_CONFIRMATION_REQUIRED - { code } - the code to verify against the phone.\n' +
'WORKING - { reachoutTimelock } - present when WhatsApp restricts the account ' +
'from messaging new contacts (the cause of 463 errors on send); ' +
'the session stays connected and the restriction lifts automatically.',
example: null,
})
data?: any;
+41 -6
View File
@@ -57,12 +57,47 @@ export async function waitUntil(
/**
* Handle setTimeout 32-bit overflow
*/
export function setLongTimeout(callback, delayMs) {
const MAX_DELAY = 2_147_483_647; // ~24.8 days
export class LongTimeout {
private static readonly MAX_DELAY = 2_147_483_647; // ~24.8 days
if (delayMs > MAX_DELAY) {
setTimeout(() => setLongTimeout(callback, delayMs - MAX_DELAY), MAX_DELAY);
} else {
setTimeout(callback, delayMs);
private timeout?: ReturnType<typeof setTimeout>;
private unrefed: boolean = false;
constructor(
private callback: () => void,
delayMs: number,
) {
this.schedule(delayMs);
}
private schedule(delayMs: number) {
if (delayMs > LongTimeout.MAX_DELAY) {
this.timeout = setTimeout(
() => this.schedule(delayMs - LongTimeout.MAX_DELAY),
LongTimeout.MAX_DELAY,
);
} else {
this.timeout = setTimeout(this.callback, delayMs);
}
if (this.unrefed) {
this.timeout.unref?.();
}
}
/**
* Do not keep the process alive just for this timer
*/
unref(): LongTimeout {
this.unrefed = true;
this.timeout?.unref?.();
return this;
}
clear() {
clearTimeout(this.timeout);
}
}
export function setLongTimeout(callback: () => void, delayMs: number) {
return new LongTimeout(callback, delayMs);
}
+18 -1
View File
@@ -1,5 +1,15 @@
/**
* If value is ms - we convert it to seconds
* Both helpers detect the unit by magnitude, using 1e12 as the threshold:
* as unix SECONDS 1e12 is year 33658, as unix MILLISECONDS it's Sep 2001 - so any realistic "now-ish"
* timestamp is below 1e12 in seconds and above it in milliseconds.
*/
/**
* Normalize a unix timestamp to SECONDS.
* Milliseconds get converted (floored), values already in seconds pass through unchanged.
*
* EnsureSeconds(1784477333) => 1784477333 (already seconds)
* EnsureSeconds(1784477333000) => 1784477333 (ms => seconds)
*/
export function EnsureSeconds(ms: number) {
if (!ms) {
@@ -10,6 +20,13 @@ export function EnsureSeconds(ms: number) {
}
return ms;
}
/**
* Normalize a unix timestamp to MILLISECONDS. Seconds get converted, values already in milliseconds pass through unchanged.
*
* EnsureMilliseconds(1784477333) => 1784477333000 (seconds => ms)
* EnsureMilliseconds(1784477333000) => 1784477333000 (already ms)
*/
export function EnsureMilliseconds(seconds: number) {
if (!seconds) {
return seconds;
+1 -1
View File
@@ -48,7 +48,7 @@ function getPlatform() {
}
export const VERSION: WAHAEnvironment = {
version: '2026.7.1',
version: '2026.7.2',
engine: getEngineName(),
tier: getWAHAVersion(),
browser: getBrowser(),
+2 -2
View File
@@ -2,11 +2,11 @@
"waha": {
"gows": {
"repo": "devlikeapro/gows-plus",
"ref": "v1.0.43"
"ref": "v1.0.44"
},
"dashboard": {
"repo": "devlikeapro/dashboard",
"ref": "68a2e06035beed6ba04bd5dede49399d5478096d"
"ref": "d670e01a3776d94842c230f94fab8ebce833f8d5"
}
}
}
+8 -8
View File
@@ -2,12 +2,12 @@
# Manual changes might be lost - proceed with caution!
__metadata:
version: 8
version: 10
cacheKey: 10
"@adiwajshing/baileys@github:devlikeapro/Baileys#fork-master-2026-04-28":
version: 7.0.0-rc13
resolution: "@adiwajshing/baileys@https://github.com/devlikeapro/Baileys.git#commit=942ba050bab32436384e267ec2d8122ca434fba6"
version: 7.0.0-rc14
resolution: "@adiwajshing/baileys@https://github.com/devlikeapro/Baileys.git#commit=0124c8f073719727fb8db2b08d4b14b81573d82c"
dependencies:
"@cacheable/node-cache": "npm:^1.4.0"
"@hapi/boom": "npm:^9.1.3"
@@ -32,7 +32,7 @@ __metadata:
optional: true
link-preview-js:
optional: true
checksum: 10/44194fa66bd9b6f3ca1cac9aa2e0edc88a5c2537b6bf366c70f9ebcb4560888785af8c5f4f2f18851d5fd9f55f7421a319a0157c0c6ed8d72734e3c9a0946c0c
checksum: 10/35b77766d9a8eccc8366efa5a0f702b45039df3578b9ac6bc53743305255b9c7f28ca04248c50f30ed7af3d5adcd4842fdeeb557967f21d7dc096c303de8cf55
languageName: node
linkType: hard
@@ -12185,14 +12185,14 @@ __metadata:
"resolve@patch:resolve@npm%3A^1.20.0#optional!builtin<compat/resolve>":
version: 1.22.10
resolution: "resolve@patch:resolve@npm%3A1.22.10#optional!builtin<compat/resolve>::version=1.22.10&hash=c3c19d"
resolution: "resolve@patch:resolve@npm%3A1.22.10#optional!builtin<compat/resolve>::version=1.22.10&hash=9bd1a5"
dependencies:
is-core-module: "npm:^2.16.0"
path-parse: "npm:^1.0.7"
supports-preserve-symlinks-flag: "npm:^1.0.0"
bin:
resolve: bin/resolve
checksum: 10/d4d878bfe3702d215ea23e75e0e9caf99468e3db76f5ca100d27ebdc527366fee3877e54bce7d47cc72ca8952fc2782a070d238bfa79a550eeb0082384c3b81a
checksum: 10/c202d86f0b7090886c41427c7161c781169fafe20c9389c5feb0bc021a83e29db8f691c02fcd7d9730db2e7de6ef608a45d0e509733e7e54060d64f0e38521a5
languageName: node
linkType: hard
@@ -14148,7 +14148,7 @@ __metadata:
"whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2026-06-26":
version: 1.34.7
resolution: "whatsapp-web.js@https://github.com/devlikeapro/whatsapp-web.js.git#commit=2daac8c8f83ddd7d92983bff8803dde970002a79"
resolution: "whatsapp-web.js@https://github.com/devlikeapro/whatsapp-web.js.git#commit=23d28e7070e9e2689280118c184edb8cb3bb709b"
dependencies:
archiver: "npm:7.0.1"
fluent-ffmpeg: "npm:2.1.3"
@@ -14165,7 +14165,7 @@ __metadata:
optional: true
unzipper:
optional: true
checksum: 10/358d9c3e9da5f04d2db26706ba2cbf2a675efed47905d1b47319096b7739dd34e3ded8526ae5985da6b6fce9e9b931bc142a612db4af46b520bd9d55ad5ef6c9
checksum: 10/c9493ee9f0034f2fcd5234575f140dadb1402dbfa143af88efc8c489bc7b43d0375e83c5fa01ad507f0d3ed5982ad8504f6802aa7f95c0af8293a051a2e99ba6
languageName: node
linkType: hard