Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ named where one exists.

**Messages and privacy**
- A `getMessage` miss or failed lookup answers nothing, so Baileys no longer relays an empty message on a retry and uses it up (#2705, and #2706 for groups; fixes also offered in #2728 and #2623).
- A message Baileys cannot decrypt (its CIPHERTEXT stub: no session, Bad MAC, an invalid PreKey, a message absent from the node) is still not a `messages.upsert`, but it is no longer only a WARN line: it reaches the consumer as `messages.undecryptable` (subscribed as `MESSAGES_UNDECRYPTABLE`, in all seven transports and the global configurations) with the key (a DM's @lid swapped as on `messages.upsert`), the push name, the time, the chat's kind and the reason Evolution recognised (`other` for a CIPHERTEXT stub whose error it does not list), never a body or the error's own text. Once per message (chat, direction and id), not once per failed retry: a day's memory, in the instance cache, so with the cache off, or Redis failing, a retry posts again, and a post whose delivery ran out of webhook retries is not posted again for that day; never for a peer message (the owner's own devices, not a chat message) or a group the instance ignores. A readable copy that Baileys' retry receipt brings later is a `messages.upsert` with the same key id, as before.
- The fork's own log lines at `LOG_LEVEL=ERROR,WARN`, and Baileys' error logs, carry bounded fields, not message text, phone numbers, JIDs or push names; an exception's message is kept only scrubbed of URLs, JIDs, quoted parts and phone numbers (also as a person writes them). This is not a guarantee for every line: inherited code still logs some raw errors (integrations such as S3, Chatwoot and the chatbots are not covered by the tests), and a name inside an exception's unquoted words is not recognisable.
- libsignal's console lines about a Signal session ("Closing session:", "Removing old closed session:", "Session already closed"...) print the session as `[signal session]`, never the object, whose dump held its private ratchet key, root key, chain keys and the contact's identity key; and they print only when LOG_LEVEL has their level (they are console.info, so not at `ERROR,WARN`). They bypassed LOG_LEVEL and reached stdout every few seconds on a busy account. The one-line "Closing open session in favor of incoming prekey bundle" still prints. Other console calls, Evolution's logger included, are untouched.
- The `messages.upsert` webhook key of a DM WhatsApp addresses by @lid shows the phone JID as `remoteJid`, as 2.3.7 does, but keeps the original @lid in `remoteJidAlt` (2.3.7 copied the phone there too and lost it), with `addressingMode: 'pn'`: upstream develop's swap.
Expand Down
36 changes: 34 additions & 2 deletions src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1874,10 +1874,42 @@ export class BaileysStartupService extends ChannelStartupService {
'No session found to decrypt message',
'Message absent from node',
].find((err) => received?.messageStubParameters?.some?.((param) => param?.includes?.(err)));
if (decryptFailure) {
// Any CIPHERTEXT stub is a message Baileys could not decrypt, whatever its error says
// (libsignal has more than the list above, "Missing SignedPreKey" among them).
const undecrypted =
!received?.message && received?.messageStubType === proto.WebMessageInfo.StubType.CIPHERTEXT;
if (decryptFailure || undecrypted) {
const reason = decryptFailure ?? 'other';
this.logger.warn(
`Message ignored with messageStubParameters: id=${received.key?.id}, chat=${jidKind(received.key?.remoteJid)}, reason=${decryptFailure}`,
`Message ignored with messageStubParameters: id=${received.key?.id}, chat=${jidKind(received.key?.remoteJid)}, reason=${reason}`,
);
// Not a messages.upsert, which would store and answer it, but a trace a consumer can keep:
// who, where, when and why, never a body or the error's own text
// (test/handlers/undecryptable-message.test.ts). Not for a peer message (the owner's devices
// talking to each other, never a chat message), nor in a group the instance ignores, where
// the readable copy would be dropped too; and once per message, not once per failed retry.
const group = received.key?.remoteJid?.includes('@g.us');
if ((received as any).category !== 'peer' && !(settings?.groupsIgnore && group)) {
const traced = `undecryptable_${this.instanceId}_${received.key?.remoteJid}_${received.key?.fromMe ? 1 : 0}_${received.key?.id}`;
if (!(await this.baileysCache.get(traced))) {
await this.baileysCache.set(traced, true, 60 * 60 * 24);
const key = { ...received.key };
if (key.remoteJid?.includes('@lid') && key.remoteJidAlt) {
// The same swap as messages.upsert, so the readable copy a retry brings lands in the same chat.
[key.remoteJid, key.remoteJidAlt] = [key.remoteJidAlt, key.remoteJid];
key.addressingMode = 'pn';
}
this.sendDataWebhook(Events.MESSAGES_UNDECRYPTABLE, {
key,
pushName: received.pushName,
messageTimestamp: Long.isLong(received.messageTimestamp)
? received.messageTimestamp.toNumber()
: received.messageTimestamp,
chatKind: jidKind(key.remoteJid),
reason,
});
}
}
continue;
}
if (received.message?.conversation || received.message?.extendedTextMessage?.text) {
Expand Down
1 change: 1 addition & 0 deletions src/api/integrations/event/event.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export class EventController {
'MESSAGES_SET',
'MESSAGES_UPSERT',
'MESSAGES_EDITED',
'MESSAGES_UNDECRYPTABLE',
'MESSAGES_UPDATE',
'MESSAGES_DELETE',
'SEND_MESSAGE',
Expand Down
1 change: 1 addition & 0 deletions src/api/types/wa.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export enum Events {
MESSAGES_SET = 'messages.set',
MESSAGES_UPSERT = 'messages.upsert',
MESSAGES_EDITED = 'messages.edited',
MESSAGES_UNDECRYPTABLE = 'messages.undecryptable',
MESSAGES_UPDATE = 'messages.update',
MESSAGES_DELETE = 'messages.delete',
SEND_MESSAGE = 'send.message',
Expand Down
10 changes: 10 additions & 0 deletions src/config/env.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export type EventsRabbitmq = {
MESSAGES_SET: boolean;
MESSAGES_UPSERT: boolean;
MESSAGES_EDITED: boolean;
MESSAGES_UNDECRYPTABLE: boolean;
MESSAGES_UPDATE: boolean;
MESSAGES_DELETE: boolean;
SEND_MESSAGE: boolean;
Expand Down Expand Up @@ -141,6 +142,7 @@ export type Sqs = {
LOGOUT_INSTANCE: boolean;
MESSAGES_DELETE: boolean;
MESSAGES_EDITED: boolean;
MESSAGES_UNDECRYPTABLE: boolean;
MESSAGES_SET: boolean;
MESSAGES_UPDATE: boolean;
MESSAGES_UPSERT: boolean;
Expand Down Expand Up @@ -202,6 +204,7 @@ export type EventsWebhook = {
MESSAGES_SET: boolean;
MESSAGES_UPSERT: boolean;
MESSAGES_EDITED: boolean;
MESSAGES_UNDECRYPTABLE: boolean;
MESSAGES_UPDATE: boolean;
MESSAGES_DELETE: boolean;
SEND_MESSAGE: boolean;
Expand Down Expand Up @@ -235,6 +238,7 @@ export type EventsPusher = {
MESSAGES_SET: boolean;
MESSAGES_UPSERT: boolean;
MESSAGES_EDITED: boolean;
MESSAGES_UNDECRYPTABLE: boolean;
MESSAGES_UPDATE: boolean;
MESSAGES_DELETE: boolean;
SEND_MESSAGE: boolean;
Expand Down Expand Up @@ -516,6 +520,7 @@ export class ConfigService {
MESSAGES_SET: process.env?.RABBITMQ_EVENTS_MESSAGES_SET === 'true',
MESSAGES_UPSERT: process.env?.RABBITMQ_EVENTS_MESSAGES_UPSERT === 'true',
MESSAGES_EDITED: process.env?.RABBITMQ_EVENTS_MESSAGES_EDITED === 'true',
MESSAGES_UNDECRYPTABLE: process.env?.RABBITMQ_EVENTS_MESSAGES_UNDECRYPTABLE === 'true',
MESSAGES_UPDATE: process.env?.RABBITMQ_EVENTS_MESSAGES_UPDATE === 'true',
MESSAGES_DELETE: process.env?.RABBITMQ_EVENTS_MESSAGES_DELETE === 'true',
SEND_MESSAGE: process.env?.RABBITMQ_EVENTS_SEND_MESSAGE === 'true',
Expand Down Expand Up @@ -553,6 +558,7 @@ export class ConfigService {
MESSAGES_SET: process.env?.NATS_EVENTS_MESSAGES_SET === 'true',
MESSAGES_UPSERT: process.env?.NATS_EVENTS_MESSAGES_UPSERT === 'true',
MESSAGES_EDITED: process.env?.NATS_EVENTS_MESSAGES_EDITED === 'true',
MESSAGES_UNDECRYPTABLE: process.env?.NATS_EVENTS_MESSAGES_UNDECRYPTABLE === 'true',
MESSAGES_UPDATE: process.env?.NATS_EVENTS_MESSAGES_UPDATE === 'true',
MESSAGES_DELETE: process.env?.NATS_EVENTS_MESSAGES_DELETE === 'true',
SEND_MESSAGE: process.env?.NATS_EVENTS_SEND_MESSAGE === 'true',
Expand Down Expand Up @@ -605,6 +611,7 @@ export class ConfigService {
LOGOUT_INSTANCE: process.env?.SQS_GLOBAL_LOGOUT_INSTANCE === 'true',
MESSAGES_DELETE: process.env?.SQS_GLOBAL_MESSAGES_DELETE === 'true',
MESSAGES_EDITED: process.env?.SQS_GLOBAL_MESSAGES_EDITED === 'true',
MESSAGES_UNDECRYPTABLE: process.env?.SQS_GLOBAL_MESSAGES_UNDECRYPTABLE === 'true',
MESSAGES_SET: process.env?.SQS_GLOBAL_MESSAGES_SET === 'true',
MESSAGES_UPDATE: process.env?.SQS_GLOBAL_MESSAGES_UPDATE === 'true',
MESSAGES_UPSERT: process.env?.SQS_GLOBAL_MESSAGES_UPSERT === 'true',
Expand Down Expand Up @@ -636,6 +643,7 @@ export class ConfigService {
MESSAGES_SET: process.env?.KAFKA_EVENTS_MESSAGES_SET === 'true',
MESSAGES_UPSERT: process.env?.KAFKA_EVENTS_MESSAGES_UPSERT === 'true',
MESSAGES_EDITED: process.env?.KAFKA_EVENTS_MESSAGES_EDITED === 'true',
MESSAGES_UNDECRYPTABLE: process.env?.KAFKA_EVENTS_MESSAGES_UNDECRYPTABLE === 'true',
MESSAGES_UPDATE: process.env?.KAFKA_EVENTS_MESSAGES_UPDATE === 'true',
MESSAGES_DELETE: process.env?.KAFKA_EVENTS_MESSAGES_DELETE === 'true',
SEND_MESSAGE: process.env?.KAFKA_EVENTS_SEND_MESSAGE === 'true',
Expand Down Expand Up @@ -701,6 +709,7 @@ export class ConfigService {
MESSAGES_SET: process.env?.PUSHER_EVENTS_MESSAGES_SET === 'true',
MESSAGES_UPSERT: process.env?.PUSHER_EVENTS_MESSAGES_UPSERT === 'true',
MESSAGES_EDITED: process.env?.PUSHER_EVENTS_MESSAGES_EDITED === 'true',
MESSAGES_UNDECRYPTABLE: process.env?.PUSHER_EVENTS_MESSAGES_UNDECRYPTABLE === 'true',
MESSAGES_UPDATE: process.env?.PUSHER_EVENTS_MESSAGES_UPDATE === 'true',
MESSAGES_DELETE: process.env?.PUSHER_EVENTS_MESSAGES_DELETE === 'true',
SEND_MESSAGE: process.env?.PUSHER_EVENTS_SEND_MESSAGE === 'true',
Expand Down Expand Up @@ -758,6 +767,7 @@ export class ConfigService {
MESSAGES_SET: process.env?.WEBHOOK_EVENTS_MESSAGES_SET === 'true',
MESSAGES_UPSERT: process.env?.WEBHOOK_EVENTS_MESSAGES_UPSERT === 'true',
MESSAGES_EDITED: process.env?.WEBHOOK_EVENTS_MESSAGES_EDITED === 'true',
MESSAGES_UNDECRYPTABLE: process.env?.WEBHOOK_EVENTS_MESSAGES_UNDECRYPTABLE === 'true',
MESSAGES_UPDATE: process.env?.WEBHOOK_EVENTS_MESSAGES_UPDATE === 'true',
MESSAGES_DELETE: process.env?.WEBHOOK_EVENTS_MESSAGES_DELETE === 'true',
SEND_MESSAGE: process.env?.WEBHOOK_EVENTS_SEND_MESSAGE === 'true',
Expand Down
4 changes: 4 additions & 0 deletions src/validate/instance.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export const instanceSchema: JSONSchema7 = {
'MESSAGES_SET',
'MESSAGES_UPSERT',
'MESSAGES_EDITED',
'MESSAGES_UNDECRYPTABLE',
'MESSAGES_UPDATE',
'MESSAGES_DELETE',
'SEND_MESSAGE',
Expand Down Expand Up @@ -102,6 +103,7 @@ export const instanceSchema: JSONSchema7 = {
'MESSAGES_SET',
'MESSAGES_UPSERT',
'MESSAGES_EDITED',
'MESSAGES_UNDECRYPTABLE',
'MESSAGES_UPDATE',
'MESSAGES_DELETE',
'SEND_MESSAGE',
Expand Down Expand Up @@ -139,6 +141,7 @@ export const instanceSchema: JSONSchema7 = {
'MESSAGES_SET',
'MESSAGES_UPSERT',
'MESSAGES_EDITED',
'MESSAGES_UNDECRYPTABLE',
'MESSAGES_UPDATE',
'MESSAGES_DELETE',
'SEND_MESSAGE',
Expand Down Expand Up @@ -176,6 +179,7 @@ export const instanceSchema: JSONSchema7 = {
'MESSAGES_SET',
'MESSAGES_UPSERT',
'MESSAGES_EDITED',
'MESSAGES_UNDECRYPTABLE',
'MESSAGES_UPDATE',
'MESSAGES_DELETE',
'SEND_MESSAGE',
Expand Down
104 changes: 104 additions & 0 deletions test/events/undecryptable-webhook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// messages.undecryptable (test/handlers/undecryptable-message.test.ts) is an event
// of this fork's, so a consumer has to be able to ask for it: /webhook/set (and
// every /<transport>/set) validates the names it is given against
// EventController.events and refuses the whole request on one it does not know,
// and the global configurations are keyed by name. Subscribed, it is delivered;
// not subscribed, it is not, and no other subscription brings it.
//
// Through the real WebhookController, to a real HTTP destination on 127.0.0.1.
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';

import { validate } from 'jsonschema';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('@api/server.module', () => import('../helpers/fake-server-module'));

const DATA = {
key: { remoteJid: '120363000000000001@g.us', fromMe: false, id: 'AC2695000000000000000000000000AA' },
pushName: 'Sender',
messageTimestamp: 1761934500,
chatKind: 'group',
reason: 'No session found to decrypt message',
};

describe('a webhook subscribed to messages.undecryptable', () => {
const hits: { path: string; body: any }[] = [];
let base: string;
const server = createServer((req, res) => {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => (hits.push({ path: req.url ?? '', body: JSON.parse(body) }), res.end('ok')));
});

beforeAll(async () => {
server.listen(0, '127.0.0.1');
await new Promise((r) => server.once('listening', r));
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(() => new Promise((r) => server.close(r)));
beforeEach(() => void hits.splice(0));

async function emit(events: string[] | null, event: string, data: any = DATA) {
const { WebhookController } = await import('@api/integrations/event/webhook/webhook.controller');
const row = events && { enabled: true, events, url: `${base}/hook`, headers: {}, webhookBase64: false, webhookByEvents: false };
const prisma: any = { webhook: { findUnique: async () => row, findFirst: async () => row } };
const monitor: any = { waInstances: { test: { instanceId: 'inst-1' } } };
await new WebhookController(prisma, monitor).emit({
instanceName: 'test',
origin: 'test',
event,
data,
serverUrl: 'http://127.0.0.1',
dateTime: '2026-11-01T00:00:00.000Z',
sender: '972500000000@s.whatsapp.net',
apiKey: null,
local: true,
});
}

it('/webhook/set accepts MESSAGES_UNDECRYPTABLE beside the events it already took', async () => {
const { webhookSchema } = await import('@api/integrations/event/webhook/webhook.schema');
const body = { webhook: { enabled: true, url: `${base}/hook`, events: ['MESSAGES_UPSERT', 'MESSAGES_UNDECRYPTABLE'] } };
expect(validate(body, webhookSchema).errors).toEqual([]);
});

it('every /<transport>/set takes it: it is one of EventController.events', async () => {
const { EventController } = await import('@api/integrations/event/event.controller');
expect(EventController.events).toContain('MESSAGES_UNDECRYPTABLE');
});

it('per instance, subscribed, receives it as sent', async () => {
await emit(['MESSAGES_UNDECRYPTABLE'], 'messages.undecryptable');
expect(hits.map((h) => [h.path, h.body.event, h.body.instance])).toEqual([['/hook', 'messages.undecryptable', 'test']]);
expect(hits[0].body.data).toEqual(DATA);
});

it('per instance, a subscription to messages.upsert does not bring it', async () => {
await emit(['MESSAGES_UPSERT', 'MESSAGES_UPDATE'], 'messages.undecryptable');
expect(hits).toEqual([]);
});

it('global, enabled by WEBHOOK_EVENTS_MESSAGES_UNDECRYPTABLE, receives it', async () => {
const saved = process.env.WEBHOOK_EVENTS_MESSAGES_UNDECRYPTABLE;
process.env.WEBHOOK_EVENTS_MESSAGES_UNDECRYPTABLE = 'true';
const { ConfigService, configService } = await import('@config/env.config');
const fresh = new ConfigService().get<any>('WEBHOOK');
if (saved === undefined) delete process.env.WEBHOOK_EVENTS_MESSAGES_UNDECRYPTABLE;
else process.env.WEBHOOK_EVENTS_MESSAGES_UNDECRYPTABLE = saved;
expect(fresh.EVENTS.MESSAGES_UNDECRYPTABLE).toBe(true);

const webhook = configService.get<any>('WEBHOOK');
const before = structuredClone(webhook);
Object.assign(webhook.GLOBAL, { ENABLED: true, URL: `${base}/global`, WEBHOOK_BY_EVENTS: false });
webhook.EVENTS = Object.fromEntries(Object.keys(webhook.EVENTS).map((k) => [k, false]));
webhook.EVENTS.MESSAGES_UNDECRYPTABLE = fresh.EVENTS.MESSAGES_UNDECRYPTABLE;
try {
await emit(null, 'messages.undecryptable');
await emit(null, 'messages.upsert', { key: { id: 'X' } });
} finally {
Object.assign(webhook, before);
}
expect(hits.map((h) => [h.path, h.body.event])).toEqual([['/global', 'messages.undecryptable']]);
});
});
Loading
Loading