Compare commits

..
33 Commits
Author SHA1 Message Date
devlikepro 382c1a7e94 fix(WEBJS): phantom AUTHENTICATED/READY on logout and onNewMessageId binding race - fix #2222
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-08-14 18:48:18 +07:00
devlikepro d7fb3091a6 build: ignore on watch 2026-08-14 18:48:18 +07:00
devlikepro 1fbe60e646 fix(WEBJS): session stuck in STARTING 2026-08-14 18:48:18 +07:00
devlikepro 841063739e feat: GET /api/sessions/{name}/timelock - fix #2219 2026-08-14 18:48:18 +07:00
devlikepro 8d4eb02202 up(WEBJS): hide "What's new on WhatsApp Web" - fix #2217 2026-08-14 18:48:18 +07:00
Moshe Grunwald f6ed147acc fix(s3): set ContentType on uploaded media objects 2026-08-14 17:45:14 +07:00
devlikepro 96a106cff7 up(WPP): fix promote to admin types 2026-08-14 17:39:23 +07:00
devlikepro 98854e2da3 up(WPP) 2026-08-14 17:25:11 +07:00
devlikepro 48c34cbc17 up(dashboard) 2026-08-05 16:00:16 +07:00
devlikepro 926b5c1b66 fea(GOWS): disable contacts, messageSecrets - mention #2207 2026-08-05 15:31:40 +07:00
devlikepro 608bad07a9 fix(ChatWoot): populate phone number if available fix #2208 2026-08-05 15:00:43 +07:00
devlikepro 64c6a32786 fix(GOWS): populate jid if available fix #2208 2026-08-05 14:34:59 +07:00
devlikepro f33ca787a5 up(NOWEB): fix presense chat issue 2026-08-05 14:34:59 +07:00
devlikepro 78aea9aa51 fix: set participant.pn if available 2026-08-05 14:34:59 +07:00
devlikepro 5e75d47149 up(WPP) 2026-08-05 14:34:59 +07:00
devlikepro c5a53d36bb fix(NOWEB): Fix group.v2.leave - fix #2206 closes #2209 2026-08-05 14:34:59 +07:00
devlikepro 0213692705 fix(ChatWot): do not save full attachments content in a task result - fix #2201 closes #2202 2026-08-05 14:34:59 +07:00
devlikepro a112fde248 fix(WEBJS): set group description #2199 2026-08-05 14:34:59 +07:00
devlikepro c207f3c08c fix(WEBJS): kill chrome at the end 2026-08-05 14:34:59 +07:00
devlikepro 1b8027473e fix(NOWEB): stop session properly 2026-08-05 14:34:59 +07:00
devlikepro 976066b216 chore(WEBJS): Up chrome to 142.0.7444.134-1 2026-08-05 14:34:59 +07:00
devlikepro af229a1d40 fix(GOWS): SIGKILL if hanged 2026-08-05 14:34:59 +07:00
devlikepro d14126d8a6 fix: process catch on promise timeout 2026-08-05 14:34:59 +07:00
devlikepro d4ce0e6541 fix: process.once on signals 2026-08-05 14:34:59 +07:00
devlikepro 5ddbeb995e feat(WEBJS, NOWEB): message capping API and events mention #2186 2026-08-05 14:34:59 +07:00
devlikepro f26d43b299 fix(WEBJS) - send button reply fix #2183 2026-08-05 14:34:59 +07:00
Berg Pinheiro ff998579f0 feat(GOWS): expose new-chat message capping (per-cycle quota) (#2186) 2026-08-05 14:34:59 +07:00
devlikepro 3cae921fba up(WPP): up engine 2026-08-05 14:34:59 +07:00
devlikepro 4682dddb4e chore(WPP): make with ignore sharp 2026-08-05 14:34:59 +07:00
devlikepro 2e6683d26f up(WEBJS): fix add group participants 2026-08-05 14:34:59 +07:00
devlikepro e97107ba1a up(GOWS)
- Update proto
- Fix disable whole store - fix #2207
2026-08-05 14:34:59 +07:00
devlikepro 2d96a57f72 feat(NOWEB): WAHA_NOWEB_WA_VERSION=auto-web|auto-baileys - fetch the latest WhatsApp Web version on start 2026-07-29 16:36:19 +07:00
devlikepro 9adcd3b133 version: 2026.8.1 2026-07-29 15:53:48 +07:00
39 changed files with 1650 additions and 386 deletions

No files matched your search

+1 -1
View File
@@ -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.207-1"
ARG CHROME_VERSION="142.0.7444.134-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 \
+2 -2
View File
@@ -44,8 +44,8 @@ up-webjs:
yarn up whatsapp-web.js@github:devlikeapro/whatsapp-web.js#fork-main-2026-06-26
up-wpp:
yarn up @wppconnect-team/wppconnect@github:wppconnect-team/wppconnect#master
yarn up @wppconnect/wa-js@github:wppconnect-team/wa-js#main
SHARP_IGNORE_GLOBAL_LIBVIPS=1 yarn up @wppconnect-team/wppconnect@github:wppconnect-team/wppconnect#master
SHARP_IGNORE_GLOBAL_LIBVIPS=1 yarn up @wppconnect/wa-js@github:wppconnect-team/wa-js#main
up-rust-bridge:
yarn up -R whatsapp-rust-bridge
-1
View File
@@ -3,7 +3,6 @@
"sourceRoot": "src",
"compilerOptions": {
"plugins": ["@nestjs/swagger"],
"watchPathIgnorePatterns": ["node_modules", "src/dashboard", "dist"],
"assets": [
{ "include": "dashboard/**", "watchAssets": false },
{ "include": "core/engines/webjs/*", "watchAssets": true },
+35
View File
@@ -34,6 +34,8 @@ import { WhatsappSession } from '../core/abc/session.abc';
import {
ListSessionsQuery,
MeInfo,
MessageCappingData,
ReachoutTimelockData,
SessionCreateRequest,
SessionDTO,
SessionExpand,
@@ -98,6 +100,39 @@ class SessionsController {
return this.sessionService.getSessionMe(session);
}
@Get(':session/capping')
@SessionApiParam
@ApiOperation({
summary: 'Fetch the account new-chat message capping (per-cycle quota)',
description:
'Fetch a fresh new-chat message capping (quota) state from WhatsApp. ' +
'The same value is also available under me.messageCapping in the session ' +
'info, and changes are pushed through the session.status event.',
})
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
fetchMessageCapping(
@SessionParam session: WhatsappSession,
): Promise<MessageCappingData> {
return session.fetchMessageCapping();
}
@Get(':session/timelock')
@SessionApiParam
@ApiOperation({
summary: 'Fetch the account reachout timelock state',
description:
'Fetch a fresh reachout timelock state from WhatsApp - the restriction ' +
'behind "server returned error 463" when messaging new contacts. ' +
'The same value is also available under me.reachoutTimelock in the session ' +
'info, and changes are pushed through the session.status event.',
})
@CheckPolicies(CanSession(Action.Read, FromParam('session')))
fetchReachoutTimelock(
@SessionParam session: WhatsappSession,
): Promise<ReachoutTimelockData> {
return session.fetchReachoutTimelock();
}
@Post('')
@ApiOperation({
summary: 'Create a session',
+13
View File
@@ -0,0 +1,13 @@
export function clearContent(attachments) {
/**
* Remove actual base64 "content" from attachment
*/
if (!attachments) {
return attachments;
}
return attachments.map((attachment) => {
attachment = { ...attachment };
attachment.content = '';
return attachment;
});
}
+4
View File
@@ -39,6 +39,7 @@ import {
ChatWootAppConfig,
ChatWootConfig,
} from '@waha/apps/chatwoot/dto/config.dto';
import { clearContent } from '@waha/apps/chatwoot/consumers/utils';
export function ListenEventsForChatWoot(config: ChatWootConfig) {
const events = [
@@ -319,6 +320,7 @@ export abstract class MessageBaseHandler<
this.session,
EngineHelper.ChatID(payload as any),
this.l,
EngineHelper.PhoneNumber(payload as any),
);
const conversation = await this.repo.ConversationByContact(contactInfo);
this.info.onConversationId(conversation.conversationId);
@@ -329,6 +331,8 @@ export abstract class MessageBaseHandler<
`Created message as '${message.message_type}' from WhatsApp: ${response.id}`,
);
await this.saveMapping(response, payload);
// Clear attachments content to avoid saving it in the task result
message.attachments = clearContent(message.attachments);
return message;
}
@@ -89,9 +89,21 @@ class JidContactInfo extends ChatContactInfo {
* LID contact info
*/
class LidContactInfo extends ChatContactInfo {
constructor(
session: WAHASessionAPI,
chatId: string,
locale: Locale,
private pn: string | null = null,
) {
super(session, chatId, locale);
}
@CacheAsync()
async jid() {
const pn = await this.session.findPNByLid(this.chatId);
let pn = this.pn;
if (!pn) {
pn = await this.session.findPNByLid(this.chatId);
}
if (!pn) {
return null;
}
@@ -261,6 +273,7 @@ export function WhatsAppContactInfo(
session: WAHASessionAPI,
chatId: string,
locale: Locale,
pn: string | null = null,
): ContactInfo {
if (isJidGroup(chatId)) {
return new GroupContactInfo(session, chatId, locale);
@@ -271,7 +284,7 @@ export function WhatsAppContactInfo(
} else if (isJidBroadcast(chatId)) {
return new BroadcastContactInfo(session, chatId, locale);
} else if (isLidUser(chatId)) {
return new LidContactInfo(session, normalizeJid(chatId), locale);
return new LidContactInfo(session, normalizeJid(chatId), locale, pn);
} else if (isPnUser(chatId)) {
return new JidContactInfo(session, chatId, locale);
} else {
+57 -1
View File
@@ -5,7 +5,12 @@ import { WAHAEngine } from '@waha/structures/enums.dto';
import { getEngineName } from '@waha/version';
import { Message as MessageInstance } from 'whatsapp-web.js/src/structures';
import { Jid } from '@waha/core/engines/const';
import { isLidUser, isPnUser, toCusFormat } from '@waha/core/utils/jids';
import {
isLidUser,
isPnUser,
jidsFromKey,
toCusFormat,
} from '@waha/core/utils/jids';
import { parseMessageIdSerialized } from '@waha/core/utils/ids';
import { WAMessage } from '@waha/structures/responses.dto';
import { CallData } from '@waha/structures/calls.dto';
@@ -22,6 +27,12 @@ export interface QuotedMedia {
interface IEngineHelper {
ChatID(message: WAMessage | any): string;
/**
* Phone number chat id (@c.us) of the DM contact extracted from the message payload
* (alt jid fields), when the engine provides it. Null otherwise.
*/
PhoneNumber(message: WAMessage | any): string | null;
CallChatID(call: CallData | any): string;
IsReplyToStatus(
@@ -49,6 +60,15 @@ class NOWEBHelper implements IEngineHelper {
return message.from;
}
PhoneNumber(message: WAMessage | any): string | null {
const key = message._data?.key;
if (!key) {
return null;
}
const jids = jidsFromKey(key);
return pnChatIdOrNull(jids?.pn);
}
CallChatID(call: CallData): string {
return call.from;
}
@@ -100,6 +120,21 @@ class GOWSHelper implements IEngineHelper {
return message.from;
}
PhoneNumber(message: WAMessage | any): string | null {
const info = message._data?.Info;
if (!info) {
return null;
}
// SenderAlt / RecipientAlt carry the phone number for @lid-addressed chats
let pn: string | null;
if (info.IsFromMe) {
pn = info.RecipientAlt || null;
} else {
pn = info.SenderAlt || null;
}
return pnChatIdOrNull(pn);
}
CallChatID(call: CallData): string {
return call._data?.CallCreator || call.from;
}
@@ -156,6 +191,11 @@ class WEBJSHelper implements IEngineHelper {
return message._data?.id?.remote || message.from;
}
PhoneNumber(message: WAMessage | any): string | null {
void message;
return null;
}
CallChatID(call: CallData): string {
return call.from;
}
@@ -222,6 +262,11 @@ class WPPHelper implements IEngineHelper {
return toCusFormat(parseMessageIdSerialized(message.id as any).remoteJid);
}
PhoneNumber(message: WAMessage | any): string | null {
void message;
return null;
}
CallChatID(call: CallData): string {
return call.from;
}
@@ -367,6 +412,17 @@ function extractBrowserQuotedMedia(replyData: any): QuotedMedia | null {
};
}
function pnChatIdOrNull(pn: string | null | undefined): string | null {
if (!pn) {
return null;
}
const chatId = toCusFormat(pn);
if (isPnUser(chatId)) {
return chatId;
}
return null;
}
function preferPnChats(chats: string[]): string[] {
const unique = lodash.uniq(chats ?? []);
const hasPn = unique.some(isPnUser);
+37
View File
@@ -171,4 +171,41 @@ export class SessionTools extends McpController {
url: `/api/sessions/${session}/restart`,
});
}
@Tool('sessions-capping', {
title: 'Get message capping',
description:
'Fetch the account new-chat message capping (per-cycle quota).',
inputSchema: SessionNameInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async capping({ session }: z.infer<typeof SessionNameInput>) {
return this.textRequest({
method: 'GET',
url: `/api/sessions/${session}/capping`,
});
}
@Tool('sessions-timelock', {
title: 'Get reachout timelock',
description:
'Fetch the account reachout timelock state ' +
'(the restriction behind error 463 when messaging new contacts).',
inputSchema: SessionNameInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async timelock({ session }: z.infer<typeof SessionNameInput>) {
return this.textRequest({
method: 'GET',
url: `/api/sessions/${session}/timelock`,
});
}
}
+39
View File
@@ -0,0 +1,39 @@
import { MessageCappingData } from '@waha/structures/sessions.dto';
import * as lodash from 'lodash';
import { Logger } from 'pino';
import { Observable, Subject } from 'rxjs';
/**
* WhatsApp new-chat message capping (per-cycle quota).
* The volume counterpart of the reachout timelock: it caps how many new chats
* the account may start per cycle. Keeps the latest known state and emits
* changes. Unlike the timelock there is no client-side expiry - the cycle
* resets on the server and the state is refreshed by re-fetching.
*/
export class MessageCappingTracker {
private capping: MessageCappingData | null = null;
private changes: Subject<MessageCappingData | null> = new Subject();
constructor(private logger: Logger) {}
get value(): MessageCappingData | null {
return this.capping;
}
get changes$(): Observable<MessageCappingData | null> {
return this.changes;
}
update(capping: MessageCappingData | null) {
if (lodash.isEqual(this.capping, capping)) {
return;
}
this.apply(capping);
}
private apply(capping: MessageCappingData | null) {
this.capping = capping;
this.logger.info({ messageCapping: capping }, 'Message capping updated');
this.changes.next(capping);
}
}
+33
View File
@@ -0,0 +1,33 @@
import {
MessageCappingData,
MessageCappingStatus,
} from '@waha/structures/sessions.dto';
import { EnsureSeconds } from '@waha/utils/timehelper';
function parseCappingTimestamp(value: any): number | null {
if (!value) {
return null;
}
const parsed = parseInt(value, 10);
return Number.isNaN(parsed) ? null : EnsureSeconds(parsed);
}
/**
* Normalize the message capping payload (xwa2_message_capping_info) - the shape is the same
* across engines: GOWS gRPC event, NOWEB Baileys fetch/push, WEBJS injected fetch/local read.
*/
export function parseMessageCapping(data: any): MessageCappingData {
const cappingStatus = (data?.capping_status ??
MessageCappingStatus.NONE) as MessageCappingStatus;
return {
cappingStatus: cappingStatus,
// -1 total means "no cap"
totalQuota: typeof data?.total_quota === 'number' ? data.total_quota : -1,
usedQuota: typeof data?.used_quota === 'number' ? data.used_quota : 0,
// cycle timestamps are unix seconds strings (or ms numbers from WEBJS local reads)
cycleStart: parseCappingTimestamp(data?.cycle_start_timestamp),
cycleEnd: parseCappingTimestamp(data?.cycle_end_timestamp),
mvStatus: data?.mv_status ?? null,
oteStatus: data?.ote_status ?? null,
};
}
+36 -7
View File
@@ -1,3 +1,4 @@
import { MessageCappingTracker } from '@waha/core/abc/MessageCappingTracker';
import { ReachoutTimelockTracker } from '@waha/core/abc/ReachoutTimelockTracker';
import { getBrowserExecutablePath as getBrowserExecutablePathAutodetect } from '@waha/core/abc/session.browser';
import { IMediaConverter } from '@waha/core/media/IConverter';
@@ -114,7 +115,10 @@ import {
import { WAHAChatPresences } from '../../structures/presence.dto';
import {
MeInfo,
MessageCappingData,
MessageCappingStatus,
ProxyConfig,
ReachoutTimelockData,
SessionConfig,
} from '../../structures/sessions.dto';
import {
@@ -199,6 +203,7 @@ export abstract class WhatsappSession {
private _status: WAHASessionStatus;
private _statusData: any = null;
protected reachoutTimelock: ReachoutTimelockTracker;
protected messageCapping: MessageCappingTracker;
private _presence:
| WAHAPresenceStatus.ONLINE
| WAHAPresenceStatus.OFFLINE
@@ -254,6 +259,15 @@ export abstract class WhatsappSession {
});
}
});
this.messageCapping = new MessageCappingTracker(this.logger);
this.messageCapping.changes$.subscribe((capping) => {
if (this.status === WAHASessionStatus.WORKING) {
// Re-issue WORKING so 'session.status' consumers get the update
this.setStatus(WAHASessionStatus.WORKING, {
messageCapping: capping,
});
}
});
this.events2 = new DefaultMap<WAHAEvents, SwitchObservable<any>>(
(key) =>
new SwitchObservable((obs$) => {
@@ -362,13 +376,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.WORKING && data == null) {
// Plain 'status = WORKING' assignments (reconnects) must keep carrying the
// active account-restriction info so 'session.status' consumers do not lose it
const carry: any = {};
if (this.reachoutTimelock.value?.isActive) {
carry.reachoutTimelock = this.reachoutTimelock.value;
}
const capping = this.messageCapping.value;
if (capping && capping.cappingStatus !== MessageCappingStatus.NONE) {
carry.messageCapping = capping;
}
if (Object.keys(carry).length > 0) {
data = carry;
}
}
if (
status === WAHASessionStatus.STOPPED ||
@@ -506,6 +527,14 @@ export abstract class WhatsappSession {
throw new NotImplementedByEngineError();
}
public fetchMessageCapping(): Promise<MessageCappingData> {
throw new NotImplementedByEngineError();
}
public fetchReachoutTimelock(): Promise<ReachoutTimelockData> {
throw new NotImplementedByEngineError();
}
/**
* Auth methods
*/
+72 -21
View File
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NowebConfig } from '@waha/core/engines/noweb/session.noweb.core';
import {
formatWaVersion,
isWaVersionHigher,
parseWaVersion,
} from '@waha/core/engines/noweb/waversion';
@@ -10,6 +11,13 @@ import { parseBool } from '@waha/helpers';
import esm from '@waha/vendor/esm';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
export enum WAVersionAutoMode {
WEB = 'auto-web',
BAILEYS = 'auto-baileys',
}
const FETCH_WA_VERSION_TIMEOUT_MS = 15_000;
@Injectable()
export class NowebEngineConfigService {
private config: NowebConfig | null = null;
@@ -20,9 +28,9 @@ export class NowebEngineConfigService {
protected logger: PinoLogger,
) {}
getConfig(): NowebConfig {
async getConfig(): Promise<NowebConfig> {
if (!this.config) {
const version = this.resolveWaVersion();
const version = await this.resolveWaVersion();
this.config = { waVersion: version };
}
return this.config;
@@ -30,47 +38,90 @@ export class NowebEngineConfigService {
/**
* Resolves the WhatsApp Web version to use for NOWEB engine.
* Returns undefined when the built-in Baileys version should be used.
* Returns the built-in Baileys version when WAHA_NOWEB_WA_VERSION is not set, invalid, can not be fetched
* or is lower than the built-in one (unless WAHA_NOWEB_WA_VERSION_FORCE is set).
*/
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;
private async resolveWaVersion(): Promise<WAVersion> {
const env = this.configService.get<string>('WAHA_NOWEB_WA_VERSION', '');
const wahaversion = esm.b.DEFAULT_CONNECTION_CONFIG.version as WAVersion;
const force = parseBool(
this.configService.get('WAHA_NOWEB_WA_VERSION_FORCE', 'false'),
);
const options = {
signal: AbortSignal.timeout(FETCH_WA_VERSION_TIMEOUT_MS),
};
if (!envVersion && value) {
this.logger.warn(
`WAHA_NOWEB_WA_VERSION='${value}' - invalid format, expected 'x.y.z'.`,
);
return version;
let version: WAVersion | null = null;
switch (env) {
case WAVersionAutoMode.WEB: {
const value = await esm.b.fetchLatestWaWebVersion(options);
if (!value.isLatest) {
this.logger.warn(
`WAHA_NOWEB_WA_VERSION='${env}' - failed to fetch the latest WhatsApp Web version: ${value.error}`,
);
return wahaversion;
}
this.logger.info(
`WAHA_NOWEB_WA_VERSION='${env}' - fetched the latest WhatsApp Web version: ${formatWaVersion(
value.version,
)}`,
);
version = value.version;
break;
}
case WAVersionAutoMode.BAILEYS: {
const value = await esm.b.fetchLatestBaileysVersion(options);
if (!value.isLatest) {
this.logger.warn(
`WAHA_NOWEB_WA_VERSION='${env}' - failed to fetch the latest WhatsApp Web version: ${value.error}`,
);
return wahaversion;
}
this.logger.info(
`WAHA_NOWEB_WA_VERSION='${env}' - fetched the latest WhatsApp Web version: ${formatWaVersion(
value.version,
)}`,
);
version = value.version;
break;
}
case '':
break;
default:
version = parseWaVersion(env);
}
if (!envVersion && force) {
if (!version && env) {
this.logger.warn(
`WAHA_NOWEB_WA_VERSION='${env}' - invalid format, expected 'x.y.z', '${WAVersionAutoMode.WEB}' or '${WAVersionAutoMode.BAILEYS}'.`,
);
return wahaversion;
}
if (!version && force) {
this.logger.warn(
`WAHA_NOWEB_WA_VERSION_FORCE is set, but WAHA_NOWEB_WA_VERSION is not.`,
);
return version;
return wahaversion;
}
if (!envVersion) {
return version;
if (!version) {
return wahaversion;
}
if (force && envVersion) {
if (force && version) {
this.logger.debug(
`Using WAHA_NOWEB_WA_VERSION because of WAHA_NOWEB_WA_VERSION_FORCE.`,
);
return envVersion;
return version;
}
if (isWaVersionHigher(version, envVersion)) {
if (isWaVersionHigher(wahaversion, version)) {
this.logger.debug(
`Built-in wa.version is higher than WAHA_NOWEB_WA_VERSION, using built-in version.`,
);
return version;
return wahaversion;
}
return envVersion;
return version;
}
}
+15 -2
View File
@@ -1,10 +1,11 @@
import { sleep, waitUntil } from '@waha/utils/promiseTimeout';
import { promiseTimeout, sleep, waitUntil } from '@waha/utils/promiseTimeout';
import { spawn } from 'child_process';
import { Logger } from 'pino';
export class GowsSubprocess {
private checkIntervalMs: number = 100;
private readyDelayMs: number = 1_000;
private stopTimeoutMs: number = 3_000;
private readyText = 'gRPC server started!';
private child: any;
@@ -96,7 +97,19 @@ export class GowsSubprocess {
async stop() {
this.logger.info('Stopping GOWS subprocess...');
this.child?.kill('SIGTERM');
const child = this.child;
if (!child || child.exitCode !== null || child.signalCode !== null) {
return;
}
const exited = new Promise<void>((resolve) => child.once('exit', resolve));
child.kill('SIGTERM');
// the process is detached, so if it ignores SIGTERM nothing else kills it - escalate to SIGKILL
await promiseTimeout(this.stopTimeoutMs, exited).catch(() => {
this.logger.warn(
`GOWS did not exit in ${this.stopTimeoutMs}ms, sending SIGKILL`,
);
child.kill('SIGKILL');
});
this.logger.info('GOWS subprocess stopped');
}
+4 -2
View File
@@ -4,7 +4,7 @@ import {
GroupInfoFull,
JoinedGroupEvent,
} from '@waha/core/engines/gows/types.group';
import { toCusFormat, toJID } from '@waha/core/utils/jids';
import { isPnUser, toCusFormat, toJID } from '@waha/core/utils/jids';
import {
GroupId,
GroupInfo,
@@ -115,8 +115,10 @@ function getParticipants(jids: string[] | null, type: GroupParticipantType) {
}
const participants: GroupParticipant[] = [];
for (const jid of jids) {
const id = toCusFormat(jid);
participants.push({
id: toCusFormat(jid),
id: id,
pn: isPnUser(id) ? id : null,
role: role,
});
}
+122 -1
View File
@@ -1423,7 +1423,7 @@ export namespace messages {
}
}
export class SessionStorageConfig extends pb_1.Message {
#one_of_decls: number[][] = [[1], [2], [3], [4]];
#one_of_decls: number[][] = [[1], [2], [3], [4], [5], [6]];
constructor(data?: any[] | ({} & (({
messages?: boolean;
}) | ({
@@ -1432,6 +1432,10 @@ export namespace messages {
chats?: boolean;
}) | ({
labels?: boolean;
}) | ({
contacts?: boolean;
}) | ({
message_secrets?: boolean;
})))) {
super();
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls);
@@ -1448,6 +1452,12 @@ export namespace messages {
if ("labels" in data && data.labels != undefined) {
this.labels = data.labels;
}
if ("contacts" in data && data.contacts != undefined) {
this.contacts = data.contacts;
}
if ("message_secrets" in data && data.message_secrets != undefined) {
this.message_secrets = data.message_secrets;
}
}
}
get messages() {
@@ -1486,6 +1496,24 @@ export namespace messages {
get has_labels() {
return pb_1.Message.getField(this, 4) != null;
}
get contacts() {
return pb_1.Message.getFieldWithDefault(this, 5, false) as boolean;
}
set contacts(value: boolean) {
pb_1.Message.setOneofField(this, 5, this.#one_of_decls[4], value);
}
get has_contacts() {
return pb_1.Message.getField(this, 5) != null;
}
get message_secrets() {
return pb_1.Message.getFieldWithDefault(this, 6, false) as boolean;
}
set message_secrets(value: boolean) {
pb_1.Message.setOneofField(this, 6, this.#one_of_decls[5], value);
}
get has_message_secrets() {
return pb_1.Message.getField(this, 6) != null;
}
get _messages() {
const cases: {
[index: number]: "none" | "messages";
@@ -1522,11 +1550,31 @@ export namespace messages {
};
return cases[pb_1.Message.computeOneofCase(this, [4])];
}
get _contacts() {
const cases: {
[index: number]: "none" | "contacts";
} = {
0: "none",
5: "contacts"
};
return cases[pb_1.Message.computeOneofCase(this, [5])];
}
get _message_secrets() {
const cases: {
[index: number]: "none" | "message_secrets";
} = {
0: "none",
6: "message_secrets"
};
return cases[pb_1.Message.computeOneofCase(this, [6])];
}
static fromObject(data: {
messages?: boolean;
groups?: boolean;
chats?: boolean;
labels?: boolean;
contacts?: boolean;
message_secrets?: boolean;
}): SessionStorageConfig {
const message = new SessionStorageConfig({});
if (data.messages != null) {
@@ -1541,6 +1589,12 @@ export namespace messages {
if (data.labels != null) {
message.labels = data.labels;
}
if (data.contacts != null) {
message.contacts = data.contacts;
}
if (data.message_secrets != null) {
message.message_secrets = data.message_secrets;
}
return message;
}
toObject() {
@@ -1549,6 +1603,8 @@ export namespace messages {
groups?: boolean;
chats?: boolean;
labels?: boolean;
contacts?: boolean;
message_secrets?: boolean;
} = {};
if (this.messages != null) {
data.messages = this.messages;
@@ -1562,6 +1618,12 @@ export namespace messages {
if (this.labels != null) {
data.labels = this.labels;
}
if (this.contacts != null) {
data.contacts = this.contacts;
}
if (this.message_secrets != null) {
data.message_secrets = this.message_secrets;
}
return data;
}
serialize(): Uint8Array;
@@ -1576,6 +1638,10 @@ export namespace messages {
writer.writeBool(3, this.chats);
if (this.has_labels)
writer.writeBool(4, this.labels);
if (this.has_contacts)
writer.writeBool(5, this.contacts);
if (this.has_message_secrets)
writer.writeBool(6, this.message_secrets);
if (!w)
return writer.getResultBuffer();
}
@@ -1597,6 +1663,12 @@ export namespace messages {
case 4:
message.labels = reader.readBool();
break;
case 5:
message.contacts = reader.readBool();
break;
case 6:
message.message_secrets = reader.readBool();
break;
default: reader.skipField();
}
}
@@ -6396,6 +6468,7 @@ export namespace messages {
phone?: string;
jid?: string;
registered?: boolean;
pn?: string;
}) {
super();
pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls);
@@ -6409,6 +6482,9 @@ export namespace messages {
if ("registered" in data && data.registered != undefined) {
this.registered = data.registered;
}
if ("pn" in data && data.pn != undefined) {
this.pn = data.pn;
}
}
}
get phone() {
@@ -6429,10 +6505,17 @@ export namespace messages {
set registered(value: boolean) {
pb_1.Message.setField(this, 3, value);
}
get pn() {
return pb_1.Message.getFieldWithDefault(this, 4, "") as string;
}
set pn(value: string) {
pb_1.Message.setField(this, 4, value);
}
static fromObject(data: {
phone?: string;
jid?: string;
registered?: boolean;
pn?: string;
}): PhoneInfo {
const message = new PhoneInfo({});
if (data.phone != null) {
@@ -6444,6 +6527,9 @@ export namespace messages {
if (data.registered != null) {
message.registered = data.registered;
}
if (data.pn != null) {
message.pn = data.pn;
}
return message;
}
toObject() {
@@ -6451,6 +6537,7 @@ export namespace messages {
phone?: string;
jid?: string;
registered?: boolean;
pn?: string;
} = {};
if (this.phone != null) {
data.phone = this.phone;
@@ -6461,6 +6548,9 @@ export namespace messages {
if (this.registered != null) {
data.registered = this.registered;
}
if (this.pn != null) {
data.pn = this.pn;
}
return data;
}
serialize(): Uint8Array;
@@ -6473,6 +6563,8 @@ export namespace messages {
writer.writeString(2, this.jid);
if (this.registered != false)
writer.writeBool(3, this.registered);
if (this.pn.length)
writer.writeString(4, this.pn);
if (!w)
return writer.getResultBuffer();
}
@@ -6491,6 +6583,9 @@ export namespace messages {
case 3:
message.registered = reader.readBool();
break;
case 4:
message.pn = reader.readString();
break;
default: reader.skipField();
}
}
@@ -11434,6 +11529,24 @@ export namespace messages {
responseSerialize: (message: Empty) => Buffer.from(message.serialize()),
responseDeserialize: (bytes: Buffer) => Empty.deserialize(new Uint8Array(bytes))
},
FetchReachoutTimelock: {
path: "/messages.MessageService/FetchReachoutTimelock",
requestStream: false,
responseStream: false,
requestSerialize: (message: Session) => Buffer.from(message.serialize()),
requestDeserialize: (bytes: Buffer) => Session.deserialize(new Uint8Array(bytes)),
responseSerialize: (message: Json) => Buffer.from(message.serialize()),
responseDeserialize: (bytes: Buffer) => Json.deserialize(new Uint8Array(bytes))
},
FetchMessageCapping: {
path: "/messages.MessageService/FetchMessageCapping",
requestStream: false,
responseStream: false,
requestSerialize: (message: Session) => Buffer.from(message.serialize()),
requestDeserialize: (bytes: Buffer) => Session.deserialize(new Uint8Array(bytes)),
responseSerialize: (message: Json) => Buffer.from(message.serialize()),
responseDeserialize: (bytes: Buffer) => Json.deserialize(new Uint8Array(bytes))
},
GenerateNewMessageID: {
path: "/messages.MessageService/GenerateNewMessageID",
requestStream: false,
@@ -11751,6 +11864,8 @@ export namespace messages {
abstract SubscribePresence(call: grpc_1.ServerUnaryCall<SubscribePresenceRequest, Empty>, callback: grpc_1.sendUnaryData<Empty>): void;
abstract CheckPhones(call: grpc_1.ServerUnaryCall<CheckPhonesRequest, CheckPhonesResponse>, callback: grpc_1.sendUnaryData<CheckPhonesResponse>): void;
abstract MarkChatUnread(call: grpc_1.ServerUnaryCall<ChatUnreadRequest, Empty>, callback: grpc_1.sendUnaryData<Empty>): void;
abstract FetchReachoutTimelock(call: grpc_1.ServerUnaryCall<Session, Json>, callback: grpc_1.sendUnaryData<Json>): void;
abstract FetchMessageCapping(call: grpc_1.ServerUnaryCall<Session, Json>, callback: grpc_1.sendUnaryData<Json>): void;
abstract GenerateNewMessageID(call: grpc_1.ServerUnaryCall<Session, NewMessageIDResponse>, callback: grpc_1.sendUnaryData<NewMessageIDResponse>): void;
abstract SendMessage(call: grpc_1.ServerUnaryCall<MessageRequest, MessageResponse>, callback: grpc_1.sendUnaryData<MessageResponse>): void;
abstract SendReaction(call: grpc_1.ServerUnaryCall<MessageReaction, MessageResponse>, callback: grpc_1.sendUnaryData<MessageResponse>): void;
@@ -11895,6 +12010,12 @@ export namespace messages {
MarkChatUnread: GrpcUnaryServiceInterface<ChatUnreadRequest, Empty> = (message: ChatUnreadRequest, 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.MarkChatUnread(message, metadata, options, callback);
};
FetchReachoutTimelock: GrpcUnaryServiceInterface<Session, Json> = (message: Session, 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.FetchReachoutTimelock(message, metadata, options, callback);
};
FetchMessageCapping: GrpcUnaryServiceInterface<Session, Json> = (message: Session, 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.FetchMessageCapping(message, metadata, options, callback);
};
GenerateNewMessageID: GrpcUnaryServiceInterface<Session, NewMessageIDResponse> = (message: Session, metadata: grpc_1.Metadata | grpc_1.CallOptions | grpc_1.requestCallback<NewMessageIDResponse>, options?: grpc_1.CallOptions | grpc_1.requestCallback<NewMessageIDResponse>, callback?: grpc_1.requestCallback<NewMessageIDResponse>): grpc_1.ClientUnaryCall => {
return super.GenerateNewMessageID(message, metadata, options, callback);
};
@@ -1143,6 +1143,33 @@ getProfilePicture: {
responseDeserialize: deserialize_messages_Empty,
},
//
// Account
//
// Fetch the account's current reachout timelock state.
fetchReachoutTimelock: {
path: '/messages.MessageService/FetchReachoutTimelock',
requestStream: false,
responseStream: false,
requestType: gows_pb.Session,
responseType: gows_pb.Json,
requestSerialize: serialize_messages_Session,
requestDeserialize: deserialize_messages_Session,
responseSerialize: serialize_messages_Json,
responseDeserialize: deserialize_messages_Json,
},
// Fetch the account's current new-chat message capping (per-cycle quota).
fetchMessageCapping: {
path: '/messages.MessageService/FetchMessageCapping',
requestStream: false,
responseStream: false,
requestType: gows_pb.Session,
responseType: gows_pb.Json,
requestSerialize: serialize_messages_Session,
requestDeserialize: deserialize_messages_Session,
responseSerialize: serialize_messages_Json,
responseDeserialize: deserialize_messages_Json,
},
//
// Message
//
generateNewMessageID: {
+128 -2
View File
@@ -4816,7 +4816,9 @@ proto.messages.SessionStorageConfig.toObject = function(includeInstance, msg) {
messages: jspb.Message.getBooleanFieldWithDefault(msg, 1, false),
groups: jspb.Message.getBooleanFieldWithDefault(msg, 2, false),
chats: jspb.Message.getBooleanFieldWithDefault(msg, 3, false),
labels: jspb.Message.getBooleanFieldWithDefault(msg, 4, false)
labels: jspb.Message.getBooleanFieldWithDefault(msg, 4, false),
contacts: jspb.Message.getBooleanFieldWithDefault(msg, 5, false),
messageSecrets: jspb.Message.getBooleanFieldWithDefault(msg, 6, false)
};
if (includeInstance) {
@@ -4869,6 +4871,14 @@ proto.messages.SessionStorageConfig.deserializeBinaryFromReader = function(msg,
var value = /** @type {boolean} */ (reader.readBool());
msg.setLabels(value);
break;
case 5:
var value = /** @type {boolean} */ (reader.readBool());
msg.setContacts(value);
break;
case 6:
var value = /** @type {boolean} */ (reader.readBool());
msg.setMessageSecrets(value);
break;
default:
reader.skipField();
break;
@@ -4926,6 +4936,20 @@ proto.messages.SessionStorageConfig.serializeBinaryToWriter = function(message,
f
);
}
f = /** @type {boolean} */ (jspb.Message.getField(message, 5));
if (f != null) {
writer.writeBool(
5,
f
);
}
f = /** @type {boolean} */ (jspb.Message.getField(message, 6));
if (f != null) {
writer.writeBool(
6,
f
);
}
};
@@ -5073,6 +5097,78 @@ proto.messages.SessionStorageConfig.prototype.hasLabels = function() {
};
/**
* optional bool contacts = 5;
* @return {boolean}
*/
proto.messages.SessionStorageConfig.prototype.getContacts = function() {
return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 5, false));
};
/**
* @param {boolean} value
* @return {!proto.messages.SessionStorageConfig} returns this
*/
proto.messages.SessionStorageConfig.prototype.setContacts = function(value) {
return jspb.Message.setField(this, 5, value);
};
/**
* Clears the field making it undefined.
* @return {!proto.messages.SessionStorageConfig} returns this
*/
proto.messages.SessionStorageConfig.prototype.clearContacts = function() {
return jspb.Message.setField(this, 5, undefined);
};
/**
* Returns whether this field is set.
* @return {boolean}
*/
proto.messages.SessionStorageConfig.prototype.hasContacts = function() {
return jspb.Message.getField(this, 5) != null;
};
/**
* optional bool message_secrets = 6;
* @return {boolean}
*/
proto.messages.SessionStorageConfig.prototype.getMessageSecrets = function() {
return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 6, false));
};
/**
* @param {boolean} value
* @return {!proto.messages.SessionStorageConfig} returns this
*/
proto.messages.SessionStorageConfig.prototype.setMessageSecrets = function(value) {
return jspb.Message.setField(this, 6, value);
};
/**
* Clears the field making it undefined.
* @return {!proto.messages.SessionStorageConfig} returns this
*/
proto.messages.SessionStorageConfig.prototype.clearMessageSecrets = function() {
return jspb.Message.setField(this, 6, undefined);
};
/**
* Returns whether this field is set.
* @return {boolean}
*/
proto.messages.SessionStorageConfig.prototype.hasMessageSecrets = function() {
return jspb.Message.getField(this, 6) != null;
};
@@ -13869,7 +13965,8 @@ proto.messages.PhoneInfo.toObject = function(includeInstance, msg) {
var f, obj = {
phone: jspb.Message.getFieldWithDefault(msg, 1, ""),
jid: jspb.Message.getFieldWithDefault(msg, 2, ""),
registered: jspb.Message.getBooleanFieldWithDefault(msg, 3, false)
registered: jspb.Message.getBooleanFieldWithDefault(msg, 3, false),
pn: jspb.Message.getFieldWithDefault(msg, 4, "")
};
if (includeInstance) {
@@ -13918,6 +14015,10 @@ proto.messages.PhoneInfo.deserializeBinaryFromReader = function(msg, reader) {
var value = /** @type {boolean} */ (reader.readBool());
msg.setRegistered(value);
break;
case 4:
var value = /** @type {string} */ (reader.readString());
msg.setPn(value);
break;
default:
reader.skipField();
break;
@@ -13968,6 +14069,13 @@ proto.messages.PhoneInfo.serializeBinaryToWriter = function(message, writer) {
f
);
}
f = message.getPn();
if (f.length > 0) {
writer.writeString(
4,
f
);
}
};
@@ -14025,6 +14133,24 @@ proto.messages.PhoneInfo.prototype.setRegistered = function(value) {
};
/**
* optional string pn = 4;
* @return {string}
*/
proto.messages.PhoneInfo.prototype.getPn = function() {
return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 4, ""));
};
/**
* @param {string} value
* @return {!proto.messages.PhoneInfo} returns this
*/
proto.messages.PhoneInfo.prototype.setPn = function(value) {
return jspb.Message.setProto3StringField(this, 4, value);
};
/**
* List of repeated fields within this message type.
+37 -1
View File
@@ -28,6 +28,7 @@ import {
parseJsonList,
statusToAck,
} from '@waha/core/engines/gows/helpers';
import { parseMessageCapping } from '@waha/core/abc/capping';
import { parseGowsReachoutTimelock } from '@waha/core/engines/gows/reachouttimelock';
import { GowsAuthFactoryCore } from '@waha/core/engines/gows/store/GowsAuthFactoryCore';
import {
@@ -136,7 +137,9 @@ import {
import { CallData } from '@waha/structures/calls.dto';
import {
MeInfo,
MessageCappingData,
ProxyConfig,
ReachoutTimelockData,
SessionConfig,
} from '@waha/structures/sessions.dto';
import {
@@ -222,6 +225,8 @@ function getGowsStorageConfig(
groups: storeConfig?.groups !== false,
chats: storeConfig?.chats !== false,
labels: storeConfig?.labels !== false,
contacts: storeConfig?.contacts !== false,
message_secrets: storeConfig?.messageSecrets !== false,
});
}
@@ -238,6 +243,7 @@ enum WhatsMeowEvent {
PUSH_NAME_SETTING = 'events.PushNameSetting',
LOGGED_OUT = 'events.LoggedOut',
NOTIFY_ACCOUNT_REACHOUT_TIMELOCK = 'events.NotifyAccountReachoutTimelock',
MESSAGE_CAPPING = 'gows.MessageCapping',
// Groups
GROUP_INFO = 'events.GroupInfo',
JOINED_GROUP = 'events.JoinedGroup',
@@ -479,6 +485,9 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
events.on(WhatsMeowEvent.NOTIFY_ACCOUNT_REACHOUT_TIMELOCK, (data) => {
this.reachoutTimelock.update(parseGowsReachoutTimelock(data));
});
events.on(WhatsMeowEvent.MESSAGE_CAPPING, (data) => {
this.messageCapping.update(parseMessageCapping(data));
});
events.on(WhatsMeowEvent.PRESENCE, (event: gows.Presence) => {
if (isJidGroup(event.From)) {
// So group is not "online"
@@ -927,7 +936,11 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
if (!this.me) {
return null;
}
return { ...this.me, reachoutTimelock: this.reachoutTimelock.value };
return {
...this.me,
reachoutTimelock: this.reachoutTimelock.value,
messageCapping: this.messageCapping.value,
};
}
/**
@@ -1264,6 +1277,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return {
numberExists: info?.registered || false,
chatId: toCusFormat(info?.jid || null),
pn: toCusFormat(info?.pn || null),
};
}
@@ -2275,6 +2289,28 @@ export class WhatsappSessionGoWSCore extends WhatsappSession {
return this.toWAContact(data);
}
@Activity()
public async fetchMessageCapping(): Promise<MessageCappingData> {
const response = await promisify(this.client.FetchMessageCapping)(
this.session,
);
const capping = parseMessageCapping(parseJson(response));
// Keep the tracker in sync so MeInfo and 'session.status' reflect the fetch
this.messageCapping.update(capping);
return capping;
}
@Activity()
public async fetchReachoutTimelock(): Promise<ReachoutTimelockData> {
const response = await promisify(this.client.FetchReachoutTimelock)(
this.session,
);
const timelock = parseGowsReachoutTimelock(parseJson(response));
// Keep the tracker in sync so MeInfo and 'session.status' reflect the fetch
this.reachoutTimelock.update(timelock);
return timelock;
}
public async getContacts(pagination: PaginationParams) {
const request = new messages.GetContactsRequest({
session: this.session,
+37 -8
View File
@@ -17,7 +17,7 @@ import {
GroupV2ParticipantsEvent,
GroupV2UpdateEvent,
} from '@waha/structures/groups.events.dto';
import { toCusFormat } from '@waha/core/utils/jids';
import { isPnUser, toCusFormat } from '@waha/core/utils/jids';
import esm from '@waha/vendor/esm';
export function ToGroupInfo(group: Partial<GroupMetadata>): GroupInfo {
@@ -68,7 +68,7 @@ interface GroupParticipantUpdate {
action: ParticipantAction;
}
function getParticipantId(
export function getParticipantId(
participant: string | NOWEBGroupParticipant,
): string | undefined {
if (typeof participant === 'string') {
@@ -77,6 +77,39 @@ function getParticipantId(
return participant?.id;
}
function getParticipantPn(
participant: string | NOWEBGroupParticipant,
): string | null {
if (typeof participant === 'string') {
return isPnUser(participant) ? participant : null;
}
return participant?.phoneNumber || null;
}
function getParticipantIds(
participant: string | NOWEBGroupParticipant,
): string[] {
if (typeof participant === 'string') {
return [participant];
}
return [participant?.id, participant?.phoneNumber].filter(Boolean);
}
/**
* Check if me.id or me.lid in participant list
*/
export function participantsIncludeMe(
me: Contact,
participants: Array<string | NOWEBGroupParticipant>,
): boolean {
const myIds = [me.id, me.lid].filter(Boolean);
return participants.some((participant) =>
getParticipantIds(participant).some((id) =>
myIds.some((meId) => esm.b.areJidsSameUser(id, meId)),
),
);
}
export function ToGroupV2Participants(
update: GroupParticipantUpdate,
): GroupV2ParticipantsEvent {
@@ -105,6 +138,7 @@ export function ToGroupV2Participants(
const id = getParticipantId(item);
return {
id: toCusFormat(id),
pn: toCusFormat(getParticipantPn(item)),
role: role,
};
});
@@ -140,12 +174,7 @@ export function ToGroupV2LeaveEvent(
if (!me) {
return null;
}
const meId = esm.b.jidNormalizedUser(me.id);
const includesMe = update.participants.some((participant) => {
const id = getParticipantId(participant);
return id === meId;
});
if (!includesMe) {
if (!participantsIncludeMe(me, update.participants)) {
return null;
}
+114 -27
View File
@@ -44,6 +44,7 @@ import type {
import { ILogger } from '@adiwajshing/baileys/lib/Utils/logger';
import { isLidUser } from '@adiwajshing/baileys/lib/WABinary/jid-utils';
import { UnprocessableEntityException } from '@nestjs/common';
import { parseMessageCapping } from '@waha/core/abc/capping';
import {
getChannelInviteLink,
getPublicUrlFromDirectPath,
@@ -175,6 +176,8 @@ import {
import { WAMessage, WAMessageReaction } from '@waha/structures/responses.dto';
import {
MeInfo,
MessageCappingData,
ReachoutTimelockData,
ReachoutTimelockEnforcementType,
} from '@waha/structures/sessions.dto';
import { EnsureSeconds } from '@waha/utils/timehelper';
@@ -196,7 +199,7 @@ import {
WAMessageRevokedBody,
} from '@waha/structures/webhooks.dto';
import { LoggerBuilder } from '@waha/utils/logging';
import { sleep, waitUntil } from '@waha/utils/promiseTimeout';
import { promiseTimeout, sleep, waitUntil } from '@waha/utils/promiseTimeout';
import { exclude } from '@waha/utils/reactive/ops/exclude';
import { SingleDelayedJobRunner } from '@waha/utils/SingleDelayedJobRunner';
import { SinglePeriodicJobRunner } from '@waha/utils/SinglePeriodicJobRunner';
@@ -278,6 +281,8 @@ export interface NowebConfig {
export class WhatsappSessionNoWebCore extends WhatsappSession {
private START_ATTEMPT_DELAY_SECONDS = 2;
private AUTO_RESTART_AFTER_SECONDS = 28 * 60;
// how long to wait on stop for the WebSocket close handshake and store close before forcing it
private CLOSE_TIMEOUT_MS = 3_000;
engine = WAHAEngine.NOWEB;
protected engineConfig: NowebConfig;
@@ -562,23 +567,13 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
protected listenConnectionEvents() {
this.logger.debug(`Start listening ${BaileysEvents.CONNECTION_UPDATE}...`);
this.sock.ev.on('message-capping.update', (data) => {
this.messageCapping.update(parseMessageCapping(data));
});
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,
});
this.updateReachoutTimelockFromState(update.reachoutTimeLock);
}
if (isNewLogin) {
this.restartClient();
@@ -590,6 +585,15 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
this.sock?.fetchAccountReachoutTimelock?.().catch((error) => {
this.logger.warn(`Failed to fetch reachout timelock: ${error}`);
});
// Same for the new-chat message capping - there is no push on (re)connect, only on changes
this.sock
?.fetchNewChatMessageCap?.()
.then((data) => {
this.messageCapping.update(parseMessageCapping(data));
})
.catch((error) => {
this.logger.warn(`Failed to fetch message capping: ${error}`);
});
// Do we need to resubscribe?
// Ideally not, we need to explicitly call interesting
// jids every 1 minute
@@ -667,16 +671,11 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
});
this.logger.info('Creds saved');
}
await this.end();
await this.closeStores();
this.status = WAHASessionStatus.STOPPED;
this.stopEvents();
this.mediaManager.close();
await this.end();
await this.store?.close();
this.authNOWEBStore?.close().catch((err) => {
this.logger.error('Failed to close NOWEB auth store');
this.logger.error(err, err.stack);
});
}
protected async failed() {
@@ -692,8 +691,34 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
await sleep(1_000);
}
this.stopEvents();
this.mediaManager.close();
await this.end();
await this.store?.close();
await this.closeStores();
}
/**
* Close the data and auth stores with a time bound - close() may flush pending writes,
* and broken storage must not block session stop or process shutdown
*/
private async closeStores() {
if (this.store) {
await promiseTimeout(this.CLOSE_TIMEOUT_MS, this.store.close()).catch(
(err) => {
this.logger.error('Failed to close NOWEB store');
this.logger.error(err, err.stack);
},
);
}
if (this.authNOWEBStore) {
await promiseTimeout(
this.CLOSE_TIMEOUT_MS,
this.authNOWEBStore.close(),
).catch((err) => {
this.logger.error('Failed to close NOWEB auth store');
this.logger.error(err, err.stack);
});
}
}
private fixMessages() {
@@ -873,13 +898,41 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
this.cleanupPresenceTimeout();
this.presence = null;
this.autoRestartJob.stop();
const sock = this.sock;
// @ts-ignore
this.sock?.ev?.removeAllListeners();
this.sock?.ws?.removeAllListeners();
sock?.ev?.removeAllListeners();
sock?.ws?.removeAllListeners();
// wait until connection is not connecting to avoid error:
// "WebSocket was closed before the connection was established"
await waitUntil(async () => !this.sock?.ws?.isConnecting, 1_000, 10_000);
this.sock?.end(undefined);
await waitUntil(async () => !sock?.ws?.isConnecting, 1_000, 10_000);
if (!sock) {
return;
}
// sock.end() waits for the WebSocket close handshake - on a dead or already closed connection
// it can hang forever, so bound it and destroy the raw TCP socket to let the process exit
const closing = sock.end(undefined);
try {
await promiseTimeout(this.CLOSE_TIMEOUT_MS, closing);
} catch (err) {
this.logger.warn(
`WebSocket did not close in ${this.CLOSE_TIMEOUT_MS}ms, terminating it: ${err}`,
);
this.terminate(sock);
}
}
/**
* Destroy the raw TCP socket behind Baileys WebSocket wrapper.
* ws.terminate() skips the close handshake, so it works even on half-open connections.
*/
private terminate(sock: ReturnType<typeof makeWASocket>) {
// 'socket' is protected on Baileys WebSocketClient, reach it at runtime
const raw = (sock.ws as any)?.socket;
try {
raw?.terminate?.();
} catch (err) {
this.logger.warn(`Failed to terminate WebSocket: ${err}`);
}
}
getSessionMeInfo(): MeInfo | null {
@@ -893,6 +946,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
pushName: me.name,
lid: jidNormalizedUser(me.lid),
reachoutTimelock: this.reachoutTimelock.value,
messageCapping: this.messageCapping.value,
};
}
@@ -983,6 +1037,39 @@ export class WhatsappSessionNoWebCore extends WhatsappSession {
return true;
}
@Activity()
public async fetchMessageCapping(): Promise<MessageCappingData> {
const data = await this.sock.fetchNewChatMessageCap();
const capping = parseMessageCapping(data);
// Keep the tracker in sync so MeInfo and 'session.status' reflect the fetch
this.messageCapping.update(capping);
return capping;
}
@Activity()
public async fetchReachoutTimelock(): Promise<ReachoutTimelockData> {
const state = await this.sock.fetchAccountReachoutTimelock();
return this.updateReachoutTimelockFromState(state);
}
private updateReachoutTimelockFromState(timelock: any): ReachoutTimelockData {
const enforcementType =
timelock.enforcementType ?? ReachoutTimelockEnforcementType.DEFAULT;
let timeEnforcementEnds: number | null = null;
if (timelock.timeEnforcementEnds) {
timeEnforcementEnds = EnsureSeconds(
timelock.timeEnforcementEnds.getTime(),
);
}
const data: ReachoutTimelockData = {
enforcementType: enforcementType as ReachoutTimelockEnforcementType,
isActive: timelock.isActive === true,
timeEnforcementEnds: timeEnforcementEnds,
};
this.reachoutTimelock.update(data);
return data;
}
/**
* Other methods
*/
@@ -11,6 +11,10 @@ import type {
import type { GroupMetadata } from '@adiwajshing/baileys/lib/Types/GroupMetadata';
import type { Label } from '@adiwajshing/baileys/lib/Types/Label';
import type { LabelAssociation } from '@adiwajshing/baileys/lib/Types/LabelAssociation';
import {
getParticipantId,
participantsIncludeMe,
} from '@waha/core/engines/noweb/groups.noweb';
import { IGroupRepository } from '@waha/core/engines/noweb/store/IGroupRepository';
import { ILabelAssociationRepository } from '@waha/core/engines/noweb/store/ILabelAssociationsRepository';
import { ILabelsRepository } from '@waha/core/engines/noweb/store/ILabelsRepository';
@@ -380,16 +384,13 @@ export class NowebPersistentStore implements INowebStore {
if (!this.jids.include(id)) {
return;
}
const participants: string[] = data.participants;
const participants: Array<string | GroupParticipant> = data.participants;
const action: ParticipantAction = data.action;
if (action == 'remove') {
// Remove the group if the current user is removed
const myJid = this.socket?.authState?.creds?.me?.id;
const participantsIncludesMe = lodash.find(participants, (p) =>
esm.b.areJidsSameUser(p, myJid),
);
if (participantsIncludesMe) {
const me = this.socket?.authState?.creds?.me;
if (me && participantsIncludeMe(me, participants)) {
await this.groupRepo.deleteById(id);
return;
}
@@ -419,24 +420,33 @@ export class NowebPersistentStore implements INowebStore {
private participantUpdate(
participantsById: DefaultMap<string, GroupParticipant>,
participant: string,
participant: string | GroupParticipant,
action: ParticipantAction,
) {
const participantId = getParticipantId(participant);
if (!participantId) {
return;
}
switch (action) {
case 'add':
// if there's no participant - add it (by id)
participantsById.get(participant);
if (typeof participant === 'string') {
// if there's no participant - add it (by id)
participantsById.get(participantId);
} else {
// keep the full object - it carries 'phoneNumber' and 'admin' fields
participantsById.set(participantId, participant);
}
break;
case 'remove':
// remove the participant (by id)
participantsById.delete(participant);
participantsById.delete(participantId);
break;
case 'promote':
// set admin: admin
participantsById.get(participant).admin = 'admin';
participantsById.get(participantId).admin = 'admin';
break;
case 'demote':
participantsById.get(participant).admin = null;
participantsById.get(participantId).admin = null;
break;
}
}
+50 -8
View File
@@ -8,6 +8,7 @@ import { TextStatus } from '@waha/structures/status.dto';
import { sleep } from '@waha/utils/promiseTimeout';
import { EventEmitter } from 'events';
import * as lodash from 'lodash';
import { Logger } from 'pino';
import { Page } from 'puppeteer';
import { Client, Events, Message as WebjsMessage } from 'whatsapp-web.js';
import { Message } from 'whatsapp-web.js/src/structures';
@@ -93,21 +94,31 @@ function extractReactionsByMessageKey(
export class WebjsClientCore extends Client {
public events = new EventEmitter();
private wpage: WPage = null;
private injecting: Promise<void> = null;
constructor(
options,
protected tags: boolean,
protected logger: Logger,
) {
super(options);
// Wait until it's READY and inject more utils
this.on(Events.AUTHENTICATED, async () => {
await this.attachCustomEventListeners();
await this.injectWaha();
});
this.on(Events.READY, async () => {
await this.attachCustomEventListeners();
await this.injectWaha();
});
// AUTHENTICATED and READY fire back to back - run one injection for both
this.on(Events.AUTHENTICATED, () => this.injectUtils());
this.on(Events.READY, () => this.injectUtils());
}
private injectUtils(): Promise<void> {
if (this.injecting) {
return this.injecting;
}
this.injecting = this.attachCustomEventListeners()
.then(() => this.injectWaha())
.catch((err) => this.logger.error(err, 'Failed to inject utils'))
.finally(() => {
this.injecting = null;
});
return this.injecting;
}
async initialize() {
@@ -147,6 +158,37 @@ export class WebjsClientCore extends Client {
});
}
/**
* @result indicating whether the "What's New" auto-modal was prevented or dismissed.
*/
hideWhatsNewModal(): Promise<boolean> {
return this.pupPage.evaluate(() => {
// Module registry is not available until the app bundle has loaded
if (typeof window.require !== 'function') {
return false;
}
const WAWebWhatsNewNux = window.require('WAWebWhatsNewNux');
if (!WAWebWhatsNewNux) {
return false;
}
// user prefs are not writable before login
const WAWebUserPrefsMeUser = window.require('WAWebUserPrefsMeUser');
if (!WAWebUserPrefsMeUser.getMaybeMePnUser()) {
return false;
}
const nux = WAWebWhatsNewNux.createWhatsNewNux();
if (!nux.shouldShow()) {
return false;
}
// Bump the dismiss count and start the cool-off, so the app never auto-opens the modal
nux.dismiss();
// If the modal has already opened - close the topmost modal (no-op when nothing is open)
const WAWebModalManager = window.require('WAWebModalManager');
WAWebModalManager.ModalManager.close();
return true;
});
}
async attachCustomEventListeners() {
await exposeFunctionIfAbsent(
this.pupPage,
+1
View File
@@ -140,6 +140,7 @@ export function ToGroupV2ParticipantsEvent(
(id) => {
return {
id: id,
pn: isPnUser(id) ? id : null,
role: role,
};
},
+164 -25
View File
@@ -1,4 +1,5 @@
import { UnprocessableEntityException } from '@nestjs/common';
import { parseMessageCapping } from '@waha/core/abc/capping';
import {
getChannelInviteLink,
getPublicUrlFromDirectPath,
@@ -128,6 +129,8 @@ import {
import { BrowserTraceQuery } from '@waha/structures/server.debug.dto';
import {
MeInfo,
MessageCappingData,
ReachoutTimelockData,
ReachoutTimelockEnforcementType,
} from '@waha/structures/sessions.dto';
import { EnsureSeconds } from '@waha/utils/timehelper';
@@ -148,12 +151,13 @@ import {
WAMessageRevokedBody,
} from '@waha/structures/webhooks.dto';
import { PaginatorInMemory } from '@waha/utils/Paginator';
import { sleep, waitUntil } from '@waha/utils/promiseTimeout';
import { promiseTimeout, sleep, waitUntil } from '@waha/utils/promiseTimeout';
import { SingleDelayedJobRunner } from '@waha/utils/SingleDelayedJobRunner';
import { SinglePeriodicJobRunner } from '@waha/utils/SinglePeriodicJobRunner';
import { TmpDir } from '@waha/utils/tmpdir';
import * as lodash from 'lodash';
import * as path from 'path';
import { ProtocolError } from 'puppeteer';
import { Browser, ProtocolError } from 'puppeteer';
import {
filter,
fromEvent,
@@ -218,6 +222,8 @@ export interface WebJSConfig {
export class WhatsappSessionWebJSCore extends WhatsappSession {
private START_ATTEMPT_DELAY_SECONDS = 2;
// how long to wait on stop for the browser to close gracefully before force-killing it
private DESTROY_TIMEOUT_MS = 10_000;
authFactory = new WebJSAuthFactory();
@@ -226,6 +232,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
private startDelayedJob: SingleDelayedJobRunner;
private engineStateCheckDelayedJob: SingleDelayedJobRunner;
private whatsNewModalJob: SinglePeriodicJobRunner;
private shouldRestart: boolean;
private lastQRDate: Date = null;
private static readonly REACTION_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
@@ -250,6 +257,13 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
2 * SECOND,
this.logger,
);
// Hide the "What's New" modal that blocks app init after QR login
this.whatsNewModalJob = new SinglePeriodicJobRunner(
'hide-whats-new-modal',
SECOND,
this.logger,
false,
);
}
/**
@@ -312,7 +326,11 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
const clientOptions = this.getClientOptions();
clientOptions.authStrategy = authStrategy;
this.addProxyConfig(clientOptions);
return new WebjsClientCore(clientOptions, this.getWebjsTagsFlag());
return new WebjsClientCore(
clientOptions,
this.getWebjsTagsFlag(),
this.logger,
);
}
protected getWebjsTagsFlag() {
@@ -420,6 +438,8 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
console.log(`url is ${location.href}`),
);
}
this.startWhatsNewModalJob();
})
.catch((error) => {
this.logger.error(error);
@@ -453,11 +473,11 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
async stop() {
this.cleanupPresenceTimeout();
this.shouldRestart = false;
this.startDelayedJob.cancel();
await this.end();
this.status = WAHASessionStatus.STOPPED;
this.stopEvents();
this.startDelayedJob.cancel();
this.mediaManager.close();
await this.end();
}
protected failed() {
@@ -488,6 +508,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
this.cleanupPresenceTimeout();
this.presence = null;
this.engineStateCheckDelayedJob.cancel();
this.whatsNewModalJob.stop();
this.whatsapp?.removeAllListeners();
this.whatsapp?.pupBrowser?.removeAllListeners();
this.whatsapp?.pupPage?.removeAllListeners();
@@ -514,10 +535,14 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
try {
await this.whatsapp?.destroy();
await promiseTimeout(this.DESTROY_TIMEOUT_MS, this.whatsapp?.destroy());
this.logger.debug('Successfully destroyed whatsapp client');
} catch (error) {
this.logger.error(error, 'Failed to destroy whatsapp client');
const browser = this.whatsapp?.pupBrowser;
if (browser) {
await this.kill(browser);
}
}
try {
@@ -530,6 +555,33 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
}
}
/**
* Force-kill the Chromium process when the graceful destroy() failed or timed out.
* Throws if the browser can not be proven dead - the session is not fully stopped then.
*/
private async kill(browser: Browser) {
// shadows the Node.js global 'process' on purpose - the global one is not used here
const process = browser.process();
if (!process) {
// remote or attached browser - we have no process handle to kill
throw new Error(
'No browser process to kill, can not guarantee the browser is closed',
);
}
if (process.exitCode !== null || process.signalCode !== null) {
return;
}
this.logger.warn('Force killing the browser process');
const exited = new Promise<void>((resolve) =>
process.once('exit', () => resolve()),
);
process.kill('SIGKILL');
// the session is stopped only when the browser process is really dead - verify it, do not assume
await promiseTimeout(5_000, exited).catch(() => {
throw new Error('The browser process did not exit after SIGKILL');
});
}
getSessionMeInfo(): MeInfo | null {
const clientInfo = this.whatsapp?.info;
if (!clientInfo) {
@@ -541,6 +593,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
lid: GetSerialized(clientInfo.lid),
pushName: clientInfo?.pushname,
reachoutTimelock: this.reachoutTimelock.value,
messageCapping: this.messageCapping.value,
};
}
@@ -556,27 +609,52 @@ 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 });
}
private startWhatsNewModalJob() {
// The "What's New" modal can block app init right after QR login, so the session never leaves STARTING
// https://github.com/devlikeapro/waha/issues/2217
this.whatsNewModalJob.start(async () => {
let hidden = false;
try {
hidden = await this.whatsapp.hideWhatsNewModal();
} catch (err) {
// The page navigates during login, evaluate() can fail - retry on the next tick
this.logger.debug(`Failed to hide "What's New" modal: ${err}`);
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,
});
if (hidden) {
this.logger.info(`"What's New" modal has been dismissed`);
return;
}
// The cool-off is stored user-scoped, so keep re-checking until it holds after login
if (this.status === WAHASessionStatus.WORKING) {
this.whatsNewModalJob.stop();
}
});
}
protected listenConnectionEvents() {
this.whatsapp.on(Events.REACHOUT_TIMELOCK_UPDATE, (record: any) => {
this.updateReachoutTimelockFromRecord(record);
});
this.whatsapp.on(Events.MESSAGE_CAPPING_UPDATE, (record: any) => {
// Unlike the timelock, a null record means "no local data yet", not "capping lifted"
if (!record) {
return;
}
this.messageCapping.update(parseMessageCapping(record));
});
this.whatsapp.on(Events.READY, async () => {
// Ask WhatsApp for the current message capping state, the same fetch the app runs on startup
try {
const data = await this.whatsapp.fetchMessageCapping();
if (data) {
this.messageCapping.update(parseMessageCapping(data));
}
} catch (err) {
this.logger.warn(err, 'Failed to fetch message capping');
}
});
this.whatsapp.on(Events.QR_RECEIVED, async (qr) => {
@@ -812,6 +890,63 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
return await this.whatsapp.deleteProfilePicture();
}
@Activity()
public async fetchMessageCapping(): Promise<MessageCappingData> {
const data = await this.whatsapp.fetchMessageCapping();
if (!data) {
throw new NotImplementedByEngineError(
'Message capping is not available in the current WhatsApp Web version',
);
}
const capping = parseMessageCapping(data);
// Keep the tracker in sync so MeInfo and 'session.status' reflect the fetch
this.messageCapping.update(capping);
return capping;
}
@Activity()
public async fetchReachoutTimelock(): Promise<ReachoutTimelockData> {
const record = await this.whatsapp.fetchReachoutTimelock();
if (record === undefined) {
throw new NotImplementedByEngineError(
'Reachout timelock is not available in the current WhatsApp Web version',
);
}
return this.updateReachoutTimelockFromRecord(record);
}
// Applies the stored 'WAReachoutTimelockState' record (or its removal) to the tracker,
// so MeInfo and 'session.status' reflect it, and returns the resulting state
private updateReachoutTimelockFromRecord(record: any): ReachoutTimelockData {
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 (
this.reachoutTimelock.value ?? {
enforcementType: ReachoutTimelockEnforcementType.DEFAULT,
isActive: false,
timeEnforcementEnds: null,
}
);
}
const enforcementType =
record.enforcement_type ?? ReachoutTimelockEnforcementType.DEFAULT;
const timelock: ReachoutTimelockData = {
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.reachoutTimelock.update(timelock);
return timelock;
}
/**
* Groups methods
*/
@@ -1032,6 +1167,10 @@ export class WhatsappSessionWebJSCore extends WhatsappSession {
const extra: any = {
type: 'buttons_response',
kind: 'buttonsResponse',
// WAWebGenerateButtonsResponseMessageProto reads the top-level field; nested buttonsResponse is legacy-gated
// behind the "wa_web_buttons_response_prop_removal_killswitch" ABProp (default off).
// Keep both like the native WAWebSendButtonsMsgReplyChatAction does
selectedButtonId: request.selectedButtonID,
buttonsResponse: {
selectedButtonId: request.selectedButtonID,
selectedDisplayText: request.selectedDisplayText,
+2 -2
View File
@@ -1,4 +1,4 @@
import { toCusFormat } from '@waha/core/utils/jids';
import { isPnUser, toCusFormat } from '@waha/core/utils/jids';
import { SerializeMsgKey } from '@waha/core/utils/ids';
import { WAMessageReaction } from '@waha/structures/responses.dto';
import { WAHAPresenceStatus } from '@waha/structures/enums.dto';
@@ -109,7 +109,7 @@ export function WppParticipantsToGroupV2Participants(
const group: GroupId = { id: toCusFormat(data.groupId) };
const participants: GroupParticipant[] = data.who.map((id) => ({
id: id,
pn: null,
pn: isPnUser(id) ? id : null,
role: role,
}));
+3 -2
View File
@@ -1513,14 +1513,15 @@ export class WhatsappSessionWPPCore extends WhatsappSession {
}
@Activity()
public promoteParticipantsToAdmin(
public async promoteParticipantsToAdmin(
id: string,
request: ParticipantsRequest,
): Promise<boolean> {
const participants = request.participants.map((participant) =>
this.ensureSuffix(participant.id),
);
return this.wpp!.promoteParticipant(this.ensureSuffix(id), participants);
await this.wpp!.promoteParticipant(this.ensureSuffix(id), participants);
return true;
}
@Activity()
+2 -1
View File
@@ -367,7 +367,8 @@ export class SessionManagerCore
} else if (this.EngineClass === WhatsappSessionGoWSCore) {
sessionConfig.engineConfig = this.gowsConfigService.getConfig();
} else if (this.EngineClass === WhatsappSessionNoWebCore) {
sessionConfig.engineConfig = this.nowebEngineConfigService.getConfig();
sessionConfig.engineConfig =
await this.nowebEngineConfigService.getConfig();
}
// @ts-ignore
const session = new this.EngineClass(sessionConfig);
+1
View File
@@ -14,6 +14,7 @@ interface Message {
interface File {
extension: string;
filename?: string;
mimetype?: string;
}
export interface MediaData {
+1
View File
@@ -75,6 +75,7 @@ export class MediaManager implements IMediaManager {
file: {
extension: extension,
filename: filename,
mimetype: mimetype,
},
};
+1
View File
@@ -40,6 +40,7 @@ export class MediaS3Storage implements IMediaStorage {
Bucket: this.bucket,
Key: key,
Body: buffer,
ContentType: data.file.mimetype,
Metadata: metadata,
});
await this.client.send(command);
+2 -2
View File
@@ -43,11 +43,11 @@ process.on('unhandledRejection', (reason, promise) => {
});
logger.info('NODE - Catching unhandled rejections and exceptions enabled');
process.on('SIGINT', () => {
process.once('SIGINT', () => {
logger.info('SIGINT received');
});
process.on('SIGTERM', () => {
process.once('SIGTERM', () => {
logger.info('SIGTERM received');
});
+8
View File
@@ -422,6 +422,14 @@ export class WANumberExistResult {
'Chat id for the phone number. Undefined if the number does not exist',
})
chatId?: string;
@ApiProperty({
description:
'Phone number id (@c.us). Can be null when only a @lid chatId is known',
example: '11111111111@c.us',
required: false,
})
pn?: string;
}
export class MessagePoll {
+100
View File
@@ -151,6 +151,30 @@ export class GowsStorageConfig {
@IsBoolean()
@IsOptional()
labels?: boolean | null;
@ApiProperty({
description:
'Store contacts locally. Set to false to disable; omit or null to keep enabled. ' +
'When disabled: contacts API returns no data, no contact names in chats, ' +
'no PushName/BusinessName events, and sending status to all contacts does not work.',
required: false,
example: true,
})
@IsBoolean()
@IsOptional()
contacts?: boolean | null;
@ApiProperty({
description:
'Store message secrets locally. Set to false to disable; omit or null to keep enabled. ' +
'When disabled: incoming poll votes, event responses and bot messages can not be decrypted, ' +
'and sending own poll votes does not work.',
required: false,
example: true,
})
@IsBoolean()
@IsOptional()
messageSecrets?: boolean | null;
}
export class GowsConfig {
@@ -295,6 +319,8 @@ export class SessionConfig {
groups: true,
chats: true,
labels: true,
contacts: true,
messageSecrets: true,
},
},
})
@@ -375,6 +401,71 @@ export class ReachoutTimelockData {
timeEnforcementEnds: number | null;
}
/**
* Capping status for the per-cycle new-chat message quota.
* WhatsApp may introduce new values at any time - treat it as an open set.
*/
export enum MessageCappingStatus {
NONE = 'NONE',
FIRST_WARNING = 'FIRST_WARNING',
SECOND_WARNING = 'SECOND_WARNING',
CAPPED = 'CAPPED',
}
export class MessageCappingData {
@ApiProperty({
example: MessageCappingStatus.FIRST_WARNING,
enum: MessageCappingStatus,
description:
'How close the account is to its new-chat quota. ' +
'CAPPED means new chats are blocked. WhatsApp may introduce new values, ' +
'so treat it as an open set.',
})
cappingStatus: MessageCappingStatus;
@ApiProperty({
example: 1000,
description:
'New-chat messages allowed in the current cycle. -1 when the account ' +
'has no cap.',
})
totalQuota: number;
@ApiProperty({
example: 640,
description: 'New-chat messages already used in the current cycle.',
})
usedQuota: number;
@ApiProperty({
example: 1782874800,
nullable: true,
description: 'Unix timestamp (seconds) when the current cycle started.',
})
cycleStart: number | null;
@ApiProperty({
example: 1785553199,
nullable: true,
description: 'Unix timestamp (seconds) when the current cycle ends.',
})
cycleEnd: number | null;
@ApiProperty({
example: 'NOT_ELIGIBLE',
nullable: true,
description: 'Meta Verified status. Informational.',
})
mvStatus: string | null;
@ApiProperty({
example: 'NOT_ELIGIBLE',
nullable: true,
description: 'One-time engagement status. Informational.',
})
oteStatus: string | null;
}
export class MeInfo {
@ChatIdProperty()
id: string;
@@ -400,6 +491,15 @@ export class MeInfo {
'Null if no enforcement has been seen for the account.',
})
reachoutTimelock?: ReachoutTimelockData | null;
@ApiProperty({
required: false,
nullable: true,
description:
'WhatsApp new-chat message capping (per-cycle quota) info. ' +
'Null until the capping state has been fetched for the account.',
})
messageCapping?: MessageCappingData | null;
}
export class SessionInfo extends SessionDTO {
+2
View File
@@ -20,6 +20,8 @@ export const promiseTimeout = function (
ms: number,
promise: Promise<any>,
): Promise<any> {
// the promise may reject after the timeout already won the race - do not treat it as unhandled
promise?.catch?.(() => undefined);
let timer: NodeJS.Timeout;
return Promise.race([
promise,
+1 -1
View File
@@ -48,7 +48,7 @@ function getPlatform() {
}
export const VERSION: WAHAEnvironment = {
version: '2026.7.2',
version: '2026.8.1',
engine: getEngineName(),
tier: getWAHAVersion(),
browser: getBrowser(),
+5
View File
@@ -16,5 +16,10 @@
"incremental": true,
"resolveJsonModule": true,
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "src/dashboard"],
"watchOptions": {
"excludeDirectories": ["**/node_modules", "src/dashboard"]
}
}
+2 -2
View File
@@ -2,11 +2,11 @@
"waha": {
"gows": {
"repo": "devlikeapro/gows-plus",
"ref": "v1.0.44"
"ref": "v1.0.45"
},
"dashboard": {
"repo": "devlikeapro/dashboard",
"ref": "d670e01a3776d94842c230f94fab8ebce833f8d5"
"ref": "07f9a7b79c2f0db7513005b074824424e6ca1a67"
}
}
}
+455 -253
View File
File diff suppressed because it is too large. Load diff