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}