diff --git a/src/message/primitives/__tests__/incoming.test.ts b/src/message/primitives/__tests__/incoming.test.ts index 5dd6b156..10e95689 100644 --- a/src/message/primitives/__tests__/incoming.test.ts +++ b/src/message/primitives/__tests__/incoming.test.ts @@ -207,6 +207,58 @@ test('1:1 message from my lid identity is detected as fromMe', async () => { assert.equal(key.remoteJid, '144400000000000@lid') }) +test('1:1 message from my hosted device is detected as fromMe', async () => { + const emitted: WaIncomingMessageEvent[] = [] + await handleIncomingMessageAck( + { + tag: 'message', + attrs: { + id: 'msg-self-hosted', + from: '133300000000000:99@hosted.lid', + recipient: '144400000000000@lid', + sender_pn: '5511999999999@s.whatsapp.net', + t: '123' + }, + content: [{ tag: 'enc', attrs: { type: 'msg' }, content: new Uint8Array([1]) }] + }, + createDecryptingOptions(emitted, { + getMeJid: () => '5511999999999@s.whatsapp.net', + getMeLid: () => '133300000000000@lid' + }) + ) + + assert.equal(emitted.length, 1) + const { key } = emitted[0] + assert.equal(key.fromMe, true) + assert.equal(key.remoteJid, '144400000000000@lid') + assert.equal(key.remoteJidAlt, undefined) +}) + +test('self-sent 1:1 message with an unresolved chat keeps the own number out of remoteJidAlt', async () => { + const emitted: WaIncomingMessageEvent[] = [] + await handleIncomingMessageAck( + { + tag: 'message', + attrs: { + id: 'msg-self-no-chat', + from: '133300000000000:99@hosted.lid', + sender_pn: '5511999999999@s.whatsapp.net', + t: '123' + }, + content: [{ tag: 'enc', attrs: { type: 'msg' }, content: new Uint8Array([1]) }] + }, + createDecryptingOptions(emitted, { + getMeJid: () => '5511999999999@s.whatsapp.net', + getMeLid: () => '133300000000000@lid' + }) + ) + + assert.equal(emitted.length, 1) + const { key } = emitted[0] + assert.equal(key.fromMe, true) + assert.equal(key.remoteJidAlt, undefined) +}) + test('1:1 incoming message from a peer stays fromMe false with the peer as remoteJid', async () => { const emitted: WaIncomingMessageEvent[] = [] await handleIncomingMessageAck( diff --git a/src/message/primitives/incoming.ts b/src/message/primitives/incoming.ts index 7b593031..3a87c339 100644 --- a/src/message/primitives/incoming.ts +++ b/src/message/primitives/incoming.ts @@ -88,7 +88,10 @@ type MessageKeyIdentity = Omit /** * Self-authored 1:1 chat is the recipient, so its alternate addressing is the * `recipient*` attrs (the `sender*` attrs describe me). Promotes `recipientAlt` - * to `remoteJidAlt` and drops the stale sender/recipient fields. + * to `remoteJidAlt` and drops the stale sender/recipient fields. Applied + * whenever the stanza is self-authored 1:1, even when the chat itself did not + * resolve: the sender attrs always describe the own account there, so letting + * them through would address the message to the connection's own number. */ function promoteRecipientAddressing(identity: MessageKeyIdentity): MessageKeyIdentity { return { @@ -116,13 +119,13 @@ function buildIncomingMessageKey( const fromMe = sender ? isOwnAccountJid(sender.userJid, options.getMeJid?.(), options.getMeLid?.()) : false - const selfSentChat = - fromMe && !isGroup && !isBroadcast - ? (node.attrs.recipient ?? destinationJid ?? undefined) - : undefined + const isSelfSentDirect = fromMe && !isGroup && !isBroadcast + const selfSentChat = isSelfSentDirect + ? (node.attrs.recipient ?? destinationJid ?? undefined) + : undefined const chatJid = selfSentChat ? toUserJid(selfSentChat) : fromUserJid const { pushName, ...identity } = extractMessageIdentityAttrs(node.attrs) - const keyIdentity = selfSentChat ? promoteRecipientAddressing(identity) : identity + const keyIdentity = isSelfSentDirect ? promoteRecipientAddressing(identity) : identity return { pushName, key: { diff --git a/src/protocol/__tests__/protocol.test.ts b/src/protocol/__tests__/protocol.test.ts index 3bb1d261..0d6d8e1b 100644 --- a/src/protocol/__tests__/protocol.test.ts +++ b/src/protocol/__tests__/protocol.test.ts @@ -155,6 +155,13 @@ test('jid type detection and device handling', () => { assert.equal(isOwnAccountJid('5599@s.whatsapp.net', '5511@s.whatsapp.net', '1330@lid'), false) assert.equal(isOwnAccountJid('5511@s.whatsapp.net', null, null), false) + // A hosted device of the account is the account (wa-web isSameAccountAndAddressingMode). + assert.equal(isOwnAccountJid('1330:99@hosted.lid', '5511@s.whatsapp.net', '1330@lid'), true) + assert.equal(isOwnAccountJid('5511:99@hosted', '5511@s.whatsapp.net', '1330@lid'), true) + assert.equal(isOwnAccountJid('1330:99@hosted.lid', '5511@s.whatsapp.net', null), false) + assert.equal(isOwnAccountJid('9999:99@hosted.lid', '5511@s.whatsapp.net', '1330@lid'), false) + assert.equal(isOwnAccountJid('1330@lid', '5511:99@hosted', '1330:99@hosted.lid'), true) + assert.equal(normalizeDeviceJid('5511:0@s.whatsapp.net'), '5511@s.whatsapp.net') assert.equal(normalizeDeviceJid('5511:5@s.whatsapp.net'), '5511:5@s.whatsapp.net') @@ -187,6 +194,13 @@ test('jid type detection and device handling', () => { assert.equal(isHostedDeviceJid('5511:99@hosted.lid'), true) assert.equal(isHostedDeviceJid('5511:99@lid'), true) assert.equal(isHostedDeviceJid('5511:1@lid'), false) + // Detected by server alone, and malformed shapes still rejected. + assert.equal(isHostedDeviceJid('5511@hosted'), true) + assert.equal(isHostedDeviceJid('5511@hosted.lid'), true) + assert.equal(isHostedDeviceJid('a@b@hosted'), false) + assert.equal(isHostedDeviceJid('@hosted'), false) + assert.equal(isHostedDeviceJid('5511@hostedd'), false) + assert.equal(isHostedDeviceJid('5511@hosted.li'), false) assert.equal( buildDeviceJid('6116570308623', 'lid', 99, { diff --git a/src/protocol/jid.ts b/src/protocol/jid.ts index db5fe99a..d464af84 100644 --- a/src/protocol/jid.ts +++ b/src/protocol/jid.ts @@ -211,6 +211,23 @@ export function canonicalizeSignalJid( return `${address.user}:${address.device}@${server}` } +/** + * Returns `true` when the server segment of `jid` starting at `from` is one of + * the hosted variants. Compares in place, so the common miss costs one length + * check and allocates nothing. Callers that already located the `@` pass its + * index rather than paying {@link isJidType}'s tail-anchored rescan. + */ +function isHostedServerAt(jid: string, from: number): boolean { + const length = jid.length - from + if (length === WA_DEFAULTS.HOSTED_SERVER.length) { + return jid.startsWith(WA_DEFAULTS.HOSTED_SERVER, from) + } + if (length === WA_DEFAULTS.HOSTED_LID_SERVER.length) { + return jid.startsWith(WA_DEFAULTS.HOSTED_LID_SERVER, from) + } + return false +} + /** * Strips the `:device` segment from a JID, returning the bare `user@server` * form. Set `options.canonicalizeSignalServer` to also rewrite hosted servers @@ -224,13 +241,17 @@ export function toUserJid( } = {} ): string { const canonicalize = options.canonicalizeSignalServer === true - if (!canonicalize) { - const atIndex = jid.indexOf('@') - if (atIndex >= 1 && atIndex < jid.length - 1) { - const colonIndex = jid.indexOf(':', 0) - if (colonIndex === -1 || colonIndex > atIndex) { - return jid - } + const atIndex = jid.indexOf('@') + if (atIndex >= 1 && atIndex < jid.length - 1) { + const colonIndex = jid.indexOf(':', 0) + // Canonicalization only rewrites the hosted servers, so a deviceless JID + // on any other server already is its own target form and can skip the + // parse (which would slice the user out and allocate an address). + if ( + (colonIndex === -1 || colonIndex > atIndex) && + (!canonicalize || !isHostedServerAt(jid, atIndex + 1)) + ) { + return jid } } const address = parseSignalAddressFromJid(jid) @@ -244,20 +265,25 @@ export function toUserJid( return `${address.user}@${server}` } +const CANONICAL_USER_JID_OPTIONS = Object.freeze({ canonicalizeSignalServer: true } as const) + /** * True when `jid` is the account's own user, matching the `meJid` (pn) or * `meLid` (lid) identity device-insensitively. Mirrors WhatsApp Web's - * `isMeAccount`. + * `isMeAccount`, including its addressing-mode equivalence: a hosted device of + * the account (`@hosted` / `@hosted.lid`) is the same account as + * `@s.whatsapp.net` / `@lid`, so both sides are canonicalized + * before comparison. */ export function isOwnAccountJid( jid: string, meJid: string | null | undefined, meLid: string | null | undefined ): boolean { - const candidateUser = toUserJid(jid) + const candidateUser = toUserJid(jid, CANONICAL_USER_JID_OPTIONS) return ( - (!!meJid && toUserJid(meJid) === candidateUser) || - (!!meLid && toUserJid(meLid) === candidateUser) + (!!meJid && toUserJid(meJid, CANONICAL_USER_JID_OPTIONS) === candidateUser) || + (!!meLid && toUserJid(meLid, CANONICAL_USER_JID_OPTIONS) === candidateUser) ) } @@ -328,14 +354,9 @@ export function isHostedServer(server: string): boolean { * (`@hosted` / `@hosted.lid`) or by a `:HOSTED_DEVICE_ID@…` device segment. */ export function isHostedDeviceJid(jid: string): boolean { - if ( - isJidType(jid, WA_DEFAULTS.HOSTED_SERVER) || - isJidType(jid, WA_DEFAULTS.HOSTED_LID_SERVER) - ) { - return true - } const atIndex = jid.indexOf('@') if (atIndex < 1 || atIndex >= jid.length - 1) return false + if (isHostedServerAt(jid, atIndex + 1)) return true const colonIndex = jid.indexOf(':') if (colonIndex < 0 || colonIndex >= atIndex - 1) return false let deviceId = 0