diff --git a/FORK.md b/FORK.md index f30e4dde31..045c7b67e7 100644 --- a/FORK.md +++ b/FORK.md @@ -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. diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 3a0740b182..66a76c9047 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -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) { diff --git a/src/api/integrations/event/event.controller.ts b/src/api/integrations/event/event.controller.ts index a714686a48..44def65bbb 100644 --- a/src/api/integrations/event/event.controller.ts +++ b/src/api/integrations/event/event.controller.ts @@ -155,6 +155,7 @@ export class EventController { 'MESSAGES_SET', 'MESSAGES_UPSERT', 'MESSAGES_EDITED', + 'MESSAGES_UNDECRYPTABLE', 'MESSAGES_UPDATE', 'MESSAGES_DELETE', 'SEND_MESSAGE', diff --git a/src/api/types/wa.types.ts b/src/api/types/wa.types.ts index 8f7c6a3906..8efb661fa2 100644 --- a/src/api/types/wa.types.ts +++ b/src/api/types/wa.types.ts @@ -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', diff --git a/src/config/env.config.ts b/src/config/env.config.ts index 7c4e382e7e..9779b669cf 100644 --- a/src/config/env.config.ts +++ b/src/config/env.config.ts @@ -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; @@ -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; @@ -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; @@ -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; @@ -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', @@ -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', @@ -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', @@ -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', @@ -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', @@ -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', diff --git a/src/validate/instance.schema.ts b/src/validate/instance.schema.ts index a0553b6667..28ef8708f6 100644 --- a/src/validate/instance.schema.ts +++ b/src/validate/instance.schema.ts @@ -65,6 +65,7 @@ export const instanceSchema: JSONSchema7 = { 'MESSAGES_SET', 'MESSAGES_UPSERT', 'MESSAGES_EDITED', + 'MESSAGES_UNDECRYPTABLE', 'MESSAGES_UPDATE', 'MESSAGES_DELETE', 'SEND_MESSAGE', @@ -102,6 +103,7 @@ export const instanceSchema: JSONSchema7 = { 'MESSAGES_SET', 'MESSAGES_UPSERT', 'MESSAGES_EDITED', + 'MESSAGES_UNDECRYPTABLE', 'MESSAGES_UPDATE', 'MESSAGES_DELETE', 'SEND_MESSAGE', @@ -139,6 +141,7 @@ export const instanceSchema: JSONSchema7 = { 'MESSAGES_SET', 'MESSAGES_UPSERT', 'MESSAGES_EDITED', + 'MESSAGES_UNDECRYPTABLE', 'MESSAGES_UPDATE', 'MESSAGES_DELETE', 'SEND_MESSAGE', @@ -176,6 +179,7 @@ export const instanceSchema: JSONSchema7 = { 'MESSAGES_SET', 'MESSAGES_UPSERT', 'MESSAGES_EDITED', + 'MESSAGES_UNDECRYPTABLE', 'MESSAGES_UPDATE', 'MESSAGES_DELETE', 'SEND_MESSAGE', diff --git a/test/events/undecryptable-webhook.test.ts b/test/events/undecryptable-webhook.test.ts new file mode 100644 index 0000000000..73761c6bcf --- /dev/null +++ b/test/events/undecryptable-webhook.test.ts @@ -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 //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 //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('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('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']]); + }); +}); diff --git a/test/handlers/undecryptable-message.test.ts b/test/handlers/undecryptable-message.test.ts new file mode 100644 index 0000000000..70acdb2a3d --- /dev/null +++ b/test/handlers/undecryptable-message.test.ts @@ -0,0 +1,178 @@ +// A message Baileys cannot decrypt (no Signal session with the sender, a Bad MAC, +// a group sender key never received) arrives in messages.upsert as a CIPHERTEXT +// stub: the key, the sender's push name and time, and the error in +// messageStubParameters, no content. Evolution 2.3.7 skips it with a WARN log +// and sends nothing, so a consumer has no trace that a message was there at all, +// and cannot tell a message that never came from one it was never able to read +// (Circles, 2026-10-29..31: four group messages, three never recovered). +// +// The stub now reaches the consumer as messages.undecryptable: who, where, when +// and why, never a body, and it is still not a messages.upsert. When the sender +// answers Baileys' retry receipt, the readable copy is a messages.upsert with the +// same key id, as before. +// +// The keys are built by Baileys' own decodeMessageNode from a message stanza, and +// the stub fields are set the way its decrypt() sets them on a failure. +import { vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +import { decodeMessageNode, proto } from 'baileys'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { deliver, makeService, WUID } from '../helpers/baileys-service'; +import { emitted } from '../helpers/fake-server-module'; +import type { Profile } from '../helpers/profiles'; + +const ME_LID = '100000000000001@lid'; +const LID = '123456789012345@lid'; +const PHONE = '972509876543@s.whatsapp.net'; +const GROUP = '120363000000000001@g.us'; + +/** What Baileys emits in messages.upsert when decrypt() failed with `error` for this stanza. */ +function undecryptable(attrs: Record, error: string) { + const { fullMessage } = decodeMessageNode({ tag: 'message', attrs, content: [] } as any, WUID, ME_LID); + return { + ...fullMessage, + messageStubType: proto.WebMessageInfo.StubType.CIPHERTEXT, + messageStubParameters: [error], + }; +} + +const groupStub = () => + undecryptable( + { + id: 'AC2695000000000000000000000000AA', + from: GROUP, + participant: LID, + participant_pn: PHONE, + addressing_mode: 'lid', + type: 'text', + t: '1761934500', + notify: 'Sender', + }, + 'No session found to decrypt message', + ); + +const lidDmStub = () => + undecryptable( + { id: '3EB0EEEEEEEEEEEEEEE1', from: LID, sender_pn: PHONE, addressing_mode: 'lid', type: 'text', t: '1761934600', notify: 'Sender' }, + 'Bad MAC', + ); + +async function deliveredEvents(profile: Profile, message: any, settings?: Record) { + const { service, ev, prisma } = await makeService({ profile }); + if (settings) await prisma.setting.create({ data: { instanceId: 'inst-1', ...settings } }); + await deliver(service, ev, { 'messages.upsert': { messages: [message], type: 'notify' } }); + return emitted.filter((e) => e.event.startsWith('messages.')); +} + +describe.each(['minimal', 'stored'])('a message Evolution cannot decrypt (%s)', (profile) => { + beforeEach(async () => { + emitted.splice(0); + // Which failures were already posted lives in the process-wide cache, as across reconnects. + const { LocalCache } = await import('@cache/localcache'); + LocalCache.localCache.flushAll(); + }); + + it('a group message reaches the webhook as messages.undecryptable: key, sender, time, chat kind and reason', async () => { + const events = await deliveredEvents(profile, groupStub()); + expect(events.map((e) => e.event)).toEqual(['messages.undecryptable']); + expect(events[0].data).toEqual({ + key: { + remoteJid: GROUP, + fromMe: false, + id: 'AC2695000000000000000000000000AA', + participant: LID, + participantAlt: PHONE, + addressingMode: 'lid', + }, + pushName: 'Sender', + messageTimestamp: 1761934500, + chatKind: 'group', + reason: 'No session found to decrypt message', + }); + }); + + it('a DM WhatsApp addresses by @lid shows the phone as remoteJid and the @lid in remoteJidAlt, as messages.upsert does', async () => { + const events = await deliveredEvents(profile, lidDmStub()); + expect(events.map((e) => e.event)).toEqual(['messages.undecryptable']); + expect(events[0].data).toEqual({ + key: { remoteJid: PHONE, remoteJidAlt: LID, fromMe: false, id: '3EB0EEEEEEEEEEEEEEE1', addressingMode: 'pn' }, + pushName: 'Sender', + messageTimestamp: 1761934600, + chatKind: 'user', + reason: 'Bad MAC', + }); + }); + + it('carries no body and no error text: only the reason Evolution recognised', async () => { + const stub = groupStub(); + stub.messageStubParameters = ['No session found to decrypt message for 972509876543@s.whatsapp.net "quoted"']; + const [event] = await deliveredEvents(profile, stub); + expect(Object.keys(event.data).sort()).toEqual(['chatKind', 'key', 'messageTimestamp', 'pushName', 'reason']); + expect(event.data.reason).toBe('No session found to decrypt message'); + expect(JSON.stringify(event.data)).not.toContain('quoted'); + }); + + it('the readable copy that a retry brings later is a messages.upsert with the same id', async () => { + const { service, ev } = await makeService({ profile }); + const stub = groupStub(); + await deliver(service, ev, { 'messages.upsert': { messages: [stub], type: 'notify' } }); + const { messageStubType, messageStubParameters, ...rest } = stub; + void messageStubType; + void messageStubParameters; + await deliver(service, ev, { 'messages.upsert': { messages: [{ ...rest, message: { conversation: 'hello' } }], type: 'notify' } }); + const events = emitted.filter((e) => e.event.startsWith('messages.')); + expect(events.map((e) => [e.event, e.data.key.id])).toEqual([ + ['messages.undecryptable', 'AC2695000000000000000000000000AA'], + ['messages.upsert', 'AC2695000000000000000000000000AA'], + ]); + // The same key, so a consumer finds the placeholder in the same chat. + expect(events[1].data.key).toEqual(events[0].data.key); + }); + + it('a DM by @lid: the readable copy comes with the same key as the trace', async () => { + const { service, ev } = await makeService({ profile }); + const stub = lidDmStub(); + await deliver(service, ev, { 'messages.upsert': { messages: [stub], type: 'notify' } }); + const { messageStubType, messageStubParameters, ...rest } = stub; + void messageStubType; + void messageStubParameters; + await deliver(service, ev, { 'messages.upsert': { messages: [{ ...rest, message: { conversation: 'hello' } }], type: 'notify' } }); + const [trace, upsert] = emitted.filter((e) => e.event.startsWith('messages.')); + expect([trace.event, upsert.event]).toEqual(['messages.undecryptable', 'messages.upsert']); + expect(upsert.data.key).toEqual(trace.data.key); + }); + + it('a failure Baileys retries is posted once, not once per failed retry', async () => { + const { service, ev } = await makeService({ profile }); + for (let i = 0; i < 3; i++) await deliver(service, ev, { 'messages.upsert': { messages: [groupStub()], type: 'notify' } }); + expect(emitted.filter((e) => e.event.startsWith('messages.')).map((e) => e.event)).toEqual(['messages.undecryptable']); + }); + + it("the owner's own message and someone else's with the same id in one chat are two failures, each posted", async () => { + const { service, ev } = await makeService({ profile }); + const theirs = groupStub(); + const mine = { ...groupStub(), key: { ...groupStub().key, fromMe: true } }; + await deliver(service, ev, { 'messages.upsert': { messages: [theirs], type: 'notify' } }); + await deliver(service, ev, { 'messages.upsert': { messages: [mine], type: 'notify' } }); + expect(emitted.filter((e) => e.event === 'messages.undecryptable').map((e) => e.data.key.fromMe)).toEqual([false, true]); + }); + + it('a decrypt error Evolution does not list is still posted, with reason other', async () => { + const stub = groupStub(); + stub.messageStubParameters = ['Missing SignedPreKey']; + const events = await deliveredEvents(profile, stub); + expect(events.map((e) => [e.event, e.data.reason])).toEqual([['messages.undecryptable', 'other']]); + }); + + it("a peer message (the owner's own devices talking, never a chat message) is not posted", async () => { + const stub = { ...groupStub(), category: 'peer' }; + expect(await deliveredEvents(profile, stub)).toEqual([]); + }); + + it('a group message is not posted on an instance that ignores groups, as its readable copy would not be', async () => { + expect(await deliveredEvents(profile, groupStub(), { groupsIgnore: true })).toEqual([]); + }); +});