Compare commits

...

7 Commits

Author SHA1 Message Date
Renato Alcara 9181d01339 fix: mediaUrl fallback to directPath for newsletter uploads
Newsletter endpoints return only direct_path (no url field).
Use direct_path as fallback so mediaUrl is never silently undefined.
Also update WAMediaUploadFunction return type to reflect that
mediaUrl may be undefined when only direct_path is present.

Resolves Copilot review comments on PR #311.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:59:06 -03:00
Renato Alcara 60798b77ff fix: use newsletter-specific upload paths for channel media
Port of WhiskeySockets/Baileys#2434 by @alesdi.

Newsletter media uploads were using regular /mms/* paths instead of
newsletter-specific paths, causing broken/invisible media in channels
(ACK error 479) and incorrect directPath values.

Changes:
- Add NEWSLETTER_MEDIA_PATH_MAP constant with /newsletter/* paths
- Pass newsletter:true to upload function for newsletter media
- Use newsletter paths + server_thumb_gen=1 query param in upload URL
- Return thumbnailDirectPath/thumbnailSha256 from upload response
- Omit url field for newsletter messages (directPath only)
- Add mediatype attribute to plaintext node for newsletter stanzas
- Extend WAMediaUploadFunction type with newsletter option + thumbnail fields
2026-03-20 11:37:13 -03:00
Renato Alcara d2db4cec97 chore: update WhatsApp Web version to v2.3000.1035595667 (#308)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-20 06:31:30 -03:00
github-actions[bot] be44c5fdb0 chore: update proto/version to v2.3000.1035577069 (#307)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-20 01:30:09 -03:00
Renato Alcara f63f0a766f fix: align Bad MAC retry receipt with WA Desktop behavior
fix: align Bad MAC retry receipt with WA Desktop behavior
2026-03-19 23:48:44 -03:00
Renato Alcara fbefa6a0ad fix: replace magic numbers with RetryReason enum constants
Address remaining Copilot review comments on PR #306:

- Import RetryReason enum from Utils (already re-exported via Utils/index.ts)
- Replace all numeric literals (0/1/2/3/4/7) in retryErrorCode with the
  corresponding enum members (UnknownError / SignalErrorNoSession / etc.)
- Removes inline comments that were only needed to explain magic numbers
- If RetryReason values ever change, the compiler will surface the drift

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:44:34 -03:00
Renato Alcara 9c4cdc3e02 fix: use DECRYPTION_RETRY_CONFIG constants for retry error code derivation
Address Copilot review comments on PR #306:

1. Replace ad-hoc regexes with the canonical DECRYPTION_RETRY_CONFIG error
   lists from decode-wa-message.ts (single source of truth). Any future
   additions to those lists are automatically picked up here.

2. Add coverage for MessageCounterError ('Key used already or never filled')
   which was previously returning code 0. It now correctly maps to code 4
   (SignalErrorInvalidMessage) via corruptedSessionErrors.

3. Broaden session-error matching to also catch libsignal variants like
   'No sessions', 'No open session', 'No sessions available' via the
   /no (open )?sessions?/ regex alongside sessionRecordErrors.

4. Fix comment: clarify that code 7 = BadMac and code 4 = InvalidMessage
   are distinct codes (previous comment conflated them).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:29:46 -03:00
10 changed files with 111 additions and 37 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
syntax = "proto3"; syntax = "proto3";
package proto; package proto;
/// WhatsApp Version: 2.3000.1035484955 /// WhatsApp Version: 2.3000.1035577069
message ADVDeviceIdentity { message ADVDeviceIdentity {
optional uint32 rawId = 1; optional uint32 rawId = 1;
@@ -2149,7 +2149,6 @@ 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;
} }
} }
@@ -4893,6 +4892,7 @@ 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 {
+3 -2
View File
@@ -5356,8 +5356,7 @@ 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
} }
} }
@@ -12483,6 +12482,7 @@ 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,6 +12491,7 @@ 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;
+21 -9
View File
@@ -26692,10 +26692,6 @@ 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);
@@ -36778,10 +36774,6 @@ 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)
@@ -36846,7 +36838,6 @@ 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;
})(); })();
@@ -87581,6 +87572,7 @@ 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;
@@ -87596,6 +87588,12 @@ 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);
}; };
@@ -87615,6 +87613,8 @@ 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,6 +87647,10 @@ 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;
@@ -87683,6 +87687,9 @@ 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;
}; };
@@ -87716,6 +87723,11 @@ 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;
}; };
+1 -1
View File
@@ -1 +1 @@
{"version":[2,3000,1035504971]} {"version":[2,3000,1035595667]}
+9
View File
@@ -157,6 +157,15 @@ export const MEDIA_PATH_MAP: { [T in MediaType]?: string } = {
'thumbnail-sticker-pack': '/mms/thumbnail-sticker-pack' 'thumbnail-sticker-pack': '/mms/thumbnail-sticker-pack'
} }
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 = {
audio: 'Audio', audio: 'Audio',
document: 'Document', document: 'Document',
+26 -11
View File
@@ -49,9 +49,12 @@ 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,
@@ -1312,25 +1315,37 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const fromJid = node.attrs.from! const fromJid = node.attrs.from!
// Derive the Signal error code from the actual decryption failure message. // Derive the Signal error code from the actual decryption failure message.
// This is sent in the retry receipt so the peer (even another InfiniteAPI instance) // Sent in the retry receipt so the peer (even another InfiniteAPI instance)
// knows the exact reason and can recreate the session immediately instead of waiting // knows the exact failure type and can recreate the session immediately
// for the 1-hour timeout fallback. // instead of falling back to the 1-hour timeout.
// //
// Codes mirror RetryReason enum in message-retry-manager.ts: // Codes mirror RetryReason enum in message-retry-manager.ts:
// 0 = UnknownError | 1 = NoSession | 2 = InvalidKey // 0 = UnknownError | 1 = NoSession | 2 = InvalidKey
// 3 = InvalidKeyId | 7 = BadMac (= SignalErrorInvalidMessage/InvalidCipherKey) // 3 = InvalidKeyId | 4 = InvalidMessage | 7 = BadMac
//
// Uses DECRYPTION_RETRY_CONFIG error lists (single source of truth in
// decode-wa-message.ts) so additions to those lists are picked up here
// automatically.
// //
// NOTE: We do NOT delete the session here (receiver side). The Signal Protocol // NOTE: We do NOT delete the session here (receiver side). The Signal Protocol
// recovers automatically when the sender's pkmsg arrives — it overwrites the // recovers automatically when the sender's pkmsg arrives — it overwrites the
// corrupted session. Deleting prematurely creates a race window where no session // corrupted session. Deleting prematurely creates a race window where no session
// exists, which can cause "No Session" errors on concurrent messages. // exists, which can cause "No Session" errors on concurrent messages.
const retryErrorCode = (() => { const retryErrorCode = (() => {
if (!decryptionError) return 0 if (!decryptionError) return RetryReason.UnknownError
if (/bad\s*mac/i.test(decryptionError)) return 7 // SignalErrorBadMac // Bad MAC must be checked first — it is also in corruptedSessionErrors
if (/no\s*session/i.test(decryptionError)) return 1 // SignalErrorNoSession // but warrants the more specific code 7 over the generic code 4.
if (/pre\s*key/i.test(decryptionError)) return 3 // SignalErrorInvalidKeyId if (decryptionError.includes(BAD_MAC_ERROR_TEXT)) return RetryReason.SignalErrorBadMac
if (/invalid\s*key/i.test(decryptionError)) return 2 // SignalErrorInvalidKey // MessageCounterError and other corrupted-session variants
return 0 if (DECRYPTION_RETRY_CONFIG.corruptedSessionErrors.some(e => decryptionError.includes(e))) return RetryReason.SignalErrorInvalidMessage
// Missing / invalid session record
if (DECRYPTION_RETRY_CONFIG.sessionRecordErrors.some(e => decryptionError.includes(e)) ||
/no\s+(open\s+)?sessions?/i.test(decryptionError)) return RetryReason.SignalErrorNoSession
// PreKey / key-id errors
if (/pre\s*key/i.test(decryptionError)) return RetryReason.SignalErrorInvalidKeyId
// Identity / key errors
if (/invalid\s*key|untrusted\s*identity/i.test(decryptionError)) return RetryReason.SignalErrorInvalidKey
return RetryReason.UnknownError
})() })()
if (retryCount <= 2) { if (retryCount <= 2) {
+1 -1
View File
@@ -1051,7 +1051,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: {}, attrs: mediaType ? { mediatype: mediaType } : {},
content: bytes content: bytes
}) })
const stanza: BinaryNode = { const stanza: BinaryNode = {
+10 -2
View File
@@ -990,8 +990,16 @@ export type MessageGenerationOptionsFromContent = MiscMessageGenerationOptions &
export type WAMediaUploadFunction = ( export type WAMediaUploadFunction = (
encFilePath: string, encFilePath: string,
opts: { fileEncSha256B64: string; mediaType: MediaType; timeoutMs?: number } opts: { fileEncSha256B64: string; mediaType: MediaType; timeoutMs?: number; newsletter?: boolean }
) => Promise<{ mediaUrl: string; directPath: string; meta_hmac?: string; ts?: number; fbid?: number }> ) => Promise<{
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
+32 -6
View File
@@ -10,7 +10,13 @@ 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 { DEFAULT_ORIGIN, MEDIA_HKDF_KEY_MAPPING, MEDIA_PATH_MAP, type MediaType } from '../Defaults' import {
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,
@@ -681,6 +687,10 @@ 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 = {
@@ -825,11 +835,21 @@ 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 }) => { return async (filePath, { mediaType, fileEncSha256B64, timeoutMs, newsletter }) => {
// 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: { mediaUrl: string; directPath: string; meta_hmac?: string; ts?: number; fbid?: number } | undefined let urls:
| {
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)
@@ -851,7 +871,11 @@ export const getWAUploadToServer = (
logger.debug(`uploading to "${hostname}"`) logger.debug(`uploading to "${hostname}"`)
const auth = encodeURIComponent(uploadInfo.auth) const auth = encodeURIComponent(uploadInfo.auth)
const url = `https://${hostname}${MEDIA_PATH_MAP[mediaType]}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}` const mediaPath = (newsletter ? NEWSLETTER_MEDIA_PATH_MAP[mediaType] : undefined) || MEDIA_PATH_MAP[mediaType]
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 {
@@ -868,11 +892,13 @@ export const getWAUploadToServer = (
if (result?.url || result?.direct_path) { if (result?.url || result?.direct_path) {
urls = { urls = {
mediaUrl: result.url!, mediaUrl: result.url || result.direct_path!,
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 {
+6 -3
View File
@@ -196,10 +196,11 @@ export const prepareWAMessageMedia = async (
) )
const fileSha256B64 = fileSha256.toString('base64') const fileSha256B64 = fileSha256.toString('base64')
const { mediaUrl, directPath } = await options.upload(filePath, { const { directPath, thumbnailDirectPath, thumbnailSha256 } = 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)
@@ -207,10 +208,12 @@ 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: mediaUrl, // url intentionally omitted — newsletters use directPath only
directPath, directPath,
fileSha256, fileSha256,
fileLength, fileLength,
thumbnailDirectPath,
thumbnailSha256: thumbnailSha256 ? Buffer.from(thumbnailSha256, 'base64') : undefined,
...uploadData, ...uploadData,
media: undefined media: undefined
}) })