-
Notifications
You must be signed in to change notification settings - Fork 5.7k
feat(media): full expired CDN recovery pipeline — retryMediaFromMetadata, fetchGroupHistory, batchRecoverMedia #2465
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
f268571
262c930
5e2f077
28fc185
f6348f6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -3889,8 +3889,21 @@ export class BaileysStartupService extends ChannelStartupService { | |||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if (typeof mediaMessage['mediaKey'] === 'object') { | ||||||||||||||||||||
| msg.message[mediaType].mediaKey = Uint8Array.from(Object.values(mediaMessage['mediaKey'])); | ||||||||||||||||||||
| if (typeof mediaMessage['mediaKey'] === 'string') { | ||||||||||||||||||||
| // base64-encoded string (e.g. from HTTP request body) → Uint8Array | ||||||||||||||||||||
| // This matches OwnPilot retryMediaFromMetadata: new Uint8Array(Buffer.from(base64, 'base64')) | ||||||||||||||||||||
| msg.message[mediaType].mediaKey = new Uint8Array(Buffer.from(mediaMessage['mediaKey'], 'base64')); | ||||||||||||||||||||
| } else if ( | ||||||||||||||||||||
| typeof mediaMessage['mediaKey'] === 'object' && | ||||||||||||||||||||
| !Buffer.isBuffer(mediaMessage['mediaKey']) && | ||||||||||||||||||||
| !(mediaMessage['mediaKey'] instanceof Uint8Array) | ||||||||||||||||||||
| ) { | ||||||||||||||||||||
| // Plain object {0:b0, 1:b1, ...} from PostgreSQL JSONB deserialization. | ||||||||||||||||||||
| // CRITICAL: JSONB stores keys lexicographically ("0","1","10","11",...,"2",...,"9") | ||||||||||||||||||||
| // so Object.values() gives WRONG byte order. Must sort keys numerically first. | ||||||||||||||||||||
| const keyObj = mediaMessage['mediaKey'] as Record<string, number>; | ||||||||||||||||||||
| const sortedKeys = Object.keys(keyObj).sort((a, b) => parseInt(a, 10) - parseInt(b, 10)); | ||||||||||||||||||||
| msg.message[mediaType].mediaKey = new Uint8Array(sortedKeys.map((k) => keyObj[k])); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| let buffer: Buffer; | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: Capture and log the original error in the outer The empty outer
Suggested change
|
||||||||||||||||||||
|
|
@@ -3903,30 +3916,57 @@ export class BaileysStartupService extends ChannelStartupService { | |||||||||||||||||||
| { logger: P({ level: 'error' }) as any, reuploadRequest: this.client.updateMediaMessage }, | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| } catch { | ||||||||||||||||||||
| this.logger.error('Download Media failed, trying to retry in 5 seconds...'); | ||||||||||||||||||||
| await new Promise((resolve) => setTimeout(resolve, 5000)); | ||||||||||||||||||||
| const mediaType = Object.keys(msg.message).find((key) => key.endsWith('Message')); | ||||||||||||||||||||
| if (!mediaType) throw new Error('Could not determine mediaType for fallback'); | ||||||||||||||||||||
| this.logger.error('Download Media failed, attempting explicit updateMediaMessage (Baileys RC9 reuploadRequest bug workaround)...'); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Baileys RC9 bug: reuploadRequest callback never triggers because downloadMediaMessage | ||||||||||||||||||||
| // checks error.status but Boom sets output.statusCode — so the automatic re-upload path | ||||||||||||||||||||
| // is dead code. Fix: explicitly call updateMediaMessage to get a fresh CDN URL, | ||||||||||||||||||||
| // then retry the download. This mirrors the technique used in OwnPilot retryMediaFromMetadata(). | ||||||||||||||||||||
| try { | ||||||||||||||||||||
| const media = await downloadContentFromMessage( | ||||||||||||||||||||
| { | ||||||||||||||||||||
| mediaKey: msg.message?.[mediaType]?.mediaKey, | ||||||||||||||||||||
| directPath: msg.message?.[mediaType]?.directPath, | ||||||||||||||||||||
| url: `https://mmg.whatsapp.net${msg?.message?.[mediaType]?.directPath}`, | ||||||||||||||||||||
| }, | ||||||||||||||||||||
| await this.mapMediaType(mediaType), | ||||||||||||||||||||
| const REUPLOAD_TIMEOUT_MS = 30_000; | ||||||||||||||||||||
| this.logger.info('Requesting media re-upload from sender device via updateMediaMessage...'); | ||||||||||||||||||||
| const updatedMsg = await Promise.race([ | ||||||||||||||||||||
| this.client.updateMediaMessage({ key: msg.key, message: msg.message }), | ||||||||||||||||||||
| new Promise<never>((_, reject) => | ||||||||||||||||||||
| setTimeout( | ||||||||||||||||||||
| () => reject(new Error('updateMediaMessage timed out after 30s — sender device may be offline')), | ||||||||||||||||||||
| REUPLOAD_TIMEOUT_MS, | ||||||||||||||||||||
| ), | ||||||||||||||||||||
| ), | ||||||||||||||||||||
| ]); | ||||||||||||||||||||
| buffer = await downloadMediaMessage( | ||||||||||||||||||||
| updatedMsg, | ||||||||||||||||||||
| 'buffer', | ||||||||||||||||||||
| {}, | ||||||||||||||||||||
| { logger: P({ level: 'error' }) as any, reuploadRequest: this.client.updateMediaMessage }, | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| const chunks = []; | ||||||||||||||||||||
| for await (const chunk of media) { | ||||||||||||||||||||
| chunks.push(chunk); | ||||||||||||||||||||
| this.logger.info('Download Media successful after explicit updateMediaMessage!'); | ||||||||||||||||||||
| } catch (reuploadErr) { | ||||||||||||||||||||
| this.logger.error(`updateMediaMessage failed: ${reuploadErr?.message} — falling back to downloadContentFromMessage...`); | ||||||||||||||||||||
| await new Promise((resolve) => setTimeout(resolve, 5000)); | ||||||||||||||||||||
| const mediaType = Object.keys(msg.message).find((key) => key.endsWith('Message')); | ||||||||||||||||||||
| if (!mediaType) throw new Error('Could not determine mediaType for fallback'); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| try { | ||||||||||||||||||||
| const media = await downloadContentFromMessage( | ||||||||||||||||||||
| { | ||||||||||||||||||||
| mediaKey: msg.message?.[mediaType]?.mediaKey, | ||||||||||||||||||||
| directPath: msg.message?.[mediaType]?.directPath, | ||||||||||||||||||||
| url: `https://mmg.whatsapp.net${msg?.message?.[mediaType]?.directPath}`, | ||||||||||||||||||||
| }, | ||||||||||||||||||||
| await this.mapMediaType(mediaType), | ||||||||||||||||||||
| {}, | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| const chunks = []; | ||||||||||||||||||||
| for await (const chunk of media) { | ||||||||||||||||||||
| chunks.push(chunk); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| buffer = Buffer.concat(chunks); | ||||||||||||||||||||
| this.logger.info('Download Media with downloadContentFromMessage was successful!'); | ||||||||||||||||||||
| } catch (fallbackErr) { | ||||||||||||||||||||
| this.logger.error('Download Media with downloadContentFromMessage also failed!'); | ||||||||||||||||||||
| throw fallbackErr; | ||||||||||||||||||||
| } | ||||||||||||||||||||
| buffer = Buffer.concat(chunks); | ||||||||||||||||||||
| this.logger.info('Download Media with downloadContentFromMessage was successful!'); | ||||||||||||||||||||
| } catch (fallbackErr) { | ||||||||||||||||||||
| this.logger.error('Download Media with downloadContentFromMessage also failed!'); | ||||||||||||||||||||
| throw fallbackErr; | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
| const typeMessage = getContentType(msg.message); | ||||||||||||||||||||
|
|
||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
issue: Guard against
mediaKeybeingnullbefore treating it as a plain object.Since
typeof null === 'object', amediaKeyofnullwill hit this branch, be cast toRecord<string, number>, and causeObject.keys(keyObj)to throw. Ifnullis a possible value (e.g. malformed input or partial DB data), add an explicit non-null check or a more specific guard (e.g.mediaMessage['mediaKey'] && typeof mediaMessage['mediaKey'] === 'object', or anArray.isArray/instanceofcheck) before entering this path.