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";
package proto;
/// WhatsApp Version: 2.3000.1035484955
/// WhatsApp Version: 2.3000.1035577069
message ADVDeviceIdentity {
optional uint32 rawId = 1;
@@ -2149,7 +2149,6 @@ message LimitSharing {
CHAT_SETTING = 1;
BIZ_SUPPORTS_FB_HOSTING = 2;
UNKNOWN_GROUP = 3;
DEPRECATION = 4;
}
}
@@ -4893,6 +4892,7 @@ message SyncActionValue {
repeated SyncActionValue.BroadcastListParticipant participants = 2;
optional string listName = 3;
repeated string labelIds = 4;
optional string audienceExpression = 5;
}
message CallLogAction {
+3 -2
View File
@@ -5356,8 +5356,7 @@ export namespace proto {
UNKNOWN = 0,
CHAT_SETTING = 1,
BIZ_SUPPORTS_FB_HOSTING = 2,
UNKNOWN_GROUP = 3,
DEPRECATION = 4
UNKNOWN_GROUP = 3
}
}
@@ -12483,6 +12482,7 @@ export namespace proto {
participants?: (proto.SyncActionValue.IBroadcastListParticipant[]|null);
listName?: (string|null);
labelIds?: (string[]|null);
audienceExpression?: (string|null);
}
class BusinessBroadcastListAction implements IBusinessBroadcastListAction {
@@ -12491,6 +12491,7 @@ export namespace proto {
public participants: proto.SyncActionValue.IBroadcastListParticipant[];
public listName?: (string|null);
public labelIds: string[];
public audienceExpression?: (string|null);
public static create(properties?: proto.SyncActionValue.IBusinessBroadcastListAction): proto.SyncActionValue.BusinessBroadcastListAction;
public static encode(m: proto.SyncActionValue.IBusinessBroadcastListAction, w?: $protobuf.Writer): $protobuf.Writer;
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:
m.limitSharingTrigger = 3;
break;
case "DEPRECATION":
case 4:
m.limitSharingTrigger = 4;
break;
}
if (d.limitSharingInitiatedByMe != null) {
m.limitSharingInitiatedByMe = Boolean(d.limitSharingInitiatedByMe);
@@ -36778,10 +36774,6 @@ export const proto = $root.proto = (() => {
case 3:
m.trigger = 3;
break;
case "DEPRECATION":
case 4:
m.trigger = 4;
break;
}
if (d.limitSharingSettingTimestamp != null) {
if ($util.Long)
@@ -36846,7 +36838,6 @@ export const proto = $root.proto = (() => {
values[valuesById[1] = "CHAT_SETTING"] = 1;
values[valuesById[2] = "BIZ_SUPPORTS_FB_HOSTING"] = 2;
values[valuesById[3] = "UNKNOWN_GROUP"] = 3;
values[valuesById[4] = "DEPRECATION"] = 4;
return values;
})();
@@ -87581,6 +87572,7 @@ export const proto = $root.proto = (() => {
BusinessBroadcastListAction.prototype.participants = $util.emptyArray;
BusinessBroadcastListAction.prototype.listName = null;
BusinessBroadcastListAction.prototype.labelIds = $util.emptyArray;
BusinessBroadcastListAction.prototype.audienceExpression = null;
let $oneOfFields;
@@ -87596,6 +87588,12 @@ export const proto = $root.proto = (() => {
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) {
return new BusinessBroadcastListAction(properties);
};
@@ -87615,6 +87613,8 @@ export const proto = $root.proto = (() => {
for (var i = 0; i < m.labelIds.length; ++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;
};
@@ -87647,6 +87647,10 @@ export const proto = $root.proto = (() => {
m.labelIds.push(r.string());
break;
}
case 5: {
m.audienceExpression = r.string();
break;
}
default:
r.skipType(t & 7);
break;
@@ -87683,6 +87687,9 @@ export const proto = $root.proto = (() => {
m.labelIds[i] = String(d.labelIds[i]);
}
}
if (d.audienceExpression != null) {
m.audienceExpression = String(d.audienceExpression);
}
return m;
};
@@ -87716,6 +87723,11 @@ export const proto = $root.proto = (() => {
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;
};
+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'
}
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 = {
audio: 'Audio',
document: 'Document',
+26 -11
View File
@@ -49,9 +49,12 @@ import {
getStatusFromReceiptType,
handleIdentityChange,
hkdf,
BAD_MAC_ERROR_TEXT,
DECRYPTION_RETRY_CONFIG,
MISSING_KEYS_ERROR_TEXT,
NACK_REASONS,
NO_MESSAGE_FOUND_ERROR_TEXT,
RetryReason,
normalizeKeyLidToPn,
normalizeMessageJids,
resolveLidToPn,
@@ -1312,25 +1315,37 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const fromJid = node.attrs.from!
// 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)
// knows the exact reason and can recreate the session immediately instead of waiting
// for the 1-hour timeout fallback.
// Sent in the retry receipt so the peer (even another InfiniteAPI instance)
// knows the exact failure type and can recreate the session immediately
// instead of falling back to the 1-hour timeout.
//
// Codes mirror RetryReason enum in message-retry-manager.ts:
// 0 = UnknownError | 1 = NoSession | 2 = InvalidKey
// 3 = InvalidKeyId | 7 = BadMac (= SignalErrorInvalidMessage/InvalidCipherKey)
// 0 = UnknownError | 1 = NoSession | 2 = InvalidKey
// 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
// recovers automatically when the sender's pkmsg arrives — it overwrites the
// corrupted session. Deleting prematurely creates a race window where no session
// exists, which can cause "No Session" errors on concurrent messages.
const retryErrorCode = (() => {
if (!decryptionError) return 0
if (/bad\s*mac/i.test(decryptionError)) return 7 // SignalErrorBadMac
if (/no\s*session/i.test(decryptionError)) return 1 // SignalErrorNoSession
if (/pre\s*key/i.test(decryptionError)) return 3 // SignalErrorInvalidKeyId
if (/invalid\s*key/i.test(decryptionError)) return 2 // SignalErrorInvalidKey
return 0
if (!decryptionError) return RetryReason.UnknownError
// Bad MAC must be checked first — it is also in corruptedSessionErrors
// but warrants the more specific code 7 over the generic code 4.
if (decryptionError.includes(BAD_MAC_ERROR_TEXT)) return RetryReason.SignalErrorBadMac
// MessageCounterError and other corrupted-session variants
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) {
+1 -1
View File
@@ -1051,7 +1051,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
const bytes = encodeNewsletterMessage(patched as proto.IMessage)
binaryNodeContent.push({
tag: 'plaintext',
attrs: {},
attrs: mediaType ? { mediatype: mediaType } : {},
content: bytes
})
const stanza: BinaryNode = {
+10 -2
View File
@@ -990,8 +990,16 @@ export type MessageGenerationOptionsFromContent = MiscMessageGenerationOptions &
export type WAMediaUploadFunction = (
encFilePath: string,
opts: { fileEncSha256B64: string; mediaType: MediaType; timeoutMs?: number }
) => Promise<{ mediaUrl: string; directPath: string; meta_hmac?: string; ts?: number; fbid?: number }>
opts: { fileEncSha256B64: string; mediaType: MediaType; timeoutMs?: number; newsletter?: boolean }
) => Promise<{
mediaUrl: string | undefined
directPath: string
meta_hmac?: string
ts?: number
fbid?: number
thumbnailDirectPath?: string
thumbnailSha256?: string
}>
export type MediaGenerationOptions = {
logger?: ILogger
+32 -6
View File
@@ -10,7 +10,13 @@ import { join } from 'path'
import { Readable, Transform } from 'stream'
import { URL } from 'url'
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 {
BaileysEventMap,
DownloadableMessage,
@@ -681,6 +687,10 @@ type MediaUploadResult = {
meta_hmac?: string
ts?: number
fbid?: number
thumbnail_info?: {
thumbnail_sha256?: string
thumbnail_direct_path?: string
}
}
export type UploadParams = {
@@ -825,11 +835,21 @@ export const getWAUploadToServer = (
{ customUploadHosts, fetchAgent, logger, options }: SocketConfig,
refreshMediaConn: (force: boolean) => Promise<MediaConnInfo>
): 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
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]
fileEncSha256B64 = encodeBase64EncodedStringForUpload(fileEncSha256B64)
@@ -851,7 +871,11 @@ export const getWAUploadToServer = (
logger.debug(`uploading to "${hostname}"`)
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
try {
@@ -868,11 +892,13 @@ export const getWAUploadToServer = (
if (result?.url || result?.direct_path) {
urls = {
mediaUrl: result.url!,
mediaUrl: result.url || result.direct_path!,
directPath: result.direct_path!,
meta_hmac: result.meta_hmac,
fbid: result.fbid,
ts: result.ts
ts: result.ts,
thumbnailDirectPath: result.thumbnail_info?.thumbnail_direct_path,
thumbnailSha256: result.thumbnail_info?.thumbnail_sha256
}
break
} else {
+6 -3
View File
@@ -196,10 +196,11 @@ export const prepareWAMessageMedia = async (
)
const fileSha256B64 = fileSha256.toString('base64')
const { mediaUrl, directPath } = await options.upload(filePath, {
const { directPath, thumbnailDirectPath, thumbnailSha256 } = await options.upload(filePath, {
fileEncSha256B64: fileSha256B64,
mediaType: mediaType,
timeoutMs: options.mediaUploadTimeoutMs
timeoutMs: options.mediaUploadTimeoutMs,
newsletter: true
})
await fs.unlink(filePath)
@@ -207,10 +208,12 @@ export const prepareWAMessageMedia = async (
const obj = WAProto.Message.fromObject({
// todo: add more support here
[`${mediaType}Message`]: (MessageTypeProto as any)[mediaType].fromObject({
url: mediaUrl,
// url intentionally omitted — newsletters use directPath only
directPath,
fileSha256,
fileLength,
thumbnailDirectPath,
thumbnailSha256: thumbnailSha256 ? Buffer.from(thumbnailSha256, 'base64') : undefined,
...uploadData,
media: undefined
})