Skip to main content

whatsapp_rust/features/
media_reupload.rs

1//! Media reupload feature: request the server to re-upload expired media.
2//!
3//! When a media download fails because the URL has expired, this feature
4//! sends a `<receipt type="server-error">` stanza and waits for a
5//! `<notification type="mediaretry">` response with a new `directPath`.
6//!
7//! Reference: WAWebRequestMediaReuploadManager.
8
9use crate::client::{Client, ClientError, NodeFilter};
10use log::debug;
11use std::time::Duration;
12use thiserror::Error;
13pub use wacore::media_retry::MediaRetryResult;
14use wacore::media_retry::{
15    build_media_retry_receipt, encrypt_media_retry_receipt, parse_media_retry_notification,
16};
17use wacore_binary::{Jid, JidExt as _};
18
19const MEDIA_RETRY_TIMEOUT: Duration = Duration::from_secs(30);
20
21/// Max media-reupload requests in flight for [`MediaReupload::request_many`].
22/// The per-item work is I/O-light (send a small receipt, park on a notification
23/// waiter), so a generous window lets the waits overlap — bulk recovery after a
24/// long offline period completes in ~one timeout instead of the sum — while
25/// still bounding how many receipts hit the socket/server at once. WA Web caps
26/// media work at a similar order (its `ConcurrentPriorityPromiseQueue`).
27const MEDIA_REUPLOAD_CONCURRENCY: usize = 32;
28
29/// Error returned by the media reupload request flow.
30#[derive(Debug, Error)]
31#[non_exhaustive]
32pub enum MediaReuploadError {
33    /// Connection/transport failure sending the server-error receipt.
34    #[error("{0}")]
35    Client(#[from] ClientError),
36    /// The client is not logged in.
37    #[error("client is not logged in")]
38    NotLoggedIn,
39    /// The request is not applicable to this message (e.g. a newsletter message
40    /// carries no media keys).
41    #[error("invalid media reupload request: {0}")]
42    InvalidRequest(String),
43    /// The server did not return a `mediaretry` notification in time.
44    #[error("media retry notification timed out")]
45    Timeout,
46    /// Catch-all for internal failures (receipt encryption, response parsing).
47    #[error("{0}")]
48    Internal(#[from] anyhow::Error),
49}
50
51/// Parameters for a media reupload request.
52pub struct MediaReuploadRequest<'a> {
53    /// The message ID containing the media.
54    pub msg_id: &'a str,
55    /// The chat JID where the message was received.
56    pub chat_jid: &'a Jid,
57    /// The raw media key bytes (32 bytes, from the message's `mediaKey` field).
58    pub media_key: &'a [u8],
59    /// Whether the message was sent by us.
60    pub is_from_me: bool,
61    /// For group/broadcast messages, the participant JID who sent the message.
62    pub participant: Option<&'a Jid>,
63}
64
65pub struct MediaReupload<'a> {
66    client: &'a Client,
67}
68
69impl<'a> MediaReupload<'a> {
70    pub(crate) fn new(client: &'a Client) -> Self {
71        Self { client }
72    }
73
74    /// Request the server to re-upload media for a message with an expired URL.
75    ///
76    /// Returns the new `directPath` on success, or an error variant indicating
77    /// why the reupload failed.
78    ///
79    /// # Protocol flow
80    /// 1. Encrypt `ServerErrorReceipt` protobuf with HKDF-derived key from media key
81    /// 2. Send `<receipt type="server-error">` with encrypted payload + `<rmr>` metadata
82    /// 3. Wait for `<notification type="mediaretry">` response
83    /// 4. Decrypt response and extract new `directPath`
84    pub async fn request(
85        &self,
86        req: &MediaReuploadRequest<'_>,
87    ) -> Result<MediaRetryResult, MediaReuploadError> {
88        // WA Web: ServerErrorReceiptJob rejects newsletter messages (no media keys).
89        if req.chat_jid.is_newsletter() {
90            return Err(MediaReuploadError::InvalidRequest(
91                "media reupload is not supported for newsletter messages".into(),
92            ));
93        }
94
95        debug!(
96            "[media][rmr] Requesting media reupload for msg {} in chat {}",
97            req.msg_id, req.chat_jid
98        );
99
100        // Encrypt the ServerErrorReceipt
101        let (ciphertext, iv) = encrypt_media_retry_receipt(req.media_key, req.msg_id)?;
102
103        // Get own JID for the receipt's `to` attribute
104        let device_snapshot = self.client.persistence_manager.get_device_snapshot();
105        let own_jid = device_snapshot
106            .pn
107            .as_ref()
108            .ok_or(MediaReuploadError::NotLoggedIn)?;
109
110        // Register waiter BEFORE sending (to avoid race)
111        let waiter = self.client.wait_for_node(
112            NodeFilter::tag("notification")
113                .attr("type", "mediaretry")
114                .attr("id", req.msg_id),
115        );
116
117        // Build and send the receipt node
118        let receipt_node = build_media_retry_receipt(
119            own_jid,
120            req.msg_id,
121            req.chat_jid,
122            req.is_from_me,
123            req.participant,
124            &ciphertext,
125            &iv,
126        );
127
128        self.client.send_node(receipt_node).await?;
129
130        debug!(
131            "[media][rmr] Sent server-error receipt for {}, waiting for response",
132            req.msg_id
133        );
134
135        // Wait for the mediaretry notification
136        let notification_node =
137            wacore::runtime::timeout(&*self.client.runtime, MEDIA_RETRY_TIMEOUT, waiter)
138                .await
139                .map_err(|_| MediaReuploadError::Timeout)?
140                .map_err(|_| {
141                    MediaReuploadError::Internal(anyhow::anyhow!("media retry waiter cancelled"))
142                })?;
143
144        debug!(
145            "[media][rmr] Received mediaretry notification for {}",
146            req.msg_id
147        );
148
149        // Parse and decrypt the response
150        Ok(parse_media_retry_notification(
151            notification_node.get(),
152            req.media_key,
153        )?)
154    }
155
156    /// Request reupload for several messages at once, concurrently.
157    ///
158    /// Each request registers its own notification waiter and awaits it
159    /// independently, so a bulk recovery — e.g. many expired-URL media after a
160    /// long offline period — runs `MEDIA_REUPLOAD_CONCURRENCY` at a time instead
161    /// of paying the serial sum of per-item waits. A batch larger than that runs
162    /// in waves, so the worst case is `ceil(len / MEDIA_REUPLOAD_CONCURRENCY)`
163    /// `MEDIA_RETRY_TIMEOUT` windows. Results are returned in the
164    /// same order as `reqs`; each entry carries that item's success or error
165    /// (one item failing never aborts the others).
166    ///
167    /// Duplicate `msg_id`s in one batch are rejected (past the first occurrence)
168    /// with [`MediaReuploadError::InvalidRequest`]. The `mediaretry` waiter
169    /// filters on message id alone and `resolve_waiters` wakes *every* match, so
170    /// two same-id waiters would cross-resolve. Serializing them isn't enough:
171    /// once the first waiter times out it lingers in `node_waiters` (canceled
172    /// waiters are purged only lazily), so its late notification could still
173    /// resolve a same-id retry with the wrong payload. Requiring unique ids means
174    /// no two waiters ever share a filter — a message id is unique per message,
175    /// so a duplicate is a caller mistake, not a real recovery target.
176    pub async fn request_many(
177        &self,
178        reqs: &[MediaReuploadRequest<'_>],
179    ) -> Vec<Result<MediaRetryResult, MediaReuploadError>> {
180        use futures::StreamExt;
181        use std::collections::HashSet;
182        if reqs.is_empty() {
183            return Vec::new();
184        }
185
186        let mut results: Vec<Option<Result<MediaRetryResult, MediaReuploadError>>> =
187            (0..reqs.len()).map(|_| None).collect();
188        let mut seen: HashSet<&str> = HashSet::with_capacity(reqs.len());
189        let mut unique: Vec<usize> = Vec::with_capacity(reqs.len());
190        for (i, req) in reqs.iter().enumerate() {
191            if seen.insert(req.msg_id) {
192                unique.push(i);
193            } else {
194                results[i] = Some(Err(MediaReuploadError::InvalidRequest(format!(
195                    "duplicate msg_id {} in batch",
196                    req.msg_id
197                ))));
198            }
199        }
200
201        // Stream over owned indices (not a borrow of `reqs` through the
202        // combinator) and index inside each task, so the fan-out future stays
203        // Send. Every id here is unique, so no two waiters share a filter.
204        let done: Vec<(usize, Result<MediaRetryResult, MediaReuploadError>)> =
205            futures::stream::iter(unique)
206                .map(|i| async move { (i, self.request(&reqs[i]).await) })
207                .buffer_unordered(MEDIA_REUPLOAD_CONCURRENCY)
208                .collect()
209                .await;
210        for (i, res) in done {
211            results[i] = Some(res);
212        }
213        results
214            .into_iter()
215            .map(|res| res.expect("every index is either a duplicate or fetched"))
216            .collect()
217    }
218}
219
220impl Client {
221    /// Access media reupload operations.
222    pub fn media_reupload(&self) -> MediaReupload<'_> {
223        MediaReupload::new(self)
224    }
225}