zapo-js 1.6.2 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/abprops-spec.d.ts +10 -0
- package/dist/abprops-spec.js +25 -0
- package/dist/client/WaClient.d.ts +11 -0
- package/dist/client/WaClient.js +60 -0
- package/dist/client/WaClientFactory.d.ts +2 -2
- package/dist/client/WaClientFactory.js +50 -12
- package/dist/client/coordinators/WaAbPropsCoordinator.d.ts +2 -2
- package/dist/client/coordinators/WaAbPropsCoordinator.js +16 -4
- package/dist/client/coordinators/WaIncomingNodeCoordinator.d.ts +2 -1
- package/dist/client/coordinators/WaIncomingNodeCoordinator.js +4 -0
- package/dist/client/coordinators/WaMessageCoordinator.d.ts +96 -0
- package/dist/client/coordinators/WaMessageCoordinator.js +163 -8
- package/dist/client/coordinators/WaMessageDispatchCoordinator.d.ts +64 -0
- package/dist/client/coordinators/WaMessageDispatchCoordinator.js +217 -30
- package/dist/client/coordinators/WaPrivacyCoordinator.d.ts +97 -19
- package/dist/client/coordinators/WaPrivacyCoordinator.js +152 -101
- package/dist/client/coordinators/WaRetryCoordinator.d.ts +22 -0
- package/dist/client/coordinators/WaRetryCoordinator.js +74 -11
- package/dist/client/events/dirty.d.ts +3 -0
- package/dist/client/events/dirty.js +12 -7
- package/dist/client/events/offline.d.ts +9 -0
- package/dist/client/events/offline.js +95 -0
- package/dist/client/events/privacy.d.ts +45 -0
- package/dist/client/events/privacy.js +130 -0
- package/dist/client/messaging/group-metadata.d.ts +12 -0
- package/dist/client/messaging/group-metadata.js +24 -8
- package/dist/client/messaging/link-preview.d.ts +7 -0
- package/dist/client/messaging/link-preview.js +27 -0
- package/dist/client/newsletter/content.d.ts +4 -1
- package/dist/client/newsletter/content.js +43 -18
- package/dist/client/newsletter/messaging.d.ts +2 -0
- package/dist/client/newsletter/messaging.js +2 -1
- package/dist/client/newsletter/mex.d.ts +2 -2
- package/dist/client/persistence/WriteBehindPersistence.js +2 -1
- package/dist/client/persistence/ephemeral-setting.d.ts +42 -0
- package/dist/client/persistence/ephemeral-setting.js +120 -0
- package/dist/client/persistence/group-history.d.ts +29 -0
- package/dist/client/persistence/group-history.js +149 -0
- package/dist/client/persistence/history-blob.d.ts +25 -0
- package/dist/client/persistence/history-blob.js +38 -0
- package/dist/client/persistence/history-sync.d.ts +2 -0
- package/dist/client/persistence/history-sync.js +31 -29
- package/dist/client/types.d.ts +150 -16
- package/dist/crypto/core/xeddsa.js +16 -0
- package/dist/crypto/curves/X25519.js +13 -0
- package/dist/crypto/nativeBackend.d.ts +20 -0
- package/dist/crypto/nativeBackend.js +95 -0
- package/dist/esm/abprops-spec.js +15 -0
- package/dist/esm/client/WaClient.js +60 -0
- package/dist/esm/client/WaClientFactory.js +51 -13
- package/dist/esm/client/coordinators/WaAbPropsCoordinator.js +17 -5
- package/dist/esm/client/coordinators/WaIncomingNodeCoordinator.js +4 -0
- package/dist/esm/client/coordinators/WaMessageCoordinator.js +166 -11
- package/dist/esm/client/coordinators/WaMessageDispatchCoordinator.js +218 -32
- package/dist/esm/client/coordinators/WaPrivacyCoordinator.js +145 -94
- package/dist/esm/client/coordinators/WaRetryCoordinator.js +74 -11
- package/dist/esm/client/events/dirty.js +12 -7
- package/dist/esm/client/events/offline.js +92 -0
- package/dist/esm/client/events/privacy.js +123 -0
- package/dist/esm/client/messaging/group-metadata.js +25 -9
- package/dist/esm/client/messaging/link-preview.js +29 -2
- package/dist/esm/client/newsletter/content.js +44 -19
- package/dist/esm/client/newsletter/messaging.js +2 -1
- package/dist/esm/client/persistence/WriteBehindPersistence.js +2 -1
- package/dist/esm/client/persistence/ephemeral-setting.js +116 -0
- package/dist/esm/client/persistence/group-history.js +145 -0
- package/dist/esm/client/persistence/history-blob.js +34 -0
- package/dist/esm/client/persistence/history-sync.js +24 -22
- package/dist/esm/crypto/core/xeddsa.js +17 -1
- package/dist/esm/crypto/curves/X25519.js +13 -0
- package/dist/esm/crypto/nativeBackend.js +93 -0
- package/dist/esm/index.js +3 -1
- package/dist/esm/message/context-info.js +39 -0
- package/dist/esm/message/encode/content.js +20 -4
- package/dist/esm/message/encode/media-payload.js +3 -1
- package/dist/esm/message/kinds/group-history.js +36 -0
- package/dist/esm/message/primitives/incoming.js +18 -6
- package/dist/esm/protocol/abprops.js +18 -162
- package/dist/esm/protocol/constants.js +4 -3
- package/dist/esm/protocol/defaults.js +6 -0
- package/dist/esm/protocol/media.js +1 -0
- package/dist/esm/protocol/message.js +8 -0
- package/dist/esm/protocol/notification.js +1 -0
- package/dist/esm/protocol/privacy.js +70 -4
- package/dist/esm/store/contracts/chat-metadata.store.js +1 -0
- package/dist/esm/store/createStore.js +19 -2
- package/dist/esm/store/index.js +1 -0
- package/dist/esm/store/memory/chat-metadata.store.js +66 -0
- package/dist/esm/store/noop.store.js +8 -0
- package/dist/esm/transport/node/builders/privacy.js +63 -4
- package/dist/esm/util/coercion.js +12 -4
- package/dist/esm/util/index.js +1 -1
- package/dist/index.d.ts +11 -6
- package/dist/index.js +15 -2
- package/dist/media/types.d.ts +1 -1
- package/dist/message/context-info.d.ts +10 -0
- package/dist/message/context-info.js +40 -0
- package/dist/message/encode/content.d.ts +7 -3
- package/dist/message/encode/content.js +20 -4
- package/dist/message/encode/media-payload.d.ts +5 -2
- package/dist/message/encode/media-payload.js +3 -1
- package/dist/message/kinds/group-history.d.ts +30 -0
- package/dist/message/kinds/group-history.js +40 -0
- package/dist/message/primitives/incoming.d.ts +5 -0
- package/dist/message/primitives/incoming.js +18 -6
- package/dist/protocol/abprops.d.ts +27 -140
- package/dist/protocol/abprops.js +18 -163
- package/dist/protocol/constants.d.ts +4 -3
- package/dist/protocol/constants.js +10 -3
- package/dist/protocol/defaults.d.ts +6 -0
- package/dist/protocol/defaults.js +6 -0
- package/dist/protocol/media.d.ts +1 -0
- package/dist/protocol/media.js +1 -0
- package/dist/protocol/message.d.ts +6 -0
- package/dist/protocol/message.js +9 -0
- package/dist/protocol/notification.d.ts +1 -0
- package/dist/protocol/notification.js +1 -0
- package/dist/protocol/privacy.d.ts +52 -0
- package/dist/protocol/privacy.js +71 -5
- package/dist/store/contracts/chat-metadata.store.d.ts +34 -0
- package/dist/store/contracts/chat-metadata.store.js +2 -0
- package/dist/store/contracts/group-metadata.store.d.ts +7 -0
- package/dist/store/contracts/thread.store.d.ts +6 -0
- package/dist/store/createStore.d.ts +10 -2
- package/dist/store/createStore.js +18 -1
- package/dist/store/index.d.ts +3 -1
- package/dist/store/index.js +3 -1
- package/dist/store/memory/chat-metadata.store.d.ts +26 -0
- package/dist/store/memory/chat-metadata.store.js +70 -0
- package/dist/store/noop.store.d.ts +2 -0
- package/dist/store/noop.store.js +9 -1
- package/dist/store/types.d.ts +190 -39
- package/dist/transport/node/builders/privacy.d.ts +26 -2
- package/dist/transport/node/builders/privacy.js +63 -3
- package/dist/util/coercion.d.ts +6 -0
- package/dist/util/coercion.js +13 -4
- package/dist/util/index.d.ts +1 -1
- package/dist/util/index.js +2 -1
- package/package.json +3 -1
- package/spec/abprops/index.d.ts +4402 -0
- package/spec/abprops/index.js +4372 -0
|
@@ -6,17 +6,69 @@ import { applyContextInfo, resolveSendContextInfo } from '../../message/context-
|
|
|
6
6
|
import { injectDeviceListMetadata, resolveIcdcMeta } from '../../message/crypto/icdc.js';
|
|
7
7
|
import { computePhashV2 } from '../../message/crypto/phash.js';
|
|
8
8
|
import { buildReportingTokenArtifacts } from '../../message/crypto/reporting-token.js';
|
|
9
|
-
import { isSendAddonCryptoMessage, isSendMediaMessage, isSendTextMessage, needsSecretPersistence, resolveButtonAddonKind, resolveDecryptFailAttr, resolveEditAttr, resolveEncMediaType, resolveMessageTypeAttr, resolveMetaAttrs, wrapAsViewOnce } from '../../message/encode/content.js';
|
|
9
|
+
import { isSendAddonCryptoMessage, isSendMediaMessage, isSendTextMessage, needsSecretPersistence, resolveButtonAddonKind, resolveDecryptFailAttr, resolveEditAttr, resolveEncMediaType, resolveMessageTypeAttr, resolveMetaAttrs, unwrapMessage, wrapAsViewOnce } from '../../message/encode/content.js';
|
|
10
10
|
import { wrapDeviceSentMessage } from '../../message/encode/device-sent.js';
|
|
11
11
|
import { writeRandomPadMax16 } from '../../message/encode/padding.js';
|
|
12
12
|
import { buildBotInvokeProtoCopy, extractInvokedBotJid, genBotMsgSecret } from '../../message/kinds/bot.js';
|
|
13
13
|
import { proto } from '../../proto.js';
|
|
14
|
-
import { WA_ADDRESSING_MODES, WA_DEFAULTS, WA_NACK_REASONS } from '../../protocol/constants.js';
|
|
14
|
+
import { normalizeEphemeralSettingSeconds, WA_ADDRESSING_MODES, WA_DEFAULTS, WA_NACK_REASONS } from '../../protocol/constants.js';
|
|
15
15
|
import { canonicalizeOwnAccountJid, isBotJid, isGroupJid, isHostedDeviceJid, isLidJid, isNewsletterJid, isUserJid, normalizeDeviceJid, normalizeRecipientJid, parseJidFull, parseSignalAddressFromJid, signalAddressKey, toUserJid } from '../../protocol/jid.js';
|
|
16
16
|
import { encodeBinaryNode } from '../../transport/binary/index.js';
|
|
17
17
|
import { buildButtonAddonNode, buildDirectMessageFanoutNode, buildGroupSenderKeyMessageNode, buildMetaNode } from '../../transport/node/builders/message.js';
|
|
18
18
|
import { bytesToHex, TEXT_ENCODER } from '../../util/bytes.js';
|
|
19
19
|
import { toError } from '../../util/primitives.js';
|
|
20
|
+
/**
|
|
21
|
+
* Members a group-history bundle is addressed to. Empty for every other
|
|
22
|
+
* message, which is what keeps the restricted fanout scoped to bundles.
|
|
23
|
+
*/
|
|
24
|
+
function resolveGroupHistoryReceivers(message) {
|
|
25
|
+
const metadata = unwrapMessage(message).messageHistoryBundle?.messageHistoryMetadata;
|
|
26
|
+
return metadata?.historyReceivers ?? [];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Narrows a group's participant list to the members a group-history bundle is
|
|
30
|
+
* addressed to, plus the sender so its own devices keep a copy. Receivers are
|
|
31
|
+
* intersected with the live participant list, so a stale receiver entry cannot
|
|
32
|
+
* pull a non-member into the fanout. Any other message keeps the full list.
|
|
33
|
+
*
|
|
34
|
+
* Receivers must be written in the group's addressing mode - a PN entry for a
|
|
35
|
+
* LID-addressed group does not match and is reported through `onDropped`.
|
|
36
|
+
*
|
|
37
|
+
* @throws when no receiver matches a current member. Sending to the sender's
|
|
38
|
+
* own devices alone counts as no match: it would look like a delivered share
|
|
39
|
+
* while nobody received anything.
|
|
40
|
+
*/
|
|
41
|
+
export function restrictGroupHistoryTargets(message, participantUserJids, senderJid, onDropped) {
|
|
42
|
+
const receivers = resolveGroupHistoryReceivers(message);
|
|
43
|
+
if (receivers.length === 0) {
|
|
44
|
+
return participantUserJids;
|
|
45
|
+
}
|
|
46
|
+
const pending = new Set();
|
|
47
|
+
for (let index = 0; index < receivers.length; index += 1) {
|
|
48
|
+
pending.add(toUserJid(receivers[index]));
|
|
49
|
+
}
|
|
50
|
+
const senderUserJid = toUserJid(senderJid);
|
|
51
|
+
const targets = [];
|
|
52
|
+
let matchedReceivers = 0;
|
|
53
|
+
for (let index = 0; index < participantUserJids.length; index += 1) {
|
|
54
|
+
const participant = participantUserJids[index];
|
|
55
|
+
const participantUserJid = toUserJid(participant);
|
|
56
|
+
if (pending.delete(participantUserJid)) {
|
|
57
|
+
matchedReceivers += 1;
|
|
58
|
+
targets[targets.length] = participant;
|
|
59
|
+
}
|
|
60
|
+
else if (participantUserJid === senderUserJid) {
|
|
61
|
+
targets[targets.length] = participant;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (pending.size > 0 && onDropped) {
|
|
65
|
+
onDropped([...pending], receivers.length);
|
|
66
|
+
}
|
|
67
|
+
if (matchedReceivers === 0) {
|
|
68
|
+
throw new Error('group history bundle resolved no receivers in the group');
|
|
69
|
+
}
|
|
70
|
+
return targets;
|
|
71
|
+
}
|
|
20
72
|
export class WaMessageDispatchCoordinator {
|
|
21
73
|
constructor(options) {
|
|
22
74
|
this.icdcDedup = new PromiseDedup();
|
|
@@ -26,6 +78,82 @@ export class WaMessageDispatchCoordinator {
|
|
|
26
78
|
this.mobileMessageIdFormat = options.mobileMessageIdFormat ?? (() => false);
|
|
27
79
|
this.serverClock = options.serverClock;
|
|
28
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Per-chat disappearing settings from the cache, falling back to the
|
|
83
|
+
* durable thread record on a cold miss and warming the cache with it. A
|
|
84
|
+
* chat with no disappearing mode is cached as expiration `0` rather than
|
|
85
|
+
* left absent, so the archive is not re-read on every send to it; the
|
|
86
|
+
* incoming writers overwrite that entry the moment the chat turns it on.
|
|
87
|
+
* A failing archive degrades the send to "no ephemeral fields" instead of
|
|
88
|
+
* rejecting it - the store is opt-in persistence, not a send dependency.
|
|
89
|
+
*/
|
|
90
|
+
async resolveChatMetadata(chatJid) {
|
|
91
|
+
try {
|
|
92
|
+
const cached = await this.deps.chatMetadataStore.getChatMetadata(chatJid);
|
|
93
|
+
if (cached) {
|
|
94
|
+
return cached;
|
|
95
|
+
}
|
|
96
|
+
const thread = await this.deps.threadStore.getByJid(chatJid);
|
|
97
|
+
const expiration = thread?.ephemeralExpiration ?? 0;
|
|
98
|
+
const settingTimestamp = expiration > 0 ? thread?.ephemeralSettingTimestamp : undefined;
|
|
99
|
+
const snapshot = {
|
|
100
|
+
chatJid,
|
|
101
|
+
ephemeralExpiration: expiration,
|
|
102
|
+
...(settingTimestamp !== undefined
|
|
103
|
+
? { ephemeralSettingTimestamp: settingTimestamp }
|
|
104
|
+
: {}),
|
|
105
|
+
updatedAtMs: Date.now()
|
|
106
|
+
};
|
|
107
|
+
await this.deps.chatMetadataStore.upsertChatMetadata(snapshot);
|
|
108
|
+
return snapshot;
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
this.deps.logger.debug('chat metadata lookup failed, sending without ephemeral fields', {
|
|
112
|
+
jid: chatJid,
|
|
113
|
+
message: toError(error).message
|
|
114
|
+
});
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Disappearing-message fields for a send into a chat with the mode on, or
|
|
120
|
+
* `{}` when nothing resolves. Both chat kinds report initiator
|
|
121
|
+
* `CHANGED_IN_CHAT` when nothing more specific is stored; the trigger comes
|
|
122
|
+
* from the group metadata for a group and defaults to `CHAT_SETTING` for
|
|
123
|
+
* 1:1, and is omitted when a group reports none.
|
|
124
|
+
*/
|
|
125
|
+
async resolveChatEphemeral(recipientJid) {
|
|
126
|
+
if (isGroupJid(recipientJid)) {
|
|
127
|
+
const settings = await this.deps.groupMetadataCache.resolveEphemeralSettings(recipientJid);
|
|
128
|
+
if (!settings) {
|
|
129
|
+
return {};
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
expirationSeconds: settings.expirationSeconds,
|
|
133
|
+
disappearingModeInitiator: proto.DisappearingMode.Initiator.CHANGED_IN_CHAT,
|
|
134
|
+
...(settings.trigger !== undefined
|
|
135
|
+
? {
|
|
136
|
+
disappearingModeTrigger: settings.trigger
|
|
137
|
+
}
|
|
138
|
+
: {})
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const chat = await this.resolveChatMetadata(recipientJid);
|
|
142
|
+
const expiration = chat?.ephemeralExpiration;
|
|
143
|
+
if (expiration === undefined || expiration <= 0) {
|
|
144
|
+
return {};
|
|
145
|
+
}
|
|
146
|
+
const stored = chat?.ephemeralSettingTimestamp;
|
|
147
|
+
const settingTimestamp = stored !== undefined ? normalizeEphemeralSettingSeconds(stored) : undefined;
|
|
148
|
+
return {
|
|
149
|
+
expirationSeconds: expiration,
|
|
150
|
+
disappearingModeInitiator: proto.DisappearingMode.Initiator.CHANGED_IN_CHAT,
|
|
151
|
+
disappearingModeTrigger: proto.DisappearingMode.Trigger.CHAT_SETTING,
|
|
152
|
+
...(settingTimestamp !== undefined
|
|
153
|
+
? { ephemeralSettingTimestamp: settingTimestamp }
|
|
154
|
+
: {})
|
|
155
|
+
};
|
|
156
|
+
}
|
|
29
157
|
async publishMessageNode(node, options = {}) {
|
|
30
158
|
this.deps.logger.trace('wa client publish message node', {
|
|
31
159
|
tag: node.tag,
|
|
@@ -146,17 +274,34 @@ export class WaMessageDispatchCoordinator {
|
|
|
146
274
|
this.withResolvedMessageId(options)
|
|
147
275
|
]);
|
|
148
276
|
}
|
|
277
|
+
const isGroupRecipient = isGroupJid(recipientJid);
|
|
278
|
+
const directRecipientJid = isGroupRecipient
|
|
279
|
+
? recipientJid
|
|
280
|
+
: await this.resolveDirectRecipientLid(toUserJid(recipientJid));
|
|
149
281
|
let optionsCtx = options.contextInfo;
|
|
150
282
|
if (options.expirationSeconds !== undefined) {
|
|
151
283
|
optionsCtx = { ...optionsCtx, expirationSeconds: options.expirationSeconds };
|
|
152
284
|
}
|
|
153
|
-
if (
|
|
154
|
-
optionsCtx
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
285
|
+
if (options.ephemeralSettingTimestamp !== undefined) {
|
|
286
|
+
optionsCtx = {
|
|
287
|
+
...optionsCtx,
|
|
288
|
+
ephemeralSettingTimestamp: options.ephemeralSettingTimestamp
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
if (options.disappearingModeTrigger !== undefined) {
|
|
292
|
+
optionsCtx = {
|
|
293
|
+
...optionsCtx,
|
|
294
|
+
disappearingModeTrigger: options.disappearingModeTrigger
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
const autoInjectDisabled = isGroupRecipient
|
|
298
|
+
? options.disableGroupEphemeralAutoInject
|
|
299
|
+
: options.disableDirectEphemeralAutoInject;
|
|
300
|
+
if (optionsCtx?.expirationSeconds === undefined && !autoInjectDisabled) {
|
|
301
|
+
optionsCtx = {
|
|
302
|
+
...(await this.resolveChatEphemeral(directRecipientJid)),
|
|
303
|
+
...optionsCtx
|
|
304
|
+
};
|
|
160
305
|
}
|
|
161
306
|
const ctx = resolveSendContextInfo({
|
|
162
307
|
contentLevel: pickContentContextInfo(content),
|
|
@@ -282,9 +427,6 @@ export class WaMessageDispatchCoordinator {
|
|
|
282
427
|
customNodes: customNodes.length > 0 ? customNodes : undefined,
|
|
283
428
|
sendOptions
|
|
284
429
|
};
|
|
285
|
-
const directRecipientJid = isGroup
|
|
286
|
-
? recipientJid
|
|
287
|
-
: await this.resolveDirectRecipientLid(toUserJid(recipientJid));
|
|
288
430
|
const peerRecipientPn = isGroup
|
|
289
431
|
? undefined
|
|
290
432
|
: await this.resolvePeerRecipientPn(toUserJid(recipientJid), directRecipientJid);
|
|
@@ -547,9 +689,59 @@ export class WaMessageDispatchCoordinator {
|
|
|
547
689
|
async mutateGroupMetadataCacheFromGroupEvent(event) {
|
|
548
690
|
await this.deps.groupMetadataCache.mutateFromGroupEvent(event);
|
|
549
691
|
}
|
|
550
|
-
|
|
692
|
+
/**
|
|
693
|
+
* Splits a group's members into the ones a history bundle is being shared
|
|
694
|
+
* with and the rest, normalizing everything to the group's addressing mode
|
|
695
|
+
* so the receiver list written into the proto matches what the fanout can
|
|
696
|
+
* actually resolve. This account is excluded from both lists.
|
|
697
|
+
*
|
|
698
|
+
* Requested JIDs that are not current members come back in `unknownJids`
|
|
699
|
+
* instead of being silently dropped - sharing history with a non-member is
|
|
700
|
+
* a caller mistake worth surfacing.
|
|
701
|
+
*/
|
|
702
|
+
async resolveGroupHistoryAudience(groupJid, requestedJids) {
|
|
703
|
+
const participants = await this.deps.groupMetadataCache.resolveParticipantUsers(groupJid);
|
|
704
|
+
const meJid = this.requireCurrentMeJid('shareGroupHistory');
|
|
705
|
+
const addressingMode = this.resolveGroupAddressingMode(participants, groupJid);
|
|
706
|
+
const meUserJid = toUserJid(this.resolveSenderForAddressingMode(addressingMode, meJid));
|
|
707
|
+
const pending = new Set();
|
|
708
|
+
for (let index = 0; index < requestedJids.length; index += 1) {
|
|
709
|
+
pending.add(toUserJid(normalizeRecipientJid(requestedJids[index])));
|
|
710
|
+
}
|
|
711
|
+
const historyReceivers = [];
|
|
712
|
+
const nonHistoryReceivers = [];
|
|
713
|
+
for (let index = 0; index < participants.length; index += 1) {
|
|
714
|
+
const participantUserJid = toUserJid(participants[index]);
|
|
715
|
+
if (participantUserJid === meUserJid) {
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
if (pending.delete(participantUserJid)) {
|
|
719
|
+
historyReceivers[historyReceivers.length] = participantUserJid;
|
|
720
|
+
}
|
|
721
|
+
else {
|
|
722
|
+
nonHistoryReceivers[nonHistoryReceivers.length] = participantUserJid;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
const requestedSelf = pending.delete(meUserJid);
|
|
726
|
+
const unknownJids = [];
|
|
727
|
+
for (const jid of pending) {
|
|
728
|
+
unknownJids[unknownJids.length] = jid;
|
|
729
|
+
}
|
|
730
|
+
return {
|
|
731
|
+
historyReceivers,
|
|
732
|
+
nonHistoryReceivers,
|
|
733
|
+
unknownJids,
|
|
734
|
+
requestedSelf,
|
|
735
|
+
addressingMode
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* A group-history bundle goes to the members named in `historyReceivers`
|
|
740
|
+
* rather than to the whole group, so it takes the per-device direct fanout
|
|
741
|
+
* instead of the group sender key.
|
|
742
|
+
*/
|
|
551
743
|
shouldUseGroupDirectPath(message) {
|
|
552
|
-
return
|
|
744
|
+
return resolveGroupHistoryReceivers(message).length > 0;
|
|
553
745
|
}
|
|
554
746
|
async publishGroupDirectMessage(groupJid, envelope, retryContext = {}) {
|
|
555
747
|
const { message, plaintext, type, edit, mediatype, sendOptions } = envelope;
|
|
@@ -559,8 +751,16 @@ export class WaMessageDispatchCoordinator {
|
|
|
559
751
|
: await this.deps.groupMetadataCache.resolveParticipantUsers(groupJid);
|
|
560
752
|
const addressingMode = retryContext.forceAddressingMode ??
|
|
561
753
|
this.resolveGroupAddressingMode(participantUserJids, groupJid);
|
|
562
|
-
const
|
|
563
|
-
const
|
|
754
|
+
const senderJid = this.resolveSenderForAddressingMode(addressingMode, meJid);
|
|
755
|
+
const targetUserJids = restrictGroupHistoryTargets(message, participantUserJids, senderJid, (droppedJids, totalExpected) => {
|
|
756
|
+
this.deps.logger.warn('group history receivers missing from the participant list', {
|
|
757
|
+
groupJid,
|
|
758
|
+
droppedCount: droppedJids.length,
|
|
759
|
+
totalExpected,
|
|
760
|
+
sample: droppedJids.slice(0, 3)
|
|
761
|
+
});
|
|
762
|
+
});
|
|
763
|
+
const fanoutDeviceJids = await this.deps.fanoutResolver.resolveGroupParticipantDeviceJids(targetUserJids);
|
|
564
764
|
if (fanoutDeviceJids.length === 0) {
|
|
565
765
|
throw new Error('group direct send resolved no target devices');
|
|
566
766
|
}
|
|
@@ -615,22 +815,10 @@ export class WaMessageDispatchCoordinator {
|
|
|
615
815
|
break;
|
|
616
816
|
}
|
|
617
817
|
}
|
|
618
|
-
const phashTargets = new Array(resolvedFanoutTargets.length + 1);
|
|
619
|
-
let phashTargetCount = 0;
|
|
620
|
-
for (let index = 0; index < resolvedFanoutTargets.length; index += 1) {
|
|
621
|
-
const candidate = resolvedFanoutTargets[index].jid;
|
|
622
|
-
if (isHostedDeviceJid(candidate))
|
|
623
|
-
continue;
|
|
624
|
-
phashTargets[phashTargetCount] = candidate;
|
|
625
|
-
phashTargetCount += 1;
|
|
626
|
-
}
|
|
627
|
-
phashTargets[phashTargetCount] = senderForPhash;
|
|
628
|
-
phashTargets.length = phashTargetCount + 1;
|
|
629
|
-
const localPhash = computePhashV2(phashTargets);
|
|
630
818
|
const reportingArtifacts = await this.tryBuildReportingTokenArtifacts({
|
|
631
819
|
message,
|
|
632
820
|
stanzaId: sendOptions.id,
|
|
633
|
-
senderUserJid: toUserJid(
|
|
821
|
+
senderUserJid: toUserJid(senderJid),
|
|
634
822
|
remoteJid: groupJid,
|
|
635
823
|
context: 'group_direct'
|
|
636
824
|
});
|
|
@@ -642,7 +830,6 @@ export class WaMessageDispatchCoordinator {
|
|
|
642
830
|
type,
|
|
643
831
|
id: sendOptions.id,
|
|
644
832
|
edit,
|
|
645
|
-
phash: localPhash,
|
|
646
833
|
addressingMode,
|
|
647
834
|
participants,
|
|
648
835
|
deviceIdentity: shouldAttachDeviceIdentity
|
|
@@ -668,7 +855,7 @@ export class WaMessageDispatchCoordinator {
|
|
|
668
855
|
const ackError = result.ack.error;
|
|
669
856
|
const serverPhash = result.ack.phash;
|
|
670
857
|
const serverAddressingMode = result.ack.addressingMode;
|
|
671
|
-
const hasPhashMismatch = !!serverPhash
|
|
858
|
+
const hasPhashMismatch = !!serverPhash;
|
|
672
859
|
const hasAddressingMismatch = !!serverAddressingMode && serverAddressingMode !== addressingMode;
|
|
673
860
|
const hasAddressingError = ackError === WA_NACK_REASONS.STALE_GROUP_ADDRESSING_MODE;
|
|
674
861
|
if (!retryContext.retried &&
|
|
@@ -676,7 +863,6 @@ export class WaMessageDispatchCoordinator {
|
|
|
676
863
|
this.deps.logger.warn('group direct publish acknowledged with mismatch metadata', {
|
|
677
864
|
id: result.id,
|
|
678
865
|
groupJid,
|
|
679
|
-
localPhash,
|
|
680
866
|
serverPhash,
|
|
681
867
|
localAddressingMode: addressingMode,
|
|
682
868
|
serverAddressingMode,
|
|
@@ -1,91 +1,12 @@
|
|
|
1
|
+
import { parseBlocklist, parseDisallowedList, parseDisallowedListUpdate, parsePrivacyCategoryDhash, parsePrivacySettings } from '../events/privacy.js';
|
|
2
|
+
import { PromiseDedup } from '../../infra/perf/PromiseDedup.js';
|
|
3
|
+
import { WA_DEFAULTS } from '../../protocol/defaults.js';
|
|
1
4
|
import { isLidJid, isUserJid, normalizeRecipientJid } from '../../protocol/jid.js';
|
|
2
|
-
import { WA_NODE_TAGS } from '../../protocol/nodes.js';
|
|
3
|
-
import {
|
|
4
|
-
import { buildBlocklistBlockIq, buildBlocklistUnblockIq, buildGetBlocklistIq, buildGetPrivacyDisallowedListIq, buildGetPrivacySettingsIq, buildSetPrivacyCategoryIq } from '../../transport/node/builders/privacy.js';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
const IGNORED_SERVER_CATEGORIES = new Set([
|
|
8
|
-
'pix',
|
|
9
|
-
'linked_profiles',
|
|
10
|
-
'stickers',
|
|
11
|
-
'dependentaccountmessages',
|
|
12
|
-
'cover_photo',
|
|
13
|
-
'dependent_account_calling',
|
|
14
|
-
'groupcreation'
|
|
15
|
-
]);
|
|
16
|
-
const VALID_PRIVACY_VALUES = new Set(Object.values(WA_PRIVACY_VALUES));
|
|
17
|
-
function isValidPrivacyValue(value) {
|
|
18
|
-
return value !== WA_PRIVACY_VALUES.ERROR && VALID_PRIVACY_VALUES.has(value);
|
|
19
|
-
}
|
|
20
|
-
function parsePrivacySettings(result) {
|
|
21
|
-
const privacyNode = findNodeChild(result, WA_NODE_TAGS.PRIVACY);
|
|
22
|
-
if (!privacyNode) {
|
|
23
|
-
return {};
|
|
24
|
-
}
|
|
25
|
-
const settings = {};
|
|
26
|
-
const categories = getNodeChildrenByTag(privacyNode, WA_PRIVACY_TAGS.CATEGORY);
|
|
27
|
-
for (let i = 0; i < categories.length; i += 1) {
|
|
28
|
-
const node = categories[i];
|
|
29
|
-
const name = node.attrs.name;
|
|
30
|
-
const value = node.attrs.value;
|
|
31
|
-
if (!name || !value) {
|
|
32
|
-
continue;
|
|
33
|
-
}
|
|
34
|
-
if (IGNORED_SERVER_CATEGORIES.has(name)) {
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
if (!isValidPrivacyValue(value)) {
|
|
38
|
-
continue;
|
|
39
|
-
}
|
|
40
|
-
const settingName = WA_PRIVACY_CATEGORY_TO_SETTING[name];
|
|
41
|
-
if (settingName) {
|
|
42
|
-
settings[settingName] = value;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
return settings;
|
|
46
|
-
}
|
|
47
|
-
function parseDisallowedList(result) {
|
|
48
|
-
const privacyNode = findNodeChild(result, WA_NODE_TAGS.PRIVACY);
|
|
49
|
-
if (!privacyNode) {
|
|
50
|
-
return { jids: [] };
|
|
51
|
-
}
|
|
52
|
-
const listNode = findNodeChild(privacyNode, WA_PRIVACY_TAGS.LIST);
|
|
53
|
-
if (!listNode) {
|
|
54
|
-
return { jids: [] };
|
|
55
|
-
}
|
|
56
|
-
const dhash = listNode.attrs.dhash;
|
|
57
|
-
const userNodes = getNodeChildrenByTag(listNode, WA_PRIVACY_TAGS.USER);
|
|
58
|
-
const jids = new Array(userNodes.length);
|
|
59
|
-
let jidsCount = 0;
|
|
60
|
-
for (let i = 0; i < userNodes.length; i += 1) {
|
|
61
|
-
const jid = userNodes[i].attrs.jid;
|
|
62
|
-
if (jid) {
|
|
63
|
-
jids[jidsCount] = jid;
|
|
64
|
-
jidsCount += 1;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
jids.length = jidsCount;
|
|
68
|
-
return { jids, dhash };
|
|
69
|
-
}
|
|
70
|
-
function parseBlocklist(result) {
|
|
71
|
-
const listNode = findNodeChild(result, WA_NODE_TAGS.LIST);
|
|
72
|
-
if (!listNode) {
|
|
73
|
-
return { jids: [] };
|
|
74
|
-
}
|
|
75
|
-
const dhash = listNode.attrs.dhash;
|
|
76
|
-
const itemNodes = getNodeChildren(listNode);
|
|
77
|
-
const jids = new Array(itemNodes.length);
|
|
78
|
-
let jidsCount = 0;
|
|
79
|
-
for (let i = 0; i < itemNodes.length; i += 1) {
|
|
80
|
-
const jid = itemNodes[i].attrs.jid;
|
|
81
|
-
if (jid) {
|
|
82
|
-
jids[jidsCount] = jid;
|
|
83
|
-
jidsCount += 1;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
jids.length = jidsCount;
|
|
87
|
-
return { jids, dhash };
|
|
88
|
-
}
|
|
5
|
+
import { WA_IQ_TYPES, WA_NODE_TAGS } from '../../protocol/nodes.js';
|
|
6
|
+
import { WA_PRIVACY_ACCOUNT_SYNC_DISALLOWED_LISTS, WA_PRIVACY_LIST_ACTIONS, WA_PRIVACY_SETTING_TO_CATEGORY } from '../../protocol/privacy.js';
|
|
7
|
+
import { buildBlocklistBlockIq, buildBlocklistUnblockIq, buildGetBlocklistIq, buildGetPrivacyDisallowedListIq, buildGetPrivacySettingsIq, buildSetPrivacyCategoryIq, buildSetPrivacyDisallowedListIq } from '../../transport/node/builders/privacy.js';
|
|
8
|
+
import { assertIqResult, parseIqError } from '../../transport/node/query.js';
|
|
9
|
+
import { toError } from '../../util/primitives.js';
|
|
89
10
|
/**
|
|
90
11
|
* Resolves a blocklist input into both addressing forms via
|
|
91
12
|
* `resolveUserJidPair` (device-list cache first, usync fallback for phone
|
|
@@ -103,9 +24,111 @@ async function resolveBlocklistTarget(options, jid) {
|
|
|
103
24
|
}
|
|
104
25
|
return { lidJid: null, pnJid: pair.pnJid ?? normalized };
|
|
105
26
|
}
|
|
27
|
+
const ACCOUNT_SYNC_REFRESH_KEY = 'account-sync';
|
|
28
|
+
/**
|
|
29
|
+
* A `409` on a disallowed-list write means the `dhash` we sent no longer
|
|
30
|
+
* matches the server's: another device changed the list in between.
|
|
31
|
+
*/
|
|
32
|
+
function isStaleDhashResult(node) {
|
|
33
|
+
if (node.tag !== WA_NODE_TAGS.IQ || node.attrs.type === WA_IQ_TYPES.RESULT) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
const error = parseIqError(node);
|
|
37
|
+
return error.numericCode === 409 || error.code === '409';
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Resolves a disallowed-list input into `<user>` entries. Unlike the blocklist
|
|
41
|
+
* the server takes both actions in one stanza, so add/remove are resolved
|
|
42
|
+
* together and keep their relative order.
|
|
43
|
+
*/
|
|
44
|
+
async function resolveDisallowedListEntries(options, input) {
|
|
45
|
+
const entries = [];
|
|
46
|
+
const actions = [
|
|
47
|
+
{ action: WA_PRIVACY_LIST_ACTIONS.ADD, jids: input.add ?? [] },
|
|
48
|
+
{ action: WA_PRIVACY_LIST_ACTIONS.REMOVE, jids: input.remove ?? [] }
|
|
49
|
+
];
|
|
50
|
+
for (const { action, jids } of actions) {
|
|
51
|
+
for (const jid of jids) {
|
|
52
|
+
const target = await resolveBlocklistTarget(options, jid);
|
|
53
|
+
entries.push({ action, lidJid: target.lidJid, pnJid: target.pnJid });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (entries.length === 0) {
|
|
57
|
+
throw new Error('setDisallowedList requires at least one add/remove entry');
|
|
58
|
+
}
|
|
59
|
+
return entries;
|
|
60
|
+
}
|
|
106
61
|
/** Builds a {@link WaPrivacyCoordinator} backed by the given IQ query function. */
|
|
107
62
|
export function createPrivacyCoordinator(options) {
|
|
108
63
|
const { queryWithContext } = options;
|
|
64
|
+
const usesLidAddressing = () => options.getSelfLid() !== null;
|
|
65
|
+
let refreshTimer = null;
|
|
66
|
+
let refreshStopped = false;
|
|
67
|
+
let activeRefresh = null;
|
|
68
|
+
const refreshDedup = new PromiseDedup();
|
|
69
|
+
const queryDisallowedList = async (category) => {
|
|
70
|
+
const node = buildGetPrivacyDisallowedListIq(category, usesLidAddressing());
|
|
71
|
+
const result = await queryWithContext('privacy.getDisallowedList', node, undefined, {
|
|
72
|
+
category
|
|
73
|
+
});
|
|
74
|
+
assertIqResult(result, 'privacy.getDisallowedList');
|
|
75
|
+
return result;
|
|
76
|
+
};
|
|
77
|
+
const readDisallowedList = async (category) => parseDisallowedList(await queryDisallowedList(category));
|
|
78
|
+
const runAccountSyncRefresh = async () => {
|
|
79
|
+
const settingsNode = await queryWithContext('privacy.getSettings', buildGetPrivacySettingsIq());
|
|
80
|
+
assertIqResult(settingsNode, 'privacy.getSettings');
|
|
81
|
+
const listNodes = await Promise.all(WA_PRIVACY_ACCOUNT_SYNC_DISALLOWED_LISTS.map(async (setting) => ({
|
|
82
|
+
setting,
|
|
83
|
+
node: await queryDisallowedList(WA_PRIVACY_SETTING_TO_CATEGORY[setting])
|
|
84
|
+
})));
|
|
85
|
+
const disallowedLists = [];
|
|
86
|
+
for (const { setting, node } of listNodes) {
|
|
87
|
+
const update = parseDisallowedListUpdate(node);
|
|
88
|
+
if (update) {
|
|
89
|
+
disallowedLists.push({ setting, ...update });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const result = {
|
|
93
|
+
settings: parsePrivacySettings(settingsNode),
|
|
94
|
+
disallowedLists
|
|
95
|
+
};
|
|
96
|
+
options.emitPrivacy(result);
|
|
97
|
+
return result;
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Joins a refresh already in flight instead of racing it, so concurrent
|
|
101
|
+
* callers share one round of queries and one emit.
|
|
102
|
+
*/
|
|
103
|
+
const dedupedRefresh = () => {
|
|
104
|
+
const running = refreshDedup.run(ACCOUNT_SYNC_REFRESH_KEY, runAccountSyncRefresh);
|
|
105
|
+
activeRefresh = running;
|
|
106
|
+
return running;
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* Refresh for a change the server just announced. It must not join a
|
|
110
|
+
* refresh already in flight: that one's queries went out before the
|
|
111
|
+
* change landed, so joining it would emit a snapshot without the new
|
|
112
|
+
* value and nothing would refetch. Waiting for it first guarantees a read
|
|
113
|
+
* issued after the notification.
|
|
114
|
+
*/
|
|
115
|
+
const followUpRefresh = async () => {
|
|
116
|
+
if (refreshStopped) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
await activeRefresh?.catch(() => undefined);
|
|
120
|
+
if (refreshStopped) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
await dedupedRefresh();
|
|
124
|
+
};
|
|
125
|
+
const writeDisallowedList = async (category, entries, dhash) => {
|
|
126
|
+
const node = buildSetPrivacyDisallowedListIq(category, entries, dhash, usesLidAddressing());
|
|
127
|
+
return queryWithContext('privacy.setDisallowedList', node, undefined, {
|
|
128
|
+
category,
|
|
129
|
+
entries: entries.length
|
|
130
|
+
});
|
|
131
|
+
};
|
|
109
132
|
return {
|
|
110
133
|
getPrivacySettings: async () => {
|
|
111
134
|
const node = buildGetPrivacySettingsIq();
|
|
@@ -121,15 +144,43 @@ export function createPrivacyCoordinator(options) {
|
|
|
121
144
|
value
|
|
122
145
|
});
|
|
123
146
|
assertIqResult(result, 'privacy.setSetting');
|
|
147
|
+
return parsePrivacyCategoryDhash(result, category);
|
|
124
148
|
},
|
|
125
|
-
getDisallowedList: async (setting) =>
|
|
149
|
+
getDisallowedList: async (setting) => readDisallowedList(WA_PRIVACY_SETTING_TO_CATEGORY[setting]),
|
|
150
|
+
setDisallowedList: async (setting, input) => {
|
|
126
151
|
const category = WA_PRIVACY_SETTING_TO_CATEGORY[setting];
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
152
|
+
const entries = await resolveDisallowedListEntries(options, input);
|
|
153
|
+
const current = await readDisallowedList(category);
|
|
154
|
+
let result = await writeDisallowedList(category, entries, current.dhash ?? null);
|
|
155
|
+
if (isStaleDhashResult(result)) {
|
|
156
|
+
const refreshed = await readDisallowedList(category);
|
|
157
|
+
result = await writeDisallowedList(category, entries, refreshed.dhash ?? null);
|
|
158
|
+
}
|
|
159
|
+
assertIqResult(result, 'privacy.setDisallowedList');
|
|
160
|
+
return parsePrivacyCategoryDhash(result, category);
|
|
161
|
+
},
|
|
162
|
+
refreshFromAccountSync: () => dedupedRefresh(),
|
|
163
|
+
scheduleAccountSyncRefresh: () => {
|
|
164
|
+
refreshStopped = false;
|
|
165
|
+
if (refreshTimer !== null) {
|
|
166
|
+
clearTimeout(refreshTimer);
|
|
167
|
+
}
|
|
168
|
+
refreshTimer = setTimeout(() => {
|
|
169
|
+
refreshTimer = null;
|
|
170
|
+
void followUpRefresh().catch((error) => {
|
|
171
|
+
options.logger.warn('account_sync privacy refresh failed', {
|
|
172
|
+
message: toError(error).message
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
}, WA_DEFAULTS.PRIVACY_ACCOUNT_SYNC_DEBOUNCE_MS);
|
|
176
|
+
refreshTimer.unref?.();
|
|
177
|
+
},
|
|
178
|
+
stopAccountSyncRefresh: () => {
|
|
179
|
+
refreshStopped = true;
|
|
180
|
+
if (refreshTimer !== null) {
|
|
181
|
+
clearTimeout(refreshTimer);
|
|
182
|
+
refreshTimer = null;
|
|
183
|
+
}
|
|
133
184
|
},
|
|
134
185
|
getBlocklist: async () => {
|
|
135
186
|
const node = buildGetBlocklistIq();
|