Skip to main content

whatsapp_rust/features/
stanza.rs

1//! Typed operations for responding to inbound protocol stanzas.
2
3use thiserror::Error;
4
5use crate::cache::Freshness;
6use crate::client::ClientError;
7use wacore_binary::Jid;
8use waproto::whatsapp as wa;
9
10pub(crate) fn required_stanza_attr<'node, 'data>(
11    node: &'node wacore_binary::NodeRef<'data>,
12    name: &'static str,
13) -> Result<&'node wacore_binary::node::ValueRef<'data>, StanzaResponseError> {
14    match node.get_attr(name) {
15        Some(value) if value != "" => Ok(value),
16        _ => Err(StanzaResponseError::MissingAttribute(name)),
17    }
18}
19
20pub use wacore::protocol::nack::NackReason;
21pub use wacore::protocol::retry::RetryReason;
22
23/// A protocol rejection sent as an `<ack error="...">` stanza.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct StanzaRejection {
26    reason: NackReason,
27    failure_reason: Option<i32>,
28}
29
30impl StanzaRejection {
31    /// Reject with a protocol reason and no protobuf failure detail.
32    pub const fn new(reason: NackReason) -> Self {
33        Self {
34            reason,
35            failure_reason: None,
36        }
37    }
38
39    /// Reject malformed protobuf content, optionally attaching its typed failure detail.
40    pub const fn invalid_protobuf(failure_reason: Option<i32>) -> Self {
41        Self {
42            reason: NackReason::InvalidProtobuf,
43            failure_reason,
44        }
45    }
46
47    /// Protocol reason encoded in the rejection.
48    pub const fn reason(self) -> NackReason {
49        self.reason
50    }
51
52    /// Optional protobuf failure detail, present only for `InvalidProtobuf`.
53    pub const fn failure_reason(self) -> Option<i32> {
54        self.failure_reason
55    }
56}
57
58impl From<NackReason> for StanzaRejection {
59    fn from(reason: NackReason) -> Self {
60        Self::new(reason)
61    }
62}
63
64/// Failure while confirming or rejecting an inbound stanza.
65#[derive(Debug, Error)]
66#[non_exhaustive]
67pub enum StanzaResponseError {
68    #[error("stanza is missing required '{0}' attribute")]
69    MissingAttribute(&'static str),
70    #[error("the local device identity is unavailable")]
71    MissingLocalIdentity,
72    #[error("the stanza class does not support this response")]
73    UnsupportedStanzaClass,
74    #[error("failed to encode stanza response")]
75    Encoding(#[from] wacore_binary::error::BinaryError),
76    #[error("{0}")]
77    Client(#[from] ClientError),
78}
79
80/// Options for requesting retransmission of one inbound message stanza.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82#[non_exhaustive]
83pub struct RetryRequestOptions {
84    reason: RetryReason,
85    force_include_keys: bool,
86}
87
88impl RetryRequestOptions {
89    /// Use the default retry reason without forcing a key bundle.
90    pub const fn new() -> Self {
91        Self {
92            reason: RetryReason::UnknownError,
93            force_include_keys: false,
94        }
95    }
96
97    /// Attach the diagnostic reason sent to the original sender.
98    pub const fn with_reason(mut self, reason: RetryReason) -> Self {
99        self.reason = reason;
100        self
101    }
102
103    /// Require key material even before the normal retry threshold.
104    pub const fn with_force_include_keys(mut self, force_include_keys: bool) -> Self {
105        self.force_include_keys = force_include_keys;
106        self
107    }
108
109    /// The diagnostic reason sent with this request.
110    pub const fn reason(self) -> RetryReason {
111        self.reason
112    }
113
114    /// Whether key material is required before the normal retry threshold.
115    pub const fn force_include_keys(self) -> bool {
116        self.force_include_keys
117    }
118}
119
120impl Default for RetryRequestOptions {
121    fn default() -> Self {
122        Self::new()
123    }
124}
125
126/// Result of a retransmission request.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128#[non_exhaustive]
129pub enum RetryRequestOutcome {
130    /// A retry receipt reached the transport.
131    Sent {
132        /// Shared attempt count encoded in the retry receipt.
133        retry_count: u8,
134        /// Whether this attempt carried the local key bundle.
135        included_keys: bool,
136    },
137    /// The protocol excludes this sender/chat combination from retry receipts.
138    Suppressed {
139        /// Shared attempt count consumed by the suppressed request.
140        retry_count: u8,
141    },
142    /// The shared retry counter had already reached its configured limit.
143    LimitReached,
144}
145
146/// Failure while parsing or sending a retransmission request.
147#[derive(Debug, Error)]
148#[non_exhaustive]
149pub enum RetryRequestError {
150    #[error("retry requests require a message stanza")]
151    UnsupportedStanzaClass,
152    #[error("stanza is missing required '{0}' attribute")]
153    MissingAttribute(&'static str),
154    #[error("the local device identity is unavailable")]
155    MissingLocalIdentity,
156    #[error("invalid message stanza")]
157    InvalidStanza(#[source] anyhow::Error),
158    #[error("{0}")]
159    Client(#[from] ClientError),
160    #[error("failed to prepare retry request")]
161    Internal(#[from] anyhow::Error),
162}
163
164/// A request to retransmit an already-sent message to one requesting device.
165///
166/// The client derives the wire stanza and owns all routing, encryption, session,
167/// sender-key, and persistence decisions. This type never accepts a pre-built
168/// retry stanza.
169#[derive(Debug)]
170#[non_exhaustive]
171pub struct MessageRetransmission {
172    pub(crate) chat: Jid,
173    pub(crate) requester: Jid,
174    pub(crate) message: wa::Message,
175    pub(crate) message_id: String,
176    pub(crate) retry_count: u8,
177    pub(crate) recipient: Option<Jid>,
178    pub(crate) group_metadata_freshness: Freshness,
179}
180
181impl MessageRetransmission {
182    /// Describe a retransmission to the device that requested it.
183    pub fn new(
184        chat: Jid,
185        requester: Jid,
186        message: wa::Message,
187        message_id: String,
188        retry_count: u8,
189    ) -> Self {
190        Self {
191            chat,
192            requester,
193            message,
194            message_id,
195            retry_count,
196            recipient: None,
197            group_metadata_freshness: Freshness::CachePreferred,
198        }
199    }
200
201    /// Preserve the receipt's recipient for self-device and bot retry routes.
202    pub fn with_recipient(mut self, recipient: Jid) -> Self {
203        self.recipient = Some(recipient);
204        self
205    }
206
207    /// Select how group metadata is obtained for this operation.
208    pub fn with_group_metadata_freshness(mut self, freshness: Freshness) -> Self {
209        self.group_metadata_freshness = freshness;
210        self
211    }
212
213    pub fn chat(&self) -> &Jid {
214        &self.chat
215    }
216
217    pub fn requester(&self) -> &Jid {
218        &self.requester
219    }
220
221    pub fn message(&self) -> &wa::Message {
222        &self.message
223    }
224
225    pub fn message_id(&self) -> &str {
226        &self.message_id
227    }
228
229    pub const fn retry_count(&self) -> u8 {
230        self.retry_count
231    }
232
233    pub fn recipient(&self) -> Option<&Jid> {
234        self.recipient.as_ref()
235    }
236
237    pub const fn group_metadata_freshness(&self) -> Freshness {
238        self.group_metadata_freshness
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn invalid_protobuf_is_the_only_rejection_with_failure_detail() {
248        let rejection = StanzaRejection::invalid_protobuf(Some(17));
249        assert_eq!(rejection.reason(), NackReason::InvalidProtobuf);
250        assert_eq!(rejection.failure_reason(), Some(17));
251
252        let rejection = StanzaRejection::new(NackReason::ParsingError);
253        assert_eq!(rejection.reason(), NackReason::ParsingError);
254        assert_eq!(rejection.failure_reason(), None);
255    }
256
257    #[test]
258    fn retry_request_options_have_protocol_safe_defaults() {
259        let defaults = RetryRequestOptions::default();
260        assert_eq!(defaults.reason(), RetryReason::UnknownError);
261        assert!(!defaults.force_include_keys());
262
263        let configured = defaults
264            .with_reason(RetryReason::BadMac)
265            .with_force_include_keys(true);
266        assert_eq!(configured.reason(), RetryReason::BadMac);
267        assert!(configured.force_include_keys());
268    }
269
270    #[test]
271    fn internal_retry_error_preserves_its_source() {
272        use std::error::Error as _;
273
274        let error = RetryRequestError::from(anyhow::anyhow!("storage sentinel"));
275        assert_eq!(
276            error.source().map(ToString::to_string).as_deref(),
277            Some("storage sentinel")
278        );
279    }
280
281    #[test]
282    fn response_encoding_error_preserves_its_source() {
283        use std::error::Error as _;
284
285        let error = StanzaResponseError::from(wacore_binary::error::BinaryError::MissingAttr(
286            "sentinel".to_owned(),
287        ));
288        assert!(
289            error
290                .source()
291                .is_some_and(|source| source.to_string().contains("sentinel"))
292        );
293    }
294}