Compare commits

..

1 Commits

Author SHA1 Message Date
Renato Alcara 64ec22d436 fix: add pastParticipants to HistorySync with LID normalization and deduplication
Two bugs fixed compared to prior implementation:

1. history.ts — normalize userJid LID→PN
   pastParticipants[].userJid may arrive as a LID identifier (e.g. "46802258641027@lid").
   The consumer expects a phone number. After building the lidPnMap from conversations
   and phoneNumberToLidMappings, each userJid is resolved to its PN equivalent.
   If the mapping is not available the original value is preserved (no data loss).

2. event-buffer.ts — deduplicate by groupJid + userJid across chunks
   Multiple HistorySync chunks can contain the same group. The previous approach
   concatenated blindly, producing duplicate entries. Now a keyed map
   { [groupJid]: IPastParticipant[] } is used so each group appears once and
   each participant within a group appears at most once.

Types updated (Events.ts):
   - messaging-history.set event includes pastParticipants?: IPastParticipants[]
   - BufferedEventData.historySets.pastParticipants typed as keyed map

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 16:21:19 -03:00
16 changed files with 539 additions and 1896 deletions
+2 -12
View File
@@ -1,7 +1,7 @@
syntax = "proto3"; syntax = "proto3";
package proto; package proto;
/// WhatsApp Version: 2.3000.1035715252 /// WhatsApp Version: 2.3000.1035484955
message ADVDeviceIdentity { message ADVDeviceIdentity {
optional uint32 rawId = 1; optional uint32 rawId = 1;
@@ -2149,6 +2149,7 @@ message LimitSharing {
CHAT_SETTING = 1; CHAT_SETTING = 1;
BIZ_SUPPORTS_FB_HOSTING = 2; BIZ_SUPPORTS_FB_HOSTING = 2;
UNKNOWN_GROUP = 3; UNKNOWN_GROUP = 3;
DEPRECATION = 4;
} }
} }
@@ -4892,7 +4893,6 @@ message SyncActionValue {
repeated SyncActionValue.BroadcastListParticipant participants = 2; repeated SyncActionValue.BroadcastListParticipant participants = 2;
optional string listName = 3; optional string listName = 3;
repeated string labelIds = 4; repeated string labelIds = 4;
optional string audienceExpression = 5;
} }
message CallLogAction { message CallLogAction {
@@ -5304,21 +5304,11 @@ message SyncActionValue {
repeated string userJid = 2; repeated string userJid = 2;
optional bool shareToFB = 3; optional bool shareToFB = 3;
optional bool shareToIG = 4; optional bool shareToIG = 4;
repeated CustomList customLists = 5;
message CustomList {
optional int64 id = 1;
optional string name = 2;
optional string emoji = 3;
optional bool isSelected = 4;
repeated string userJid = 5;
}
enum StatusDistributionMode { enum StatusDistributionMode {
ALLOW_LIST = 0; ALLOW_LIST = 0;
DENY_LIST = 1; DENY_LIST = 1;
CONTACTS = 2; CONTACTS = 2;
CLOSE_FRIENDS = 3; CLOSE_FRIENDS = 3;
CUSTOM_LIST = 4;
} }
} }
+3 -31
View File
@@ -5356,7 +5356,8 @@ export namespace proto {
UNKNOWN = 0, UNKNOWN = 0,
CHAT_SETTING = 1, CHAT_SETTING = 1,
BIZ_SUPPORTS_FB_HOSTING = 2, BIZ_SUPPORTS_FB_HOSTING = 2,
UNKNOWN_GROUP = 3 UNKNOWN_GROUP = 3,
DEPRECATION = 4
} }
} }
@@ -12482,7 +12483,6 @@ export namespace proto {
participants?: (proto.SyncActionValue.IBroadcastListParticipant[]|null); participants?: (proto.SyncActionValue.IBroadcastListParticipant[]|null);
listName?: (string|null); listName?: (string|null);
labelIds?: (string[]|null); labelIds?: (string[]|null);
audienceExpression?: (string|null);
} }
class BusinessBroadcastListAction implements IBusinessBroadcastListAction { class BusinessBroadcastListAction implements IBusinessBroadcastListAction {
@@ -12491,7 +12491,6 @@ export namespace proto {
public participants: proto.SyncActionValue.IBroadcastListParticipant[]; public participants: proto.SyncActionValue.IBroadcastListParticipant[];
public listName?: (string|null); public listName?: (string|null);
public labelIds: string[]; public labelIds: string[];
public audienceExpression?: (string|null);
public static create(properties?: proto.SyncActionValue.IBusinessBroadcastListAction): proto.SyncActionValue.BusinessBroadcastListAction; public static create(properties?: proto.SyncActionValue.IBusinessBroadcastListAction): proto.SyncActionValue.BusinessBroadcastListAction;
public static encode(m: proto.SyncActionValue.IBusinessBroadcastListAction, w?: $protobuf.Writer): $protobuf.Writer; public static encode(m: proto.SyncActionValue.IBusinessBroadcastListAction, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.SyncActionValue.BusinessBroadcastListAction; public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.SyncActionValue.BusinessBroadcastListAction;
@@ -13702,7 +13701,6 @@ export namespace proto {
userJid?: (string[]|null); userJid?: (string[]|null);
shareToFB?: (boolean|null); shareToFB?: (boolean|null);
shareToIG?: (boolean|null); shareToIG?: (boolean|null);
customLists?: (proto.SyncActionValue.StatusPrivacyAction.ICustomList[]|null);
} }
class StatusPrivacyAction implements IStatusPrivacyAction { class StatusPrivacyAction implements IStatusPrivacyAction {
@@ -13711,7 +13709,6 @@ export namespace proto {
public userJid: string[]; public userJid: string[];
public shareToFB?: (boolean|null); public shareToFB?: (boolean|null);
public shareToIG?: (boolean|null); public shareToIG?: (boolean|null);
public customLists: proto.SyncActionValue.StatusPrivacyAction.ICustomList[];
public static create(properties?: proto.SyncActionValue.IStatusPrivacyAction): proto.SyncActionValue.StatusPrivacyAction; public static create(properties?: proto.SyncActionValue.IStatusPrivacyAction): proto.SyncActionValue.StatusPrivacyAction;
public static encode(m: proto.SyncActionValue.IStatusPrivacyAction, w?: $protobuf.Writer): $protobuf.Writer; public static encode(m: proto.SyncActionValue.IStatusPrivacyAction, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.SyncActionValue.StatusPrivacyAction; public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.SyncActionValue.StatusPrivacyAction;
@@ -13723,36 +13720,11 @@ export namespace proto {
namespace StatusPrivacyAction { namespace StatusPrivacyAction {
interface ICustomList {
id?: (number|Long|null);
name?: (string|null);
emoji?: (string|null);
isSelected?: (boolean|null);
userJid?: (string[]|null);
}
class CustomList implements ICustomList {
constructor(p?: proto.SyncActionValue.StatusPrivacyAction.ICustomList);
public id?: (number|Long|null);
public name?: (string|null);
public emoji?: (string|null);
public isSelected?: (boolean|null);
public userJid: string[];
public static create(properties?: proto.SyncActionValue.StatusPrivacyAction.ICustomList): proto.SyncActionValue.StatusPrivacyAction.CustomList;
public static encode(m: proto.SyncActionValue.StatusPrivacyAction.ICustomList, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.SyncActionValue.StatusPrivacyAction.CustomList;
public static fromObject(d: { [k: string]: any }): proto.SyncActionValue.StatusPrivacyAction.CustomList;
public static toObject(m: proto.SyncActionValue.StatusPrivacyAction.CustomList, o?: $protobuf.IConversionOptions): { [k: string]: any };
public toJSON(): { [k: string]: any };
public static getTypeUrl(typeUrlPrefix?: string): string;
}
enum StatusDistributionMode { enum StatusDistributionMode {
ALLOW_LIST = 0, ALLOW_LIST = 0,
DENY_LIST = 1, DENY_LIST = 1,
CONTACTS = 2, CONTACTS = 2,
CLOSE_FRIENDS = 3, CLOSE_FRIENDS = 3
CUSTOM_LIST = 4
} }
} }
+9 -245
View File
@@ -26692,6 +26692,10 @@ export const proto = $root.proto = (() => {
case 3: case 3:
m.limitSharingTrigger = 3; m.limitSharingTrigger = 3;
break; break;
case "DEPRECATION":
case 4:
m.limitSharingTrigger = 4;
break;
} }
if (d.limitSharingInitiatedByMe != null) { if (d.limitSharingInitiatedByMe != null) {
m.limitSharingInitiatedByMe = Boolean(d.limitSharingInitiatedByMe); m.limitSharingInitiatedByMe = Boolean(d.limitSharingInitiatedByMe);
@@ -36774,6 +36778,10 @@ export const proto = $root.proto = (() => {
case 3: case 3:
m.trigger = 3; m.trigger = 3;
break; break;
case "DEPRECATION":
case 4:
m.trigger = 4;
break;
} }
if (d.limitSharingSettingTimestamp != null) { if (d.limitSharingSettingTimestamp != null) {
if ($util.Long) if ($util.Long)
@@ -36838,6 +36846,7 @@ export const proto = $root.proto = (() => {
values[valuesById[1] = "CHAT_SETTING"] = 1; values[valuesById[1] = "CHAT_SETTING"] = 1;
values[valuesById[2] = "BIZ_SUPPORTS_FB_HOSTING"] = 2; values[valuesById[2] = "BIZ_SUPPORTS_FB_HOSTING"] = 2;
values[valuesById[3] = "UNKNOWN_GROUP"] = 3; values[valuesById[3] = "UNKNOWN_GROUP"] = 3;
values[valuesById[4] = "DEPRECATION"] = 4;
return values; return values;
})(); })();
@@ -87572,7 +87581,6 @@ export const proto = $root.proto = (() => {
BusinessBroadcastListAction.prototype.participants = $util.emptyArray; BusinessBroadcastListAction.prototype.participants = $util.emptyArray;
BusinessBroadcastListAction.prototype.listName = null; BusinessBroadcastListAction.prototype.listName = null;
BusinessBroadcastListAction.prototype.labelIds = $util.emptyArray; BusinessBroadcastListAction.prototype.labelIds = $util.emptyArray;
BusinessBroadcastListAction.prototype.audienceExpression = null;
let $oneOfFields; let $oneOfFields;
@@ -87588,12 +87596,6 @@ export const proto = $root.proto = (() => {
set: $util.oneOfSetter($oneOfFields) set: $util.oneOfSetter($oneOfFields)
}); });
// Virtual OneOf for proto3 optional field
Object.defineProperty(BusinessBroadcastListAction.prototype, "_audienceExpression", {
get: $util.oneOfGetter($oneOfFields = ["audienceExpression"]),
set: $util.oneOfSetter($oneOfFields)
});
BusinessBroadcastListAction.create = function create(properties) { BusinessBroadcastListAction.create = function create(properties) {
return new BusinessBroadcastListAction(properties); return new BusinessBroadcastListAction(properties);
}; };
@@ -87613,8 +87615,6 @@ export const proto = $root.proto = (() => {
for (var i = 0; i < m.labelIds.length; ++i) for (var i = 0; i < m.labelIds.length; ++i)
w.uint32(34).string(m.labelIds[i]); w.uint32(34).string(m.labelIds[i]);
} }
if (m.audienceExpression != null && Object.hasOwnProperty.call(m, "audienceExpression"))
w.uint32(42).string(m.audienceExpression);
return w; return w;
}; };
@@ -87647,10 +87647,6 @@ export const proto = $root.proto = (() => {
m.labelIds.push(r.string()); m.labelIds.push(r.string());
break; break;
} }
case 5: {
m.audienceExpression = r.string();
break;
}
default: default:
r.skipType(t & 7); r.skipType(t & 7);
break; break;
@@ -87687,9 +87683,6 @@ export const proto = $root.proto = (() => {
m.labelIds[i] = String(d.labelIds[i]); m.labelIds[i] = String(d.labelIds[i]);
} }
} }
if (d.audienceExpression != null) {
m.audienceExpression = String(d.audienceExpression);
}
return m; return m;
}; };
@@ -87723,11 +87716,6 @@ export const proto = $root.proto = (() => {
d.labelIds[j] = m.labelIds[j]; d.labelIds[j] = m.labelIds[j];
} }
} }
if (m.audienceExpression != null && m.hasOwnProperty("audienceExpression")) {
d.audienceExpression = m.audienceExpression;
if (o.oneofs)
d._audienceExpression = "audienceExpression";
}
return d; return d;
}; };
@@ -94967,7 +94955,6 @@ export const proto = $root.proto = (() => {
function StatusPrivacyAction(p) { function StatusPrivacyAction(p) {
this.userJid = []; this.userJid = [];
this.customLists = [];
if (p) if (p)
for (var ks = Object.keys(p), i = 0; i < ks.length; ++i) for (var ks = Object.keys(p), i = 0; i < ks.length; ++i)
if (p[ks[i]] != null) if (p[ks[i]] != null)
@@ -94978,7 +94965,6 @@ export const proto = $root.proto = (() => {
StatusPrivacyAction.prototype.userJid = $util.emptyArray; StatusPrivacyAction.prototype.userJid = $util.emptyArray;
StatusPrivacyAction.prototype.shareToFB = null; StatusPrivacyAction.prototype.shareToFB = null;
StatusPrivacyAction.prototype.shareToIG = null; StatusPrivacyAction.prototype.shareToIG = null;
StatusPrivacyAction.prototype.customLists = $util.emptyArray;
let $oneOfFields; let $oneOfFields;
@@ -95017,10 +95003,6 @@ export const proto = $root.proto = (() => {
w.uint32(24).bool(m.shareToFB); w.uint32(24).bool(m.shareToFB);
if (m.shareToIG != null && Object.hasOwnProperty.call(m, "shareToIG")) if (m.shareToIG != null && Object.hasOwnProperty.call(m, "shareToIG"))
w.uint32(32).bool(m.shareToIG); w.uint32(32).bool(m.shareToIG);
if (m.customLists != null && m.customLists.length) {
for (var i = 0; i < m.customLists.length; ++i)
$root.proto.SyncActionValue.StatusPrivacyAction.CustomList.encode(m.customLists[i], w.uint32(42).fork()).ldelim();
}
return w; return w;
}; };
@@ -95051,12 +95033,6 @@ export const proto = $root.proto = (() => {
m.shareToIG = r.bool(); m.shareToIG = r.bool();
break; break;
} }
case 5: {
if (!(m.customLists && m.customLists.length))
m.customLists = [];
m.customLists.push($root.proto.SyncActionValue.StatusPrivacyAction.CustomList.decode(r, r.uint32()));
break;
}
default: default:
r.skipType(t & 7); r.skipType(t & 7);
break; break;
@@ -95092,10 +95068,6 @@ export const proto = $root.proto = (() => {
case 3: case 3:
m.mode = 3; m.mode = 3;
break; break;
case "CUSTOM_LIST":
case 4:
m.mode = 4;
break;
} }
if (d.userJid) { if (d.userJid) {
if (!Array.isArray(d.userJid)) if (!Array.isArray(d.userJid))
@@ -95111,16 +95083,6 @@ export const proto = $root.proto = (() => {
if (d.shareToIG != null) { if (d.shareToIG != null) {
m.shareToIG = Boolean(d.shareToIG); m.shareToIG = Boolean(d.shareToIG);
} }
if (d.customLists) {
if (!Array.isArray(d.customLists))
throw TypeError(".proto.SyncActionValue.StatusPrivacyAction.customLists: array expected");
m.customLists = [];
for (var i = 0; i < d.customLists.length; ++i) {
if (typeof d.customLists[i] !== "object")
throw TypeError(".proto.SyncActionValue.StatusPrivacyAction.customLists: object expected");
m.customLists[i] = $root.proto.SyncActionValue.StatusPrivacyAction.CustomList.fromObject(d.customLists[i]);
}
}
return m; return m;
}; };
@@ -95130,7 +95092,6 @@ export const proto = $root.proto = (() => {
var d = {}; var d = {};
if (o.arrays || o.defaults) { if (o.arrays || o.defaults) {
d.userJid = []; d.userJid = [];
d.customLists = [];
} }
if (m.mode != null && m.hasOwnProperty("mode")) { if (m.mode != null && m.hasOwnProperty("mode")) {
d.mode = o.enums === String ? $root.proto.SyncActionValue.StatusPrivacyAction.StatusDistributionMode[m.mode] === undefined ? m.mode : $root.proto.SyncActionValue.StatusPrivacyAction.StatusDistributionMode[m.mode] : m.mode; d.mode = o.enums === String ? $root.proto.SyncActionValue.StatusPrivacyAction.StatusDistributionMode[m.mode] === undefined ? m.mode : $root.proto.SyncActionValue.StatusPrivacyAction.StatusDistributionMode[m.mode] : m.mode;
@@ -95153,12 +95114,6 @@ export const proto = $root.proto = (() => {
if (o.oneofs) if (o.oneofs)
d._shareToIG = "shareToIG"; d._shareToIG = "shareToIG";
} }
if (m.customLists && m.customLists.length) {
d.customLists = [];
for (var j = 0; j < m.customLists.length; ++j) {
d.customLists[j] = $root.proto.SyncActionValue.StatusPrivacyAction.CustomList.toObject(m.customLists[j], o);
}
}
return d; return d;
}; };
@@ -95173,203 +95128,12 @@ export const proto = $root.proto = (() => {
return typeUrlPrefix + "/proto.SyncActionValue.StatusPrivacyAction"; return typeUrlPrefix + "/proto.SyncActionValue.StatusPrivacyAction";
}; };
StatusPrivacyAction.CustomList = (function() {
function CustomList(p) {
this.userJid = [];
if (p)
for (var ks = Object.keys(p), i = 0; i < ks.length; ++i)
if (p[ks[i]] != null)
this[ks[i]] = p[ks[i]];
}
CustomList.prototype.id = null;
CustomList.prototype.name = null;
CustomList.prototype.emoji = null;
CustomList.prototype.isSelected = null;
CustomList.prototype.userJid = $util.emptyArray;
let $oneOfFields;
// Virtual OneOf for proto3 optional field
Object.defineProperty(CustomList.prototype, "_id", {
get: $util.oneOfGetter($oneOfFields = ["id"]),
set: $util.oneOfSetter($oneOfFields)
});
// Virtual OneOf for proto3 optional field
Object.defineProperty(CustomList.prototype, "_name", {
get: $util.oneOfGetter($oneOfFields = ["name"]),
set: $util.oneOfSetter($oneOfFields)
});
// Virtual OneOf for proto3 optional field
Object.defineProperty(CustomList.prototype, "_emoji", {
get: $util.oneOfGetter($oneOfFields = ["emoji"]),
set: $util.oneOfSetter($oneOfFields)
});
// Virtual OneOf for proto3 optional field
Object.defineProperty(CustomList.prototype, "_isSelected", {
get: $util.oneOfGetter($oneOfFields = ["isSelected"]),
set: $util.oneOfSetter($oneOfFields)
});
CustomList.create = function create(properties) {
return new CustomList(properties);
};
CustomList.encode = function encode(m, w) {
if (!w)
w = $Writer.create();
if (m.id != null && Object.hasOwnProperty.call(m, "id"))
w.uint32(8).int64(m.id);
if (m.name != null && Object.hasOwnProperty.call(m, "name"))
w.uint32(18).string(m.name);
if (m.emoji != null && Object.hasOwnProperty.call(m, "emoji"))
w.uint32(26).string(m.emoji);
if (m.isSelected != null && Object.hasOwnProperty.call(m, "isSelected"))
w.uint32(32).bool(m.isSelected);
if (m.userJid != null && m.userJid.length) {
for (var i = 0; i < m.userJid.length; ++i)
w.uint32(42).string(m.userJid[i]);
}
return w;
};
CustomList.decode = function decode(r, l, e) {
if (!(r instanceof $Reader))
r = $Reader.create(r);
var c = l === undefined ? r.len : r.pos + l, m = new $root.proto.SyncActionValue.StatusPrivacyAction.CustomList();
while (r.pos < c) {
var t = r.uint32();
if (t === e)
break;
switch (t >>> 3) {
case 1: {
m.id = r.int64();
break;
}
case 2: {
m.name = r.string();
break;
}
case 3: {
m.emoji = r.string();
break;
}
case 4: {
m.isSelected = r.bool();
break;
}
case 5: {
if (!(m.userJid && m.userJid.length))
m.userJid = [];
m.userJid.push(r.string());
break;
}
default:
r.skipType(t & 7);
break;
}
}
return m;
};
CustomList.fromObject = function fromObject(d) {
if (d instanceof $root.proto.SyncActionValue.StatusPrivacyAction.CustomList)
return d;
var m = new $root.proto.SyncActionValue.StatusPrivacyAction.CustomList();
if (d.id != null) {
if ($util.Long)
(m.id = $util.Long.fromValue(d.id)).unsigned = false;
else if (typeof d.id === "string")
m.id = parseInt(d.id, 10);
else if (typeof d.id === "number")
m.id = d.id;
else if (typeof d.id === "object")
m.id = new $util.LongBits(d.id.low >>> 0, d.id.high >>> 0).toNumber();
}
if (d.name != null) {
m.name = String(d.name);
}
if (d.emoji != null) {
m.emoji = String(d.emoji);
}
if (d.isSelected != null) {
m.isSelected = Boolean(d.isSelected);
}
if (d.userJid) {
if (!Array.isArray(d.userJid))
throw TypeError(".proto.SyncActionValue.StatusPrivacyAction.CustomList.userJid: array expected");
m.userJid = [];
for (var i = 0; i < d.userJid.length; ++i) {
m.userJid[i] = String(d.userJid[i]);
}
}
return m;
};
CustomList.toObject = function toObject(m, o) {
if (!o)
o = {};
var d = {};
if (o.arrays || o.defaults) {
d.userJid = [];
}
if (m.id != null && m.hasOwnProperty("id")) {
if (typeof m.id === "number")
d.id = o.longs === String ? String(m.id) : m.id;
else
d.id = o.longs === String ? longToString(m.id) : o.longs === Number ? longToNumber(m.id) : m.id;
if (o.oneofs)
d._id = "id";
}
if (m.name != null && m.hasOwnProperty("name")) {
d.name = m.name;
if (o.oneofs)
d._name = "name";
}
if (m.emoji != null && m.hasOwnProperty("emoji")) {
d.emoji = m.emoji;
if (o.oneofs)
d._emoji = "emoji";
}
if (m.isSelected != null && m.hasOwnProperty("isSelected")) {
d.isSelected = m.isSelected;
if (o.oneofs)
d._isSelected = "isSelected";
}
if (m.userJid && m.userJid.length) {
d.userJid = [];
for (var j = 0; j < m.userJid.length; ++j) {
d.userJid[j] = m.userJid[j];
}
}
return d;
};
CustomList.prototype.toJSON = function toJSON() {
return this.constructor.toObject(this, $protobuf.util.toJSONOptions);
};
CustomList.getTypeUrl = function getTypeUrl(typeUrlPrefix) {
if (typeUrlPrefix === undefined) {
typeUrlPrefix = "type.googleapis.com";
}
return typeUrlPrefix + "/proto.SyncActionValue.StatusPrivacyAction.CustomList";
};
return CustomList;
})();
StatusPrivacyAction.StatusDistributionMode = (function() { StatusPrivacyAction.StatusDistributionMode = (function() {
const valuesById = {}, values = Object.create(valuesById); const valuesById = {}, values = Object.create(valuesById);
values[valuesById[0] = "ALLOW_LIST"] = 0; values[valuesById[0] = "ALLOW_LIST"] = 0;
values[valuesById[1] = "DENY_LIST"] = 1; values[valuesById[1] = "DENY_LIST"] = 1;
values[valuesById[2] = "CONTACTS"] = 2; values[valuesById[2] = "CONTACTS"] = 2;
values[valuesById[3] = "CLOSE_FRIENDS"] = 3; values[valuesById[3] = "CLOSE_FRIENDS"] = 3;
values[valuesById[4] = "CUSTOM_LIST"] = 4;
return values; return values;
})(); })();
File diff suppressed because it is too large Load Diff
-531
View File
@@ -1,531 +0,0 @@
# Mensagens View-Once (Visualização Única) — Guia de Implementação
**Data:** 2026-03-23
**Versão:** 1.0
---
## 1. O que é View-Once
Mensagem de visualização única é um tipo especial de mídia (imagem, vídeo ou áudio) que o destinatário pode abrir apenas uma vez. Após abrir, o conteúdo some permanentemente.
---
> ## ⚠️ RESTRIÇÃO CRÍTICA — Contas Hosted (Meta Business Cloud API)
>
> **Contas registradas como "hosted" na Meta Business Cloud API têm view-once bloqueado pelo servidor do WhatsApp.**
>
> Essa restrição se aplica a **todos os clientes** (Android, iOS, WA Web) e **não há como contornar por código** — é uma política imposta diretamente pelo servidor WA para contas hosted.
>
> **Como identificar se sua conta é hosted:**
> - A conta foi criada via Meta Business Cloud API (WABA)
> - Aparece como "Meta" ou "Business Cloud" nas configurações do WhatsApp Business Manager
>
> **Contas não-hosted** (registradas diretamente via QR code ou pair code) **funcionam normalmente** com view-once.
---
**Comportamento em cada dispositivo após envio via API (contas não-hosted):**
| Dispositivo | O que aparece |
|---|---|
| Destinatário (Android/iOS) | Recebe a mídia com ícone de olhinho — pode abrir uma vez |
| Android principal do remetente | Mostra na conversa que a mensagem foi enviada (ícone de olhinho) |
| WA Web do remetente | "Aguardando mensagem. Abra o WhatsApp no seu celular." |
> **Nota:** A mensagem "Aguardando mensagem..." no WA Web é o comportamento correto e esperado quando o envio vem de um companion (API). É uma limitação do protocolo WhatsApp — o servidor só permite que o celular principal notifique companions com o conteúdo da view-once.
---
## 2. Como Enviar via API
### 2.1 Imagem
```bash
curl -X POST https://sua-api.com/v1/messages/send_image \
-H "Content-Type: application/json" \
-H "x-api-key: SEU_API_KEY" \
-d '{
"instance": "nome-da-instancia",
"to": "5511999999999",
"caption": "Olha só isso!",
"imageUrl": "https://exemplo.com/foto.jpg",
"viewOnce": true
}'
```
**Resposta de sucesso:**
```json
{ "ok": true }
```
### 2.2 Vídeo
```bash
curl -X POST https://sua-api.com/v1/messages/send_video \
-H "Content-Type: application/json" \
-H "x-api-key: SEU_API_KEY" \
-d '{
"instance": "nome-da-instancia",
"to": "5511999999999",
"caption": "Assista uma vez",
"videoUrl": "https://exemplo.com/video.mp4",
"viewOnce": true
}'
```
### 2.3 Áudio
```bash
curl -X POST https://sua-api.com/v1/messages/send_audio \
-H "Content-Type: application/json" \
-H "x-api-key: SEU_API_KEY" \
-d '{
"instance": "nome-da-instancia",
"to": "5511999999999",
"audioUrl": "https://exemplo.com/audio.ogg",
"ptt": false,
"viewOnce": true
}'
```
### 2.4 Usando Base64 (sem URL pública)
```bash
curl -X POST https://sua-api.com/v1/messages/send_image \
-H "Content-Type: application/json" \
-H "x-api-key: SEU_API_KEY" \
-d '{
"instance": "nome-da-instancia",
"to": "5511999999999",
"imageBase64": "data:image/jpeg;base64,/9j/4AAQSkZJRgAB...",
"viewOnce": true
}'
```
---
## 3. Como Receber via Webhook
Quando um contato envia uma mensagem de visualização única para o número conectado na API, o webhook recebe o evento com `key.isViewOnce = true`.
### 3.1 Estrutura do evento recebido
```json
{
"event": "messages.upsert",
"instance": "nome-da-instancia",
"data": {
"messages": [
{
"key": {
"remoteJid": "5511999999999@s.whatsapp.net",
"fromMe": false,
"id": "3EB0ABCDEF123456",
"isViewOnce": true
},
"message": {
"viewOnceMessageV2": {
"message": {
"imageMessage": {
"url": "https://mmg.whatsapp.net/...",
"mimetype": "image/jpeg",
"mediaKey": "base64...",
"fileEncSha256": "base64...",
"directPath": "/v/...",
"viewOnce": true
}
}
}
},
"messageTimestamp": 1742700000,
"pushName": "João Silva"
}
],
"type": "notify"
}
}
```
### 3.2 Como identificar que é view-once
O campo principal para identificar é `key.isViewOnce = true`. Existem dois caminhos para confirmar:
```javascript
function isViewOnce(msg) {
// Caminho 1: flag direta (mais simples)
if (msg.key?.isViewOnce) return true
// Caminho 2: verificar o wrapper da mensagem
const inner =
msg.message?.viewOnceMessageV2?.message ||
msg.message?.viewOnceMessage?.message ||
msg.message?.viewOnceMessageV2Extension?.message
return !!(
inner?.imageMessage?.viewOnce ||
inner?.videoMessage?.viewOnce ||
inner?.audioMessage?.viewOnce
)
}
```
### 3.3 Como acessar a mídia
```javascript
function getViewOnceMedia(msg) {
const inner =
msg.message?.viewOnceMessageV2?.message ||
msg.message?.viewOnceMessage?.message ||
msg.message?.viewOnceMessageV2Extension?.message
if (!inner) return null
if (inner.imageMessage) return { type: 'image', media: inner.imageMessage }
if (inner.videoMessage) return { type: 'video', media: inner.videoMessage }
if (inner.audioMessage) return { type: 'audio', media: inner.audioMessage }
return null
}
// Uso:
const media = getViewOnceMedia(msg)
if (media) {
console.log(`View-once ${media.type}`)
console.log('URL:', media.media.url)
console.log('DirectPath:', media.media.directPath)
console.log('MediaKey:', media.media.mediaKey) // necessário para download
}
```
---
## 4. Como Exibir numa Ferramenta (Frontend/CRM)
### 4.1 Lógica de detecção e exibição
```javascript
function renderMessage(msg) {
if (!isViewOnce(msg)) {
// Renderizar mensagem normal
return renderNormalMessage(msg)
}
const media = getViewOnceMedia(msg)
if (!media) return
// Verificar se já foi aberta (controle local da ferramenta)
const alreadyOpened = localStorage.getItem(`vo_${msg.key.id}`)
if (alreadyOpened) {
// Mostrar placeholder de "já visualizado"
return renderViewOncePlaceholder(media.type, msg.key.fromMe)
}
if (msg.key.fromMe) {
// Mensagem enviada por mim — nunca mostrar conteúdo
return renderSentViewOnce(media.type)
}
// Mensagem recebida — mostrar botão para "abrir uma vez"
return renderViewOnceButton(media.type, msg)
}
```
### 4.2 Componente de exibição — mensagem recebida
```javascript
function renderViewOnceButton(type, msg) {
const icons = { image: '📷', video: '🎥', audio: '🎵' }
const labels = { image: 'Foto', video: 'Vídeo', áudio: 'Áudio' }
return `
<div class="view-once-bubble received">
<span class="view-once-icon">${icons[type]}</span>
<span class="view-once-label">${labels[type]} de visualização única</span>
<button onclick="openViewOnce('${msg.key.id}', '${type}')">
Abrir
</button>
</div>
`
}
async function openViewOnce(msgId, type) {
// 1. Marcar como aberta ANTES de mostrar (só uma chance)
localStorage.setItem(`vo_${msgId}`, '1')
// 2. Baixar e mostrar a mídia
const mediaUrl = await downloadViewOnceMedia(msgId)
showMedia(type, mediaUrl)
// 3. Após fechar/visualizar, substituir pelo placeholder
onMediaClosed(() => {
replaceWithPlaceholder(msgId, type)
})
}
```
### 4.3 Componente de exibição — mensagem enviada
```javascript
function renderSentViewOnce(type) {
const labels = { image: 'Foto', video: 'Vídeo', audio: 'Áudio' }
return `
<div class="view-once-bubble sent">
<span class="view-once-icon">👁️</span>
<span>${labels[type]} de visualização única</span>
<span class="view-once-status">Enviada</span>
</div>
`
}
```
### 4.4 Placeholder após visualização
```javascript
function renderViewOncePlaceholder(type, fromMe) {
const labels = { image: 'foto', video: 'vídeo', audio: 'áudio' }
const text = fromMe
? `Você enviou uma ${labels[type]} de visualização única`
: `${labels[type]} de visualização única aberta`
return `
<div class="view-once-bubble placeholder">
<span class="view-once-icon">🚫</span>
<span>${text}</span>
</div>
`
}
```
### 4.5 CSS sugerido
```css
.view-once-bubble {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 12px;
max-width: 280px;
}
.view-once-bubble.received {
background: #f0f0f0;
color: #333;
}
.view-once-bubble.sent {
background: #dcf8c6;
color: #333;
align-self: flex-end;
}
.view-once-bubble.placeholder {
background: #e8e8e8;
color: #999;
font-style: italic;
}
.view-once-bubble button {
background: #25d366;
color: white;
border: none;
border-radius: 8px;
padding: 6px 12px;
cursor: pointer;
font-size: 13px;
}
```
---
## 5. Código Implementado na API
### 5.1 Geração da mensagem — `src/Utils/messages.ts`
Quando `viewOnce: true` é passado, a mídia é encapsulada no wrapper moderno `viewOnceMessageV2` (campo 55 do proto) e o flag `viewOnce=true` é setado na mídia interna:
```typescript
if (hasOptionalProperty(message, 'viewOnce') && !!message.viewOnce) {
// Seta viewOnce=true na mensagem de mídia interna
if (m.imageMessage) m.imageMessage.viewOnce = true
else if (m.videoMessage) m.videoMessage.viewOnce = true
else if (m.audioMessage) m.audioMessage.viewOnce = true
// Usa viewOnceMessageV2 (campo 55) — formato atual do WA
m = { viewOnceMessageV2: { message: m } }
}
```
### 5.2 Envio — `src/Socket/messages-send.ts`
**Detecção de view-once real** (não confunde com botões/listas que também usam o wrapper `viewOnceMessage`):
```typescript
// Detecta view-once real pela flag viewOnce na mídia interna.
// viewOnceMessage* wrappers são também usados por botões/listas/carrosséis
// (que carregam interactiveMessage dentro) — esses NÃO têm viewOnce=true na mídia.
const viewOnceInner =
message.viewOnceMessageV2?.message ||
message.viewOnceMessage?.message ||
message.viewOnceMessageV2Extension?.message
const isViewOnceMsg = !!(
viewOnceInner?.imageMessage?.viewOnce ||
viewOnceInner?.videoMessage?.viewOnce ||
viewOnceInner?.audioMessage?.viewOnce
)
// Para view-once: DSM enviado apenas para o celular principal (device=0).
// Companions (device>0) são omitidos — o servidor WA gera
// <unavailable type="view_once"/> automaticamente para eles.
const viewOnceMeRecipients = isViewOnceMsg
? meRecipients.filter(jid => !jidDecode(jid)?.device)
: meRecipients
// assertSessions apenas para quem vai realmente receber
await assertSessions([...viewOnceMeRecipients, ...otherRecipients])
const [meNodes, otherNodes] = await Promise.all([
createParticipantNodes(viewOnceMeRecipients, meMsg || message, extraAttrs),
createParticipantNodes(otherRecipients, message, extraAttrs)
])
participants.push(...meNodes)
participants.push(...otherNodes)
// phash recalculado com os participantes reais
const phashRecipients = isViewOnceMsg
? [...viewOnceMeRecipients, ...otherRecipients]
: [...meRecipients, ...otherRecipients]
if (phashRecipients.length > 0) {
extraAttrs['phash'] = generateParticipantHashV2(phashRecipients)
}
```
**`getMediaType()` — garante que vídeo e áudio view-once sejam entregues:**
```typescript
const getMediaType = (message: proto.IMessage) => {
// Desencapsula wrapper view-once antes de verificar o tipo de mídia.
// Sem isso, message.videoMessage e message.audioMessage são undefined
// (estão dentro do wrapper) e o atributo mediatype ficaria ausente no
// enc node — o servidor WA descarta silenciosamente vídeo/áudio view-once.
const inner =
message.viewOnceMessage?.message ||
message.viewOnceMessageV2?.message ||
message.viewOnceMessageV2Extension?.message
if (inner) {
return getMediaType(inner)
}
if (message.imageMessage) return 'image'
else if (message.videoMessage) return 'video'
// ...
}
```
### 5.3 Recepção — `src/Utils/decode-wa-message.ts`
Quando a API recebe uma view-once como companion (linked device), o servidor envia dois stanzas:
- **Stanza 1** (`enc`): conteúdo completo com metadados da mídia
- **Stanza 2** (`unavailable type="view_once"`): sinal de sync
O stanza 1 é detectado e marcado corretamente:
```typescript
// Detecta view-once na stanza 1 recebida por linked device.
// O wrapper viewOnceMessage também é usado por mensagens interativas
// (interactiveMessage, listMessage) — esses NÃO têm viewOnce=true na mídia.
const viewOnceInner =
msg.viewOnceMessage?.message ||
msg.viewOnceMessageV2?.message ||
msg.viewOnceMessageV2Extension?.message
if (
viewOnceInner?.imageMessage?.viewOnce ||
viewOnceInner?.videoMessage?.viewOnce ||
viewOnceInner?.audioMessage?.viewOnce
) {
fullMessage.key.isViewOnce = true
}
```
---
## 6. Fluxo Completo
```
API envia view-once para destinatário
├── Protobuf gerado:
│ viewOnceMessageV2 {
│ message {
│ imageMessage { viewOnce: true, url, mediaKey, ... }
│ }
│ }
├── Stanza enviado ao servidor WA:
│ <message to="destinatario@s.whatsapp.net" type="media">
│ <enc type="pkmsg">...viewOnceMessageV2 cifrado...</enc> ← para o destinatário
│ <participants>
│ <to jid="meu-numero:0@lid"> ← celular principal
│ <enc type="msg">...DSM{viewOnceMessageV2}...</enc>
│ </to>
│ <!-- device>0 omitidos — servidor gera unavailable -->
│ </participants>
│ </message>
└── Distribuição pelo servidor:
├── Destinatário → recebe enc → abre uma vez ✅
├── Celular principal → recebe DSM → mostra "você enviou" ✅
└── WA Web → recebe <unavailable> → "Aguardando..." ✅
Destinatário envia view-once de volta para a API
├── Stanza recebido pela API (stanza 1 — enc com conteúdo):
│ <message from="destinatario@s.whatsapp.net" type="media">
│ <enc type="msg">...viewOnceMessageV2 cifrado...</enc>
│ </message>
├── decode-wa-message.ts:
│ └── Descriptografa → detecta viewOnce=true → seta key.isViewOnce=true
├── Stanza recebido (stanza 2 — unavailable, ignorado):
│ <message from="...">
│ <unavailable type="view_once"/>
│ </message>
└── Webhook emitido:
messages.upsert → msg.key.isViewOnce = true ✅
```
---
## 7. Limitações
| Situação | Comportamento | Motivo |
|---|---|---|
| **Conta hosted (Meta Business Cloud API)** | **View-once bloqueado para todos os clientes** | **Política do servidor WA para contas hosted — sem workaround** |
| WA Web do remetente | "Aguardando mensagem. Abra no celular." | Servidor WA só aceita `<unavailable>` vindo do celular principal, não de companions |
| View-once de documento/sticker | Não suportado | Limitação do protocolo WA — só imagem, vídeo e áudio |
| `error_479` nos logs | Normal, não é erro | TcToken é atualizado após o envio de view-once |
| Companion não consegue abrir | Por design | O conteúdo não é entregue para linked devices |
---
## 8. Checklist de Validação
**Envio:**
- [ ] Destinatário recebe com ícone de olhinho
- [ ] Destinatário consegue abrir a mídia
- [ ] Após abrir, a mídia some
- [ ] Celular principal do remetente mostra "você enviou" com ícone de olhinho
- [ ] WA Web do remetente mostra "Aguardando mensagem..."
- [ ] Funciona para imagem, vídeo e áudio
- [ ] Logs mostram `📤 Message sent` (error_479 é normal)
**Recepção:**
- [ ] Webhook recebe `key.isViewOnce = true`
- [ ] `viewOnceMessageV2.message.imageMessage` (ou video/audio) está presente
- [ ] API não crasha ao receber view-once de outro dispositivo
- [ ] Mensagem interativa (botão/lista) enviada após view-once não é afetada
+1 -1
View File
@@ -1 +1 @@
{"version":[2,3000,1035913242]} {"version":[2,3000,1035504971]}
+5 -24
View File
@@ -154,20 +154,7 @@ export const MEDIA_PATH_MAP: { [T in MediaType]?: string } = {
'md-msg-hist': '/mms/md-app-state', 'md-msg-hist': '/mms/md-app-state',
'biz-cover-photo': '/pps/biz-cover-photo', 'biz-cover-photo': '/pps/biz-cover-photo',
'sticker-pack': '/mms/sticker-pack', 'sticker-pack': '/mms/sticker-pack',
'thumbnail-sticker-pack': '/mms/thumbnail-sticker-pack', 'thumbnail-sticker-pack': '/mms/thumbnail-sticker-pack'
// View-once types — same upload path as regular media (server CDN routing TBD)
'viewonce-image': '/mms/image',
'viewonce-video': '/mms/video',
'viewonce-audio': '/mms/audio'
}
export const NEWSLETTER_MEDIA_PATH_MAP: { [T in MediaType]?: string } = {
image: '/newsletter/newsletter-image',
video: '/newsletter/newsletter-video',
document: '/newsletter/newsletter-document',
audio: '/newsletter/newsletter-audio',
sticker: '/newsletter/newsletter-image',
'thumbnail-link': '/newsletter/newsletter-image'
} }
export const MEDIA_HKDF_KEY_MAPPING = { export const MEDIA_HKDF_KEY_MAPPING = {
@@ -191,11 +178,7 @@ export const MEDIA_HKDF_KEY_MAPPING = {
ptv: 'Video', ptv: 'Video',
'biz-cover-photo': 'Image', 'biz-cover-photo': 'Image',
'sticker-pack': 'Sticker Pack', 'sticker-pack': 'Sticker Pack',
'thumbnail-sticker-pack': 'Sticker Pack Thumbnail', 'thumbnail-sticker-pack': 'Sticker Pack Thumbnail'
// View-once types — same HKDF keys as regular types (recipients decrypt with normal keys)
'viewonce-image': 'Image',
'viewonce-video': 'Video',
'viewonce-audio': 'Audio'
} }
export type MediaType = keyof typeof MEDIA_HKDF_KEY_MAPPING export type MediaType = keyof typeof MEDIA_HKDF_KEY_MAPPING
@@ -205,12 +188,10 @@ export const MEDIA_KEYS = Object.keys(MEDIA_PATH_MAP) as MediaType[]
/** 120s timeout for history sync stall detection, same as WA Web's handleChunkProgress / restartPausedTimer (g = 120) */ /** 120s timeout for history sync stall detection, same as WA Web's handleChunkProgress / restartPausedTimer (g = 120) */
export const HISTORY_SYNC_PAUSED_TIMEOUT_MS = 120_000 export const HISTORY_SYNC_PAUSED_TIMEOUT_MS = 120_000
// Replenishment threshold: when server count drops below this, top-up back to INITIAL_PREKEY_COUNT export const MIN_PREKEY_COUNT = 5
export const MIN_PREKEY_COUNT = 200
// Initial pool size matching WA Business (CDP IDB capture: prekey-store = 812 on registration) // Moderate prekey count (upstream uses 812, reduced to balance rate limiting and availability)
// Rounded to 800 for cleanliness; replenishment always tops up to this value export const INITIAL_PREKEY_COUNT = 200
export const INITIAL_PREKEY_COUNT = 800
export const UPLOAD_TIMEOUT = 30000 // 30 seconds export const UPLOAD_TIMEOUT = 30000 // 30 seconds
// Moderate upload interval to balance rate limiting and responsiveness (was 5000) // Moderate upload interval to balance rate limiting and responsiveness (was 5000)
+40 -60
View File
@@ -7,7 +7,6 @@ import { proto } from '../../WAProto/index.js'
import { import {
DEFAULT_CACHE_TTLS, DEFAULT_CACHE_TTLS,
DEFAULT_SESSION_CLEANUP_CONFIG, DEFAULT_SESSION_CLEANUP_CONFIG,
INITIAL_PREKEY_COUNT,
KEY_BUNDLE_TYPE, KEY_BUNDLE_TYPE,
MIN_PREKEY_COUNT, MIN_PREKEY_COUNT,
PLACEHOLDER_MAX_AGE_SECONDS, PLACEHOLDER_MAX_AGE_SECONDS,
@@ -49,12 +48,9 @@ import {
getStatusFromReceiptType, getStatusFromReceiptType,
handleIdentityChange, handleIdentityChange,
hkdf, hkdf,
BAD_MAC_ERROR_TEXT,
DECRYPTION_RETRY_CONFIG,
MISSING_KEYS_ERROR_TEXT, MISSING_KEYS_ERROR_TEXT,
NACK_REASONS, NACK_REASONS,
NO_MESSAGE_FOUND_ERROR_TEXT, NO_MESSAGE_FOUND_ERROR_TEXT,
RetryReason,
normalizeKeyLidToPn, normalizeKeyLidToPn,
normalizeMessageJids, normalizeMessageJids,
resolveLidToPn, resolveLidToPn,
@@ -1216,7 +1212,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
return await query(stanza) return await query(stanza)
} }
const sendRetryRequest = async (node: BinaryNode, forceIncludeKeys = false, decryptionError?: string) => { const sendRetryRequest = async (node: BinaryNode, forceIncludeKeys = false) => {
const { fullMessage } = decodeMessageNode(node, authState.creds.me!.id, authState.creds.me!.lid || '') const { fullMessage } = decodeMessageNode(node, authState.creds.me!.id, authState.creds.me!.lid || '')
const { key: msgKey } = fullMessage const { key: msgKey } = fullMessage
const msgId = msgKey.id! const msgId = msgKey.id!
@@ -1314,39 +1310,39 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const { account, signedPreKey, signedIdentityKey: identityKey } = authState.creds const { account, signedPreKey, signedIdentityKey: identityKey } = authState.creds
const fromJid = node.attrs.from! const fromJid = node.attrs.from!
// Derive the Signal error code from the actual decryption failure message. // Check if we should recreate the session
// Sent in the retry receipt so the peer (even another InfiniteAPI instance) let shouldRecreateSession = false
// knows the exact failure type and can recreate the session immediately let recreateReason = ''
// instead of falling back to the 1-hour timeout.
// if (enableAutoSessionRecreation && messageRetryManager && retryCount >= 1) {
// Codes mirror RetryReason enum in message-retry-manager.ts: try {
// 0 = UnknownError | 1 = NoSession | 2 = InvalidKey // Check if we have a session with this JID
// 3 = InvalidKeyId | 4 = InvalidMessage | 7 = BadMac const sessionId = signalRepository.jidToSignalProtocolAddress(fromJid)
// const hasSession = await signalRepository.validateSession(fromJid)
// Uses DECRYPTION_RETRY_CONFIG error lists (single source of truth in
// decode-wa-message.ts) so additions to those lists are picked up here // Extract error code from retry node if present (for MAC error detection)
// automatically. const retryNode = getBinaryNodeChild(node, 'retry')
// const errorAttr = retryNode?.attrs?.error
// NOTE: We do NOT delete the session here (receiver side). The Signal Protocol const errorCode = messageRetryManager.parseRetryErrorCode(errorAttr)
// recovers automatically when the sender's pkmsg arrives — it overwrites the
// corrupted session. Deleting prematurely creates a race window where no session const result = messageRetryManager.shouldRecreateSession(fromJid, hasSession.exists, errorCode)
// exists, which can cause "No Session" errors on concurrent messages. shouldRecreateSession = result.recreate
const retryErrorCode = (() => { recreateReason = result.reason
if (!decryptionError) return RetryReason.UnknownError
// Bad MAC must be checked first — it is also in corruptedSessionErrors if (shouldRecreateSession) {
// but warrants the more specific code 7 over the generic code 4. logger.debug({ fromJid, retryCount, reason: recreateReason, errorCode }, 'recreating session for retry')
if (decryptionError.includes(BAD_MAC_ERROR_TEXT)) return RetryReason.SignalErrorBadMac // Delete existing session to force recreation
// MessageCounterError and other corrupted-session variants // CRITICAL: Use same transaction key as encrypt/decrypt operations to prevent race
if (DECRYPTION_RETRY_CONFIG.corruptedSessionErrors.some(e => decryptionError.includes(e))) return RetryReason.SignalErrorInvalidMessage // Using meId ensures this delete serializes with sendMessage() and other session operations
// Missing / invalid session record await authState.keys.transaction(async () => {
if (DECRYPTION_RETRY_CONFIG.sessionRecordErrors.some(e => decryptionError.includes(e)) || await authState.keys.set({ session: { [sessionId]: null } })
/no\s+(open\s+)?sessions?/i.test(decryptionError)) return RetryReason.SignalErrorNoSession }, authState.creds.me?.id || 'session-operation')
// PreKey / key-id errors forceIncludeKeys = true
if (/pre\s*key/i.test(decryptionError)) return RetryReason.SignalErrorInvalidKeyId }
// Identity / key errors } catch (error) {
if (/invalid\s*key|untrusted\s*identity/i.test(decryptionError)) return RetryReason.SignalErrorInvalidKey logger.warn({ error, fromJid }, 'failed to check session recreation')
return RetryReason.UnknownError }
})() }
if (retryCount <= 2) { if (retryCount <= 2) {
// Use new retry manager for phone requests if available // Use new retry manager for phone requests if available
@@ -1387,7 +1383,8 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
id: node.attrs.id!, id: node.attrs.id!,
t: node.attrs.t!, t: node.attrs.t!,
v: '1', v: '1',
error: retryErrorCode.toString() // ADD ERROR FIELD
error: '0'
} }
}, },
{ {
@@ -1406,7 +1403,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
receipt.attrs.participant = node.attrs.participant receipt.attrs.participant = node.attrs.participant
} }
if (retryCount > 1 || forceIncludeKeys) { if (retryCount > 1 || forceIncludeKeys || shouldRecreateSession) {
const { update, preKeys } = await getNextPreKeys(authState, 1) const { update, preKeys } = await getNextPreKeys(authState, 1)
const [keyId] = Object.keys(preKeys) const [keyId] = Object.keys(preKeys)
@@ -1443,8 +1440,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
logger.debug({ count, shouldUploadMorePreKeys }, 'recv pre-key count') logger.debug({ count, shouldUploadMorePreKeys }, 'recv pre-key count')
if (shouldUploadMorePreKeys) { if (shouldUploadMorePreKeys) {
// Top-up back to INITIAL_PREKEY_COUNT so the pool is always restored to full size await uploadPreKeys()
await uploadPreKeys(Math.max(1, INITIAL_PREKEY_COUNT - count))
} }
} else { } else {
const result = await handleIdentityChange(node, { const result = await handleIdentityChange(node, {
@@ -2258,17 +2254,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
return return
} }
// Handle view-once unavailable sync from primary device.
// When the primary device sends a view-once, linked companions receive
// <unavailable type="view_once"/> — no enc content, just a sync signal.
// Acknowledge and skip decryption (nothing to decrypt).
const unavailableNode = getBinaryNodeChild(node, 'unavailable')
if (unavailableNode?.attrs?.type === 'view_once') {
logger.debug({ id: node.attrs.id, from: node.attrs.from }, 'received view_once unavailable sync from primary device')
await sendMessageAck(node)
return
}
const { const {
fullMessage: msg, fullMessage: msg,
category, category,
@@ -2520,7 +2505,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
} }
const encNode = getBinaryNodeChild(node, 'enc') const encNode = getBinaryNodeChild(node, 'enc')
await sendRetryRequest(node, !encNode, errorMessage) await sendRetryRequest(node, !encNode)
if (retryRequestDelayMs) { if (retryRequestDelayMs) {
await delay(retryRequestDelayMs) await delay(retryRequestDelayMs)
} }
@@ -2529,7 +2514,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// Still attempt retry even if pre-key upload failed // Still attempt retry even if pre-key upload failed
try { try {
const encNode = getBinaryNodeChild(node, 'enc') const encNode = getBinaryNodeChild(node, 'enc')
await sendRetryRequest(node, !encNode, errorMessage) await sendRetryRequest(node, !encNode)
} catch (retryErr) { } catch (retryErr) {
logger.error({ retryErr }, 'Failed to send retry after error handling') logger.error({ retryErr }, 'Failed to send retry after error handling')
} }
@@ -2563,11 +2548,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
await sendReceipt(msg.key.remoteJid!, participant!, [msg.key.id!], type) await sendReceipt(msg.key.remoteJid!, participant!, [msg.key.id!], type)
// NOTE: do NOT send view_once_read from a linked device (API).
// WA shows view-once as view_once_unavailable_fanout on linked devices — only the
// primary phone sends view_once_read when the user actually opens it.
// Sending it from a linked device causes WA to flag the account and disables view-once.
// send ack for history message // send ack for history message
const isAnyHistoryMsg = getHistoryMsg(msg.message!) const isAnyHistoryMsg = getHistoryMsg(msg.message!)
if (isAnyHistoryMsg) { if (isAnyHistoryMsg) {
+7 -74
View File
@@ -9,7 +9,6 @@ import type {
AlbumMessageOptions, AlbumMessageOptions,
AlbumSendResult, AlbumSendResult,
AnyMessageContent, AnyMessageContent,
LIDMapping,
MediaConnInfo, MediaConnInfo,
MessageReceiptType, MessageReceiptType,
MessageRelayOptions, MessageRelayOptions,
@@ -339,33 +338,6 @@ export const makeMessagesSocket = (config: SocketConfig) => {
} }
} }
// 4th LID→PN source: device-list entries sharing the same raw_id
// WA Business uses this for accounts where HistorySync sends zero phoneNumberToLidMappings
const allEntries = [...result.list, ...result.sideList]
const rawIdMap = new Map<number, { pn?: string; lid?: string }>()
for (const item of allEntries) {
if (typeof item.rawId !== 'number' || isNaN(item.rawId)) continue
const decoded = jidDecode(item.id)
if (!decoded) continue
const entry = rawIdMap.get(item.rawId) || {}
if (decoded.server === 'lid' || decoded.server === 'hosted.lid') {
entry.lid = item.id
} else if (decoded.server === 's.whatsapp.net' || decoded.server === 'c.us') {
entry.pn = item.id
}
rawIdMap.set(item.rawId, entry)
}
const rawIdMappings: LIDMapping[] = []
for (const { pn, lid } of rawIdMap.values()) {
if (pn && lid) {
rawIdMappings.push({ lid: jidNormalizedUser(lid), pn: jidNormalizedUser(pn) })
}
}
if (rawIdMappings.length > 0) {
await signalRepository.lidMapping.storeLIDPNMappings(rawIdMappings)
logger.debug({ count: rawIdMappings.length }, 'stored LID-PN mappings from raw_id pairing')
}
const meId = authState.creds.me?.id const meId = authState.creds.me?.id
if (!meId) throw new Boom('Not authenticated', { statusCode: 401 }) if (!meId) throw new Boom('Not authenticated', { statusCode: 401 })
const meLid = authState.creds.me?.lid || '' const meLid = authState.creds.me?.lid || ''
@@ -1041,8 +1013,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
} }
await authState.keys.transaction(async () => { await authState.keys.transaction(async () => {
// normalizeMessageContent unwraps viewOnceMessage/viewOnceMessageV2 so getMediaType works correctly const mediaType = getMediaType(message)
const mediaType = getMediaType(normalizeMessageContent(message) || message)
if (mediaType) { if (mediaType) {
extraAttrs['mediatype'] = mediaType extraAttrs['mediatype'] = mediaType
} }
@@ -1052,7 +1023,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
const bytes = encodeNewsletterMessage(patched as proto.IMessage) const bytes = encodeNewsletterMessage(patched as proto.IMessage)
binaryNodeContent.push({ binaryNodeContent.push({
tag: 'plaintext', tag: 'plaintext',
attrs: mediaType ? { mediatype: mediaType } : {}, attrs: {},
content: bytes content: bytes
}) })
const stanza: BinaryNode = { const stanza: BinaryNode = {
@@ -1074,8 +1045,6 @@ export const makeMessagesSocket = (config: SocketConfig) => {
extraAttrs['decrypt-fail'] = 'hide' // todo: expand for reactions and other types extraAttrs['decrypt-fail'] = 'hide' // todo: expand for reactions and other types
} }
// view_once_write disabled — causes server to drop message (validation fails for linked device sessions)
if (isGroupOrStatus && !isRetryResend) { if (isGroupOrStatus && !isRetryResend) {
const [groupData, senderKeyMap] = await Promise.all([ const [groupData, senderKeyMap] = await Promise.all([
(async () => { (async () => {
@@ -1280,48 +1249,21 @@ export const makeMessagesSocket = (config: SocketConfig) => {
: otherRecipients : otherRecipients
const effectiveAllRecipients = [...effectiveMeRecipients, ...effectiveOtherRecipients] const effectiveAllRecipients = [...effectiveMeRecipients, ...effectiveOtherRecipients]
// P2: detect actual view-once media by checking inner message's viewOnce flag. await assertSessions(effectiveAllRecipients)
// viewOnceMessage wrapper is also used for interactive messages (buttons, lists, etc)
// which do NOT carry viewOnce=true on the media — those must NOT be filtered.
const viewOnceInner =
message.viewOnceMessageV2?.message ||
message.viewOnceMessage?.message ||
message.viewOnceMessageV2Extension?.message
const isViewOnceMsg = !!(
viewOnceInner?.imageMessage?.viewOnce ||
viewOnceInner?.videoMessage?.viewOnce ||
viewOnceInner?.audioMessage?.viewOnce
)
// For view-once: only send DSM to primary phone (device=0).
// Companion devices (device>0) are omitted — WA server generates
// <unavailable type="view_once"/> for them automatically.
// Sending explicit <unavailable> from a companion is rejected by the server.
const viewOnceMeRecipients = isViewOnceMsg
? effectiveMeRecipients.filter(jid => !jidDecode(jid)?.device)
: effectiveMeRecipients
// P3: assert sessions only for recipients we actually encrypt for.
// For view-once, companions are omitted — asserting their sessions is wasteful
// and could block the send if a companion session is corrupted.
await assertSessions([...viewOnceMeRecipients, ...effectiveOtherRecipients])
const [ const [
{ nodes: meNodes, shouldIncludeDeviceIdentity: s1 }, { nodes: meNodes, shouldIncludeDeviceIdentity: s1 },
{ nodes: otherNodes, shouldIncludeDeviceIdentity: s2 } { nodes: otherNodes, shouldIncludeDeviceIdentity: s2 }
] = await Promise.all([ ] = await Promise.all([
createParticipantNodes(viewOnceMeRecipients, meMsg || message, extraAttrs), // For own devices: use DSM (deviceSentMessage) wrapper
createParticipantNodes(effectiveMeRecipients, meMsg || message, extraAttrs),
createParticipantNodes(effectiveOtherRecipients, message, extraAttrs) createParticipantNodes(effectiveOtherRecipients, message, extraAttrs)
]) ])
participants.push(...meNodes) participants.push(...meNodes)
participants.push(...otherNodes) participants.push(...otherNodes)
// phash: para view-once inclui só celular principal + destinatários if (effectiveMeRecipients.length > 0 || effectiveOtherRecipients.length > 0) {
const phashRecipients = isViewOnceMsg extraAttrs['phash'] = generateParticipantHashV2(effectiveAllRecipients)
? [...viewOnceMeRecipients, ...effectiveOtherRecipients]
: effectiveAllRecipients
if (phashRecipients.length > 0) {
extraAttrs['phash'] = generateParticipantHashV2(phashRecipients)
} }
shouldIncludeDeviceIdentity = shouldIncludeDeviceIdentity || s1 || s2 shouldIncludeDeviceIdentity = shouldIncludeDeviceIdentity || s1 || s2
@@ -1927,15 +1869,6 @@ export const makeMessagesSocket = (config: SocketConfig) => {
} }
const getMediaType = (message: proto.IMessage) => { const getMediaType = (message: proto.IMessage) => {
// For view-once media, unwrap the viewOnceMessage wrapper before checking media type
const inner =
message.viewOnceMessage?.message ||
message.viewOnceMessageV2?.message ||
message.viewOnceMessageV2Extension?.message
if (inner) {
return getMediaType(inner)
}
if (message.imageMessage) { if (message.imageMessage) {
return 'image' return 'image'
} else if (message.videoMessage) { } else if (message.videoMessage) {
+8 -28
View File
@@ -701,12 +701,10 @@ export const makeSocket = (config: SocketConfig) => {
} }
} }
// Prevent multiple concurrent uploads — if one is already running, wait for it and return: // Prevent multiple concurrent uploads
// the concurrent upload already replenished the pool, so there is nothing left to do.
if (uploadPreKeysPromise) { if (uploadPreKeysPromise) {
logger.debug('Pre-key upload already in progress, waiting for completion') logger.debug('Pre-key upload already in progress, waiting for completion')
await uploadPreKeysPromise await uploadPreKeysPromise
return
} }
const uploadLogic = async () => { const uploadLogic = async () => {
@@ -786,15 +784,14 @@ export const makeSocket = (config: SocketConfig) => {
try { try {
let count = 0 let count = 0
const preKeyCount = await getAvailablePreKeysOnServer() const preKeyCount = await getAvailablePreKeysOnServer()
// How many to upload: top-up to INITIAL_PREKEY_COUNT from whatever remains on server if (preKeyCount === 0) count = INITIAL_PREKEY_COUNT
count = Math.max(0, INITIAL_PREKEY_COUNT - preKeyCount) else count = MIN_PREKEY_COUNT
const { exists: currentPreKeyExists, currentPreKeyId } = await verifyCurrentPreKeyExists() const { exists: currentPreKeyExists, currentPreKeyId } = await verifyCurrentPreKeyExists()
logger.info(`${preKeyCount} pre-keys found on server`) logger.info(`${preKeyCount} pre-keys found on server`)
logger.info(`Current prekey ID: ${currentPreKeyId}, exists in storage: ${currentPreKeyExists}`) logger.info(`Current prekey ID: ${currentPreKeyId}, exists in storage: ${currentPreKeyExists}`)
// Trigger upload when below the replenishment threshold, not when count < topUp amount const lowServerCount = preKeyCount <= count
const lowServerCount = preKeyCount < MIN_PREKEY_COUNT
const missingCurrentPreKey = !currentPreKeyExists && currentPreKeyId > 0 const missingCurrentPreKey = !currentPreKeyExists && currentPreKeyId > 0
const shouldUpload = lowServerCount || missingCurrentPreKey const shouldUpload = lowServerCount || missingCurrentPreKey
@@ -1154,7 +1151,7 @@ export const makeSocket = (config: SocketConfig) => {
// Decrement active connections // Decrement active connections
decrementActiveConnections() decrementActiveConnections()
clearTimeout(keepAliveReq) clearInterval(keepAliveReq)
clearTimeout(qrTimer) clearTimeout(qrTimer)
// Clear offline-buffer safety timer so its callback cannot call ev.flush() // Clear offline-buffer safety timer so its callback cannot call ev.flush()
@@ -1265,16 +1262,8 @@ export const makeSocket = (config: SocketConfig) => {
}) })
} }
const startKeepAliveRequest = () => { const startKeepAliveRequest = () =>
// Use recursive setTimeout with ±15% jitter to match WA Desktop behaviour (keepAliveReq = setInterval(() => {
// (WA Business Desktop: ~25-30s intervals with natural variance)
const scheduleNextKeepAlive = () => {
const jitter = keepAliveIntervalMs * 0.15
const delay = keepAliveIntervalMs + Math.floor((Math.random() * 2 - 1) * jitter)
keepAliveReq = setTimeout(onKeepAliveTick, delay)
}
const onKeepAliveTick = () => {
if (!lastDateRecv) { if (!lastDateRecv) {
lastDateRecv = new Date() lastDateRecv = new Date()
} }
@@ -1286,7 +1275,6 @@ export const makeSocket = (config: SocketConfig) => {
*/ */
if (diff > keepAliveIntervalMs + 5000) { if (diff > keepAliveIntervalMs + 5000) {
void end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost })) void end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost }))
return // connection closing — do not reschedule
} else if (ws.isOpen) { } else if (ws.isOpen) {
// Send keep-alive ping via sendNode() (fire-and-forget) instead of query(). // Send keep-alive ping via sendNode() (fire-and-forget) instead of query().
// query() wraps the ping in the query circuit breaker — when that breaker is // query() wraps the ping in the query circuit breaker — when that breaker is
@@ -1309,15 +1297,7 @@ export const makeSocket = (config: SocketConfig) => {
} else { } else {
logger.warn('keep alive called when WS not open') logger.warn('keep alive called when WS not open')
} }
}, keepAliveIntervalMs))
// Do not reschedule once shutdown has started (closed set by end() on any concurrent path)
if (!closed) {
scheduleNextKeepAlive()
}
}
scheduleNextKeepAlive()
}
/** i have no idea why this exists. pls enlighten me */ /** i have no idea why this exists. pls enlighten me */
const sendPassiveIq = (tag: 'passive' | 'active') => const sendPassiveIq = (tag: 'passive' | 'active') =>
query({ query({
+2 -13
View File
@@ -155,7 +155,6 @@ export type MessageReceiptType =
| 'sender' | 'sender'
| 'inactive' | 'inactive'
| 'played' | 'played'
| 'view_once_read'
| undefined | undefined
export type MediaConnInfo = { export type MediaConnInfo = {
@@ -179,8 +178,6 @@ export interface WAUrlInfo {
type Mentionable = { type Mentionable = {
/** list of jids that are mentioned in the accompanying text */ /** list of jids that are mentioned in the accompanying text */
mentions?: string[] mentions?: string[]
/** mention all participants in the group */
mentionAll?: boolean
} }
type Contextable = { type Contextable = {
/** add contextInfo to the message */ /** add contextInfo to the message */
@@ -993,16 +990,8 @@ export type MessageGenerationOptionsFromContent = MiscMessageGenerationOptions &
export type WAMediaUploadFunction = ( export type WAMediaUploadFunction = (
encFilePath: string, encFilePath: string,
opts: { fileEncSha256B64: string; mediaType: MediaType; timeoutMs?: number; newsletter?: boolean } opts: { fileEncSha256B64: string; mediaType: MediaType; timeoutMs?: number }
) => Promise<{ ) => Promise<{ mediaUrl: string; directPath: string; meta_hmac?: string; ts?: number; fbid?: number }>
mediaUrl: string | undefined
directPath: string
meta_hmac?: string
ts?: number
fbid?: number
thumbnailDirectPath?: string
thumbnailSha256?: string
}>
export type MediaGenerationOptions = { export type MediaGenerationOptions = {
logger?: ILogger logger?: ILogger
-17
View File
@@ -392,23 +392,6 @@ export const decryptMessageNode = (
} else { } else {
fullMessage.message = msg fullMessage.message = msg
} }
// Detect view-once media on stanza 1 received by linked device.
// viewOnceMessage wrapper is also used for interactive messages
// (interactiveMessage, listMessage, nativeFlowMessage) -- those do NOT have
// imageMessage.viewOnce / videoMessage.viewOnce / audioMessage.viewOnce = true.
// Only real view-once media carries viewOnce: true on the inner media message.
const viewOnceInner =
msg.viewOnceMessage?.message ||
msg.viewOnceMessageV2?.message ||
msg.viewOnceMessageV2Extension?.message
if (
viewOnceInner?.imageMessage?.viewOnce ||
viewOnceInner?.videoMessage?.viewOnce ||
viewOnceInner?.audioMessage?.viewOnce
) {
fullMessage.key.isViewOnce = true
}
} catch (err: any) { } catch (err: any) {
// Check if this is a final failure after all retries exhausted // Check if this is a final failure after all retries exhausted
const isRetryExhausted = err instanceof RetryExhaustedError const isRetryExhausted = err instanceof RetryExhaustedError
+2 -19
View File
@@ -15,8 +15,7 @@ import {
import { toNumber } from './generics' import { toNumber } from './generics'
import type { ILogger } from './logger.js' import type { ILogger } from './logger.js'
import { normalizeMessageContent } from './messages' import { normalizeMessageContent } from './messages'
import { DEFAULT_ORIGIN } from '../Defaults' import { downloadContentFromMessage } from './messages-media'
import { downloadContentFromMessage, getUrlFromDirectPath } from './messages-media'
const inflatePromise = promisify(inflate) const inflatePromise = promisify(inflate)
@@ -424,23 +423,7 @@ export const downloadAndProcessHistorySyncNotification = async (
historyMsg = await downloadHistory(msg, options) historyMsg = await downloadHistory(msg, options)
} }
const result = processHistoryMessage(historyMsg, logger) return processHistoryMessage(historyMsg, logger)
// Mirror WA Desktop behaviour: DELETE the CDN blob only after processing succeeds.
// Doing this earlier (e.g. inside downloadHistory) risks permanent history loss if
// processing throws — the server copy would be gone and retry after reconnect would fail.
if (msg.directPath) {
const cdnUrl = getUrlFromDirectPath(msg.directPath)
fetch(cdnUrl, {
...options,
method: 'DELETE',
headers: { ...((options as RequestInit).headers ?? {}), Origin: DEFAULT_ORIGIN }
}).catch(() => {
// non-fatal — server will expire it anyway
})
}
return result
} }
/** /**
+6 -32
View File
@@ -10,13 +10,7 @@ import { join } from 'path'
import { Readable, Transform } from 'stream' import { Readable, Transform } from 'stream'
import { URL } from 'url' import { URL } from 'url'
import { proto } from '../../WAProto/index.js' import { proto } from '../../WAProto/index.js'
import { import { DEFAULT_ORIGIN, MEDIA_HKDF_KEY_MAPPING, MEDIA_PATH_MAP, type MediaType } from '../Defaults'
DEFAULT_ORIGIN,
MEDIA_HKDF_KEY_MAPPING,
MEDIA_PATH_MAP,
type MediaType,
NEWSLETTER_MEDIA_PATH_MAP
} from '../Defaults'
import type { import type {
BaileysEventMap, BaileysEventMap,
DownloadableMessage, DownloadableMessage,
@@ -687,10 +681,6 @@ type MediaUploadResult = {
meta_hmac?: string meta_hmac?: string
ts?: number ts?: number
fbid?: number fbid?: number
thumbnail_info?: {
thumbnail_sha256?: string
thumbnail_direct_path?: string
}
} }
export type UploadParams = { export type UploadParams = {
@@ -835,21 +825,11 @@ export const getWAUploadToServer = (
{ customUploadHosts, fetchAgent, logger, options }: SocketConfig, { customUploadHosts, fetchAgent, logger, options }: SocketConfig,
refreshMediaConn: (force: boolean) => Promise<MediaConnInfo> refreshMediaConn: (force: boolean) => Promise<MediaConnInfo>
): WAMediaUploadFunction => { ): WAMediaUploadFunction => {
return async (filePath, { mediaType, fileEncSha256B64, timeoutMs, newsletter }) => { return async (filePath, { mediaType, fileEncSha256B64, timeoutMs }) => {
// send a query JSON to obtain the url & auth token to upload our media // send a query JSON to obtain the url & auth token to upload our media
let uploadInfo = await refreshMediaConn(false) let uploadInfo = await refreshMediaConn(false)
let urls: let urls: { mediaUrl: string; directPath: string; meta_hmac?: string; ts?: number; fbid?: number } | undefined
| {
mediaUrl: string
directPath: string
meta_hmac?: string
ts?: number
fbid?: number
thumbnailDirectPath?: string
thumbnailSha256?: string
}
| undefined
const hosts = [...customUploadHosts, ...uploadInfo.hosts] const hosts = [...customUploadHosts, ...uploadInfo.hosts]
fileEncSha256B64 = encodeBase64EncodedStringForUpload(fileEncSha256B64) fileEncSha256B64 = encodeBase64EncodedStringForUpload(fileEncSha256B64)
@@ -871,11 +851,7 @@ export const getWAUploadToServer = (
logger.debug(`uploading to "${hostname}"`) logger.debug(`uploading to "${hostname}"`)
const auth = encodeURIComponent(uploadInfo.auth) const auth = encodeURIComponent(uploadInfo.auth)
const mediaPath = (newsletter ? NEWSLETTER_MEDIA_PATH_MAP[mediaType] : undefined) || MEDIA_PATH_MAP[mediaType] const url = `https://${hostname}${MEDIA_PATH_MAP[mediaType]}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}`
let url = `https://${hostname}${mediaPath}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}`
if (newsletter) {
url += '&server_thumb_gen=1'
}
let result: MediaUploadResult | undefined let result: MediaUploadResult | undefined
try { try {
@@ -892,13 +868,11 @@ export const getWAUploadToServer = (
if (result?.url || result?.direct_path) { if (result?.url || result?.direct_path) {
urls = { urls = {
mediaUrl: result.url || result.direct_path!, mediaUrl: result.url!,
directPath: result.direct_path!, directPath: result.direct_path!,
meta_hmac: result.meta_hmac, meta_hmac: result.meta_hmac,
fbid: result.fbid, fbid: result.fbid,
ts: result.ts, ts: result.ts
thumbnailDirectPath: result.thumbnail_info?.thumbnail_direct_path,
thumbnailSha256: result.thumbnail_info?.thumbnail_sha256
} }
break break
} else { } else {
+14 -46
View File
@@ -196,11 +196,10 @@ export const prepareWAMessageMedia = async (
) )
const fileSha256B64 = fileSha256.toString('base64') const fileSha256B64 = fileSha256.toString('base64')
const { directPath, thumbnailDirectPath, thumbnailSha256 } = await options.upload(filePath, { const { mediaUrl, directPath } = await options.upload(filePath, {
fileEncSha256B64: fileSha256B64, fileEncSha256B64: fileSha256B64,
mediaType: mediaType, mediaType: mediaType,
timeoutMs: options.mediaUploadTimeoutMs, timeoutMs: options.mediaUploadTimeoutMs
newsletter: true
}) })
await fs.unlink(filePath) await fs.unlink(filePath)
@@ -208,12 +207,10 @@ export const prepareWAMessageMedia = async (
const obj = WAProto.Message.fromObject({ const obj = WAProto.Message.fromObject({
// todo: add more support here // todo: add more support here
[`${mediaType}Message`]: (MessageTypeProto as any)[mediaType].fromObject({ [`${mediaType}Message`]: (MessageTypeProto as any)[mediaType].fromObject({
// url intentionally omitted — newsletters use directPath only url: mediaUrl,
directPath, directPath,
fileSha256, fileSha256,
fileLength, fileLength,
thumbnailDirectPath,
thumbnailSha256: thumbnailSha256 ? Buffer.from(thumbnailSha256, 'base64') : undefined,
...uploadData, ...uploadData,
media: undefined media: undefined
}) })
@@ -239,7 +236,7 @@ export const prepareWAMessageMedia = async (
const requiresDurationComputation = mediaType === 'audio' && typeof uploadData.seconds === 'undefined' const requiresDurationComputation = mediaType === 'audio' && typeof uploadData.seconds === 'undefined'
const requiresThumbnailComputation = const requiresThumbnailComputation =
(mediaType === 'image' || mediaType === 'video') && typeof uploadData['jpegThumbnail'] === 'undefined' (mediaType === 'image' || mediaType === 'video') && typeof uploadData['jpegThumbnail'] === 'undefined'
const requiresWaveformProcessing = mediaType === 'audio' && uploadData.ptt === true && typeof uploadData.waveform === 'undefined' const requiresWaveformProcessing = mediaType === 'audio' && uploadData.ptt === true
const requiresAudioBackground = options.backgroundColor && mediaType === 'audio' && uploadData.ptt === true const requiresAudioBackground = options.backgroundColor && mediaType === 'audio' && uploadData.ptt === true
const requiresOriginalForSomeProcessing = requiresDurationComputation || requiresThumbnailComputation const requiresOriginalForSomeProcessing = requiresDurationComputation || requiresThumbnailComputation
const { mediaKey, encFilePath, originalFilePath, fileEncSha256, fileSha256, fileLength } = await encryptedStream( const { mediaKey, encFilePath, originalFilePath, fileEncSha256, fileSha256, fileLength } = await encryptedStream(
@@ -257,9 +254,7 @@ export const prepareWAMessageMedia = async (
(async () => { (async () => {
const result = await options.upload(encFilePath, { const result = await options.upload(encFilePath, {
fileEncSha256B64, fileEncSha256B64,
// Use mediaTypeOverride for upload path (e.g. 'viewonce-image' → /o1/mms/image) mediaType,
// Encryption already used the override via encryptedStream above
mediaType: options.mediaTypeOverride || mediaType,
timeoutMs: options.mediaUploadTimeoutMs timeoutMs: options.mediaUploadTimeoutMs
}) })
logger?.debug({ mediaType, cacheableKey }, 'uploaded media') logger?.debug({ mediaType, cacheableKey }, 'uploaded media')
@@ -297,7 +292,7 @@ export const prepareWAMessageMedia = async (
logger?.debug('computed audio duration') logger?.debug('computed audio duration')
} }
if (requiresWaveformProcessing && !uploadData.waveform) { if (requiresWaveformProcessing) {
uploadData.waveform = await getAudioWaveform(filePath, logger) uploadData.waveform = await getAudioWaveform(filePath, logger)
logger?.debug('processed waveform') logger?.debug('processed waveform')
} }
@@ -1752,49 +1747,22 @@ export const generateWAMessageContent = async (
} }
} }
} else { } else {
// For view-once media, upload to the one-time CDN (/o1/mms/*) instead of regular CDN m = await prepareWAMessageMedia(message, options)
// WA Web view-once messages always have directPath: /o1/v/t24/... (not /v/t62.7118-24/...)
// Using the wrong CDN causes WA server to flag the account and breaks view-once capability
const isViewOnce = hasOptionalProperty(message, 'viewOnce') && !!message.viewOnce
const viewOnceTypeMap: Record<string, string> = {
image: 'viewonce-image',
video: 'viewonce-video',
audio: 'viewonce-audio'
}
const viewOnceOverride = isViewOnce
? viewOnceTypeMap[Object.keys(message).find(k => k in viewOnceTypeMap) || '']
: undefined
m = await prepareWAMessageMedia(message, {
...options,
mediaTypeOverride: (viewOnceOverride as any) || options.mediaTypeOverride
})
} }
if (hasOptionalProperty(message, 'viewOnce') && !!message.viewOnce) { if (hasOptionalProperty(message, 'viewOnce') && !!message.viewOnce) {
// Also set viewOnce=true on the inner media message (required by WA app to render as view-once) m = { viewOnceMessage: { message: m } }
if (m.imageMessage) m.imageMessage.viewOnce = true
else if (m.videoMessage) m.videoMessage.viewOnce = true
else if (m.audioMessage) m.audioMessage.viewOnce = true
// Use viewOnceMessageV2 (field 55) — modern WA clients use this wrapper
// viewOnceMessage (field 37) is the old format which WA server no longer accepts with view_once_write
m = { viewOnceMessageV2: { message: m } }
} }
if ( if (hasOptionalProperty(message, 'mentions') && message.mentions?.length) {
(hasOptionalProperty(message, 'mentions') && message.mentions?.length) ||
(hasOptionalProperty(message, 'mentionAll') && message.mentionAll)
) {
const messageType = Object.keys(m)[0] as Extract<keyof proto.IMessage, MessageWithContextInfo> const messageType = Object.keys(m)[0] as Extract<keyof proto.IMessage, MessageWithContextInfo>
if (messageType) { if (messageType) {
const key = m[messageType] const key = m[messageType]
if (key && 'contextInfo' in key) { if (key && 'contextInfo' in key && !!key.contextInfo) {
key.contextInfo = key.contextInfo || {} key.contextInfo.mentionedJid = message.mentions
if (message.mentions?.length) { } else if (key) {
key.contextInfo.mentionedJid = message.mentions key.contextInfo = {
} mentionedJid: message.mentions
if (message.mentionAll) {
key.contextInfo.nonJidMentions = 1
} }
} }
} }
+8 -15
View File
@@ -10,7 +10,7 @@ import {
} from './Protocols' } from './Protocols'
import { USyncUser } from './USyncUser' import { USyncUser } from './USyncUser'
export type USyncQueryResultList = { [protocol: string]: unknown; id: string; rawId?: number } export type USyncQueryResultList = { [protocol: string]: unknown; id: string }
export type USyncQueryResult = { export type USyncQueryResult = {
list: USyncQueryResultList[] list: USyncQueryResultList[]
@@ -68,8 +68,10 @@ export class USyncQuery {
//TODO: see if there are any errors in the result node //TODO: see if there are any errors in the result node
//const resultNode = getBinaryNodeChild(usyncNode, 'result') //const resultNode = getBinaryNodeChild(usyncNode, 'result')
const parseNodeList = (content: BinaryNode[]): USyncQueryResultList[] => const listNode = usyncNode ? getBinaryNodeChild(usyncNode, 'list') : undefined
content.reduce((acc: USyncQueryResultList[], node) => {
if (listNode?.content && Array.isArray(listNode.content)) {
queryResult.list = listNode.content.reduce((acc: USyncQueryResultList[], node) => {
const id = node?.attrs.jid const id = node?.attrs.jid
if (id) { if (id) {
const data = Array.isArray(node?.content) const data = Array.isArray(node?.content)
@@ -87,24 +89,15 @@ export class USyncQuery {
.filter(([, b]) => b !== null) as [string, unknown][] .filter(([, b]) => b !== null) as [string, unknown][]
) )
: {} : {}
const rawIdAttr = node?.attrs?.['raw_id'] acc.push({ ...data, id })
const rawId = rawIdAttr !== undefined ? Number(rawIdAttr) : undefined
acc.push({ ...data, id, ...(rawId !== undefined && !isNaN(rawId) ? { rawId } : {}) })
} }
return acc return acc
}, []) }, [])
const listNode = usyncNode ? getBinaryNodeChild(usyncNode, 'list') : undefined
if (listNode?.content && Array.isArray(listNode.content)) {
queryResult.list = parseNodeList(listNode.content)
}
const sideListNode = usyncNode ? getBinaryNodeChild(usyncNode, 'side_list') : undefined
if (sideListNode?.content && Array.isArray(sideListNode.content)) {
queryResult.sideList = parseNodeList(sideListNode.content)
} }
//TODO: implement side list
//const sideListNode = getBinaryNodeChild(usyncNode, 'side_list')
return queryResult return queryResult
} }