Skip to main content

whatsapp_rust/client/
messaging.rs

1//! Outgoing send primitives, receipts, reactions, edits and chat-state events.
2
3use super::*;
4
5impl Client {
6    /// Send pre-marshaled plaintext bytes through the noise socket.
7    ///
8    /// The bytes must be a valid WABinary-marshaled stanza (as produced by
9    /// `wacore_binary::marshal::marshal_to`). Sending malformed data will
10    /// cause the server to close the connection.
11    ///
12    /// This bypasses node logging and `sent_node_waiter` resolution — use
13    /// [`send_node`](Client::send_node) for normal stanza sending.
14    pub async fn send_raw_bytes(&self, plaintext: Vec<u8>) -> Result<(), ClientError> {
15        let noise_socket = self.get_noise_socket().await?;
16        // Wire bytes and the last-sent timestamp are recorded by the noise
17        // sender task at the actual transport write.
18        noise_socket
19            .encrypt_and_send(bytes::Bytes::from(plaintext))
20            .await?;
21        Ok(())
22    }
23
24    /// Receivers a burst holds without allocating. Both callers cap their batch
25    /// at 4 ([`MAX_ACK_BURST`](Self::MAX_ACK_BURST) and
26    /// [`MAX_RECEIPT_BURST`](Self::MAX_RECEIPT_BURST)), so a real burst fits.
27    pub(crate) const MAX_INLINE_BURST: usize = 4;
28
29    /// Send several pre-marshaled stanzas as one burst, returning a result per
30    /// stanza in the order given.
31    ///
32    /// The noise sender coalesces whatever is queued when it wakes, but a
33    /// worker that awaits each send before starting the next never has two
34    /// frames queued at once, so the coalescing it was built for never fires.
35    /// Handing over the whole burst is what turns batching from incidental into
36    /// the normal case.
37    ///
38    /// Order is preserved, which the ack worker depends on.
39    ///
40    /// Always drains `frames`, including when no socket is installed, while
41    /// retaining its outer allocation for the persistent workers to reuse.
42    ///
43    /// Results land in `results`, which the caller owns and reuses too. A
44    /// returned `Vec` would allocate once per burst, and the common burst is a
45    /// single frame, so that allocation was the dominant cost of sending one.
46    /// A multi-frame burst holds its receivers inline up to
47    /// [`MAX_INLINE_BURST`](Self::MAX_INLINE_BURST), so it does not allocate
48    /// either; beyond that the spill is one allocation, which is what the
49    /// previous `join_all` cost every time.
50    pub(crate) async fn send_raw_bytes_burst(
51        &self,
52        frames: &mut Vec<Vec<u8>>,
53        results: &mut Vec<crate::socket::error::EncryptSendResult>,
54    ) -> Result<(), ClientError> {
55        results.clear();
56        let noise_socket = match self.get_noise_socket().await {
57            Ok(socket) => socket,
58            Err(error) => {
59                frames.clear();
60                return Err(error);
61            }
62        };
63        if frames.len() == 1 {
64            let plaintext = frames.pop().expect("length checked");
65            results.push(
66                noise_socket
67                    .encrypt_and_send(bytes::Bytes::from(plaintext))
68                    .await,
69            );
70            return Ok(());
71        }
72        // Every frame is enqueued before any is awaited, which is what lets the
73        // sender coalesce them into one transport write; awaiting each before
74        // enqueueing the next would hand them over one completion apart. The
75        // receivers live inline for the burst sizes both callers cap at, so
76        // unlike `join_all` this neither allocates storage for the futures nor
77        // a `Vec` for their results.
78        let mut receivers: smallvec::SmallVec<[_; Self::MAX_INLINE_BURST]> =
79            smallvec::SmallVec::new();
80        // An enqueue only fails once the sender task is gone, which no later
81        // frame recovers from. Recording where it happened keeps `results`
82        // aligned with the frames that were drained: a caller reporting a
83        // failure against the wrong frame is worse than the failure.
84        let mut frames_after_enqueue_failed = 0usize;
85        for plaintext in frames.drain(..) {
86            if frames_after_enqueue_failed > 0 {
87                frames_after_enqueue_failed += 1;
88                continue;
89            }
90            match noise_socket
91                .enqueue_send(bytes::Bytes::from(plaintext))
92                .await
93            {
94                Ok(receiver) => receivers.push(receiver),
95                Err(_) => frames_after_enqueue_failed = 1,
96            }
97        }
98
99        for receiver in receivers {
100            results.push(NoiseSocket::await_send(receiver).await);
101        }
102        for _ in 0..frames_after_enqueue_failed {
103            results.push(Err(EncryptSendError::channel_closed()));
104        }
105        Ok(())
106    }
107
108    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.send.node", level = "debug", skip_all, fields(tag = %node.tag), err(Debug)))]
109    pub async fn send_node(&self, node: Node) -> Result<(), ClientError> {
110        let plaintext_buf = self.marshal_node_for_send(node)?;
111        self.send_raw_bytes(plaintext_buf).await
112    }
113
114    /// Everything [`send_node`](Client::send_node) does short of the send:
115    /// logging, waiter resolution and marshalling. Split out so a burst can
116    /// marshal its whole batch before touching the socket, which is what keeps
117    /// the sends orderable.
118    pub(crate) fn marshal_node_for_send(&self, node: Node) -> Result<Vec<u8>, ClientError> {
119        debug!(target: "Client/Send", "{}", DisplayableNode(&node));
120        if self.sent_node_waiter_count.load(Ordering::Acquire) > 0 {
121            self.resolve_sent_node_waiters(&Arc::new(node.clone()));
122        }
123
124        // Exact two-pass sizing: typical stanzas are a few hundred bytes, so
125        // the 1 KiB default reserve of the one-pass path mostly over-allocates.
126        wacore_binary::marshal::marshal_exact(&node).map_err(|e| {
127            error!("Failed to marshal node: {e:?}");
128            SocketError::Marshal(e).into()
129        })
130    }
131
132    #[cfg_attr(
133        feature = "tracing",
134        tracing::instrument(name = "wa.send.unified_session", level = "debug", skip_all)
135    )]
136    pub(crate) async fn send_unified_session(&self) {
137        if !self.is_connected() {
138            debug!(target: "Client/UnifiedSession", "Skipping: not connected");
139            return;
140        }
141
142        let Some((node, _sequence)) = self.unified_session.prepare_send().await else {
143            return;
144        };
145
146        if let Err(e) = self.send_node(node).await {
147            debug!(target: "Client/UnifiedSession", "Send failed: {e}");
148            self.unified_session.clear_last_sent().await;
149        }
150    }
151
152    pub async fn edit_message(
153        &self,
154        to: impl Into<Jid>,
155        original_id: impl Into<String>,
156        new_content: wa::Message,
157    ) -> Result<String, crate::send::SendError> {
158        self.edit_message_inner(to.into(), original_id.into(), new_content, None)
159            .await
160    }
161
162    /// Edits a message you own (`original_id`) with caller-supplied
163    /// [`crate::send::EditOptions`]. The edit-path counterpart of
164    /// [`crate::send::SendOptions::message_id`] (which overrides the stanza id
165    /// for plain sends): `stanza_id` lets callers control the outer stanza id —
166    /// for example to collide it with an existing message so clients re-render
167    /// that slot.
168    ///
169    /// When `stanza_id` is set, no id-keyed local state is bound to the borrowed
170    /// id (the edit skips outbound-secret and retry-cache persistence, leaving
171    /// the original message's state intact), and whether the collision is
172    /// honored is server/client dependent — treat it as best-effort. See
173    /// [`crate::send::EditOptions::stanza_id`].
174    pub async fn edit_message_with_options(
175        &self,
176        to: impl Into<Jid>,
177        original_id: impl Into<String>,
178        new_content: wa::Message,
179        options: crate::send::EditOptions,
180    ) -> Result<String, crate::send::SendError> {
181        self.edit_message_inner(
182            to.into(),
183            original_id.into(),
184            new_content,
185            options.stanza_id,
186        )
187        .await
188    }
189
190    /// Shared edit-send flow for [`Self::edit_message`] and
191    /// [`Self::edit_message_with_options`]. `request_id` overrides the outer
192    /// stanza id when `Some`; when `None` a fresh one is generated (the default,
193    /// safe behavior — see below).
194    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.send.edit", level = "debug", skip_all, fields(to = %to.observe()), err(Debug)))]
195    async fn edit_message_inner(
196        &self,
197        to: Jid,
198        original_id: String,
199        new_content: wa::Message,
200        request_id: Option<String>,
201    ) -> Result<String, crate::send::SendError> {
202        // WhatsApp Web uses getMeUserLidOrJidForChat(chat, EditMessage) which
203        // returns LID for LID-addressing groups and PN otherwise.
204        let participant = if to.is_group() {
205            Some(
206                self.get_own_jid_for_group(&to)
207                    .await
208                    .map_err(crate::send::SendError::from_anyhow)?
209                    .to_non_ad()
210                    .to_string(),
211            )
212        } else {
213            if self.pn().is_none() {
214                return Err(crate::send::SendError::NotLoggedIn);
215            }
216            None
217        };
218
219        let edit_container_message = crate::send::build_edit_message(
220            &to,
221            original_id.clone(),
222            participant,
223            new_content,
224            wacore::time::now_millis(),
225        );
226
227        // Default (`request_id = None`) uses a fresh stanza ID instead of
228        // reusing the original message ID: the original ID is already embedded
229        // in protocolMessage.key.id inside the encrypted payload, and reusing it
230        // as the outer stanza ID makes the server deduplicate against the
231        // original message and silently drop the edit. Callers that intentionally
232        // want to pin the outer stanza id pass it via `request_id`; that id is
233        // borrowed from another message, so id-keyed state (retry cache, outbound
234        // secret) must not be bound to it.
235        let borrowed_message_id = request_id.is_some();
236        self.send_message_impl(
237            to,
238            &edit_container_message,
239            crate::send::SendPipelineOptions {
240                edit: Some(crate::types::message::EditAttribute::MessageEdit),
241                request_id: request_id.as_deref(),
242                borrowed_message_id,
243                ..Default::default()
244            },
245        )
246        .await
247        .map_err(crate::send::SendError::from_anyhow)?;
248
249        Ok(original_id)
250    }
251
252    /// Edit a message via the message-secret encrypted path (`secret_encrypted_message`
253    /// with `secret_enc_type = MESSAGE_EDIT`), instead of the plaintext protocolMessage
254    /// edit. This is the form Community Announcement Group / channel edits require, and
255    /// what WA Web sends when `message_edit_to_message_secret_sender_enabled` is on.
256    ///
257    /// `message_secret` is the *original* message's 32-byte secret (you generated it when
258    /// you sent that message). You can only edit your own messages, so the original
259    /// sender and the editor are both you.
260    pub async fn edit_message_encrypted(
261        &self,
262        to: impl Into<Jid>,
263        original_id: impl Into<String>,
264        message_secret: &[u8],
265        new_content: wa::Message,
266    ) -> Result<String, crate::send::SendError> {
267        self.edit_message_encrypted_inner(
268            to.into(),
269            original_id.into(),
270            message_secret,
271            new_content,
272        )
273        .await
274    }
275
276    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.send.edit_encrypted", level = "debug", skip_all, fields(to = %to.observe()), err(Debug)))]
277    async fn edit_message_encrypted_inner(
278        &self,
279        to: Jid,
280        original_id: String,
281        message_secret: &[u8],
282        new_content: wa::Message,
283    ) -> Result<String, crate::send::SendError> {
284        use crate::send::SendError;
285        // Newsletters/channels are plaintext (no message-secret addon crypto) and the
286        // E2E send path rejects them, so an encrypted edit can't apply there; fail with
287        // a clear boundary error instead of the cryptic downstream rejection.
288        if to.is_newsletter() {
289            return Err(SendError::InvalidRequest(
290                "edit_message_encrypted is not valid for newsletters/channels; use newsletter().edit_message"
291                    .into(),
292            ));
293        }
294        if message_secret.len() != 32 {
295            return Err(SendError::InvalidRequest(format!(
296                "message_secret must be exactly 32 bytes, got {}",
297                message_secret.len()
298            )));
299        }
300
301        let self_jid = if to.is_group() {
302            self.get_own_jid_for_group(&to)
303                .await
304                .map_err(SendError::from_anyhow)?
305                .to_non_ad()
306        } else {
307            self.pn().ok_or(SendError::NotLoggedIn)?.to_non_ad()
308        };
309        let participant = if to.is_group() {
310            Some(self_jid.to_string())
311        } else {
312            None
313        };
314
315        let envelope = build_secret_message_edit(
316            &to,
317            &original_id,
318            participant,
319            &self_jid.to_string(),
320            message_secret,
321            new_content,
322        )?;
323
324        self.send_message_impl(
325            to,
326            &envelope,
327            crate::send::SendPipelineOptions {
328                edit: Some(crate::types::message::EditAttribute::MessageEdit),
329                ..Default::default()
330            },
331        )
332        .await
333        .map_err(SendError::from_anyhow)?;
334
335        Ok(original_id)
336    }
337
338    /// Send a server-side reaction (used by both newsletter and status reactions).
339    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.send.server_reaction", level = "debug", skip_all, fields(to = %to.observe()), err(Debug)))]
340    pub(crate) async fn send_server_reaction(
341        &self,
342        to: &Jid,
343        server_id: u64,
344        reaction: &str,
345    ) -> Result<(), anyhow::Error> {
346        let request_id = self.generate_message_id();
347
348        let stanza = NodeBuilder::new("message")
349            .attr("to", to)
350            .attr("type", "reaction")
351            .attr("id", &request_id)
352            .attr("server_id", server_id)
353            .children([NodeBuilder::new("reaction").attr("code", reaction).build()])
354            .build();
355
356        self.send_node(stanza).await?;
357        Ok(())
358    }
359
360    /// Register a oneshot waiter for a server ack by message ID.
361    /// Returns the receiver — caller sends the node separately and awaits this in background.
362    /// Sync: registration is just a `std::sync::Mutex` insert (no await).
363    /// Register a waiter that receives the ack node itself.
364    ///
365    /// Used where the caller needs the response: the VoIP offer reads the relay
366    /// out of its ack. A phash check does not, which is why that path uses
367    /// [`Self::register_phash_waiter`] and pays no channel per message. Gated on
368    /// the only consumer's feature, or it is dead code in a default build.
369    #[cfg(feature = "voip-runtime")]
370    pub(crate) fn register_ack_waiter(
371        &self,
372        message_id: &str,
373    ) -> futures::channel::oneshot::Receiver<Arc<wacore_binary::OwnedNodeRef>> {
374        let (tx, rx) = futures::channel::oneshot::channel();
375        self.response_waiters_guard()
376            .insert(message_id.to_string(), ResponseWaiter::Iq(tx));
377        rx
378    }
379
380    /// Register the phash the server is expected to echo for this send.
381    ///
382    /// Nothing awaits the result: the read loop compares inline when the ack
383    /// lands and only acts on a mismatch, so a send costs a map entry instead of
384    /// a task, a oneshot and a timer.
385    pub(crate) fn register_phash_waiter(
386        &self,
387        message_id: &str,
388        expected: wacore_binary::CompactString,
389        jid: Jid,
390        invalidate_group_cache: bool,
391    ) {
392        let mut waiters = self.response_waiters_guard();
393        // Stamped with the sweep epoch under the lock the insert already holds:
394        // a deadline derived from the instant the send started would already be
395        // stale here when preparation is slow, and a wall clock can jump.
396        let registered_epoch = waiters.current_epoch();
397        waiters.insert(
398            message_id.to_string(),
399            ResponseWaiter::Phash(PhashWaiter {
400                expected,
401                jid,
402                invalidate_group_cache,
403                registered_epoch,
404            }),
405        );
406    }
407
408    /// Creates a normalized ChatMessageId by resolving PN to LID JIDs.
409    pub(crate) async fn make_chat_message_id(&self, chat: &Jid, id: &str) -> ChatMessageId {
410        // Resolve chat JID to LID if possible
411        let chat = self.resolve_encryption_jid(chat).await;
412
413        ChatMessageId {
414            chat,
415            id: id.to_owned(),
416        }
417    }
418
419    #[cfg_attr(
420        feature = "tracing",
421        tracing::instrument(name = "wa.send.protocol_receipt", level = "debug", skip_all)
422    )]
423    pub(crate) async fn send_protocol_receipt(
424        &self,
425        id: String,
426        receipt_type: crate::types::presence::ReceiptType,
427    ) {
428        if id.is_empty() {
429            return;
430        }
431        let device_snapshot = self.persistence_manager.get_device_snapshot();
432        if let Some(own_jid) = &device_snapshot.pn {
433            // Single source of truth for the wire mapping (ReceiptType::Sent is a derived
434            // incoming-only state and is never sent by us).
435            let type_str = receipt_type.as_wire_str();
436
437            // Borrow `id` for the attr so it stays available for the error log
438            // below (the warn used to log self.unique_id, the client UUID, by
439            // mistake). Separate .attr calls avoid cloning into a homogeneous map.
440            let node = NodeBuilder::new("receipt")
441                .attr("id", id.as_str())
442                .attr("type", type_str)
443                .attr("to", own_jid.to_non_ad_string())
444                .build();
445
446            if let Err(e) = self.send_node(node).await {
447                warn!(
448                    "Failed to send protocol receipt of type {:?} for message ID {}: {:?}",
449                    receipt_type, id, e
450                );
451            }
452        }
453    }
454
455    /// Register a chatstate handler which will be invoked when a `<chatstate>` stanza is received.
456    ///
457    /// The handler receives a `ChatStateEvent` with the parsed chat state information.
458    pub async fn register_chatstate_handler(
459        &self,
460        handler: Arc<dyn Fn(ChatStateEvent) + Send + Sync>,
461    ) {
462        self.chatstate_handlers.write().await.push(handler);
463    }
464
465    /// Dispatch a parsed chatstate stanza to registered handlers.
466    ///
467    /// Called by `ChatstateHandler` after parsing the incoming stanza.
468    #[cfg_attr(
469        feature = "tracing",
470        tracing::instrument(name = "wa.notif.chatstate", level = "debug", skip_all)
471    )]
472    pub(crate) async fn dispatch_chatstate_event(
473        &self,
474        stanza: wacore::iq::chatstate::ChatstateStanza,
475    ) {
476        use wacore::iq::chatstate::{ChatstateSource, ReceivedChatState};
477        use wacore::types::events::ChatPresenceUpdate;
478        use wacore::types::message::MessageSource;
479        use wacore::types::presence::{ChatPresence, ChatPresenceMedia};
480
481        // Dispatch via event bus
482        let (chat, sender, is_group) = match &stanza.source {
483            ChatstateSource::User { from } => (from.clone(), from.clone(), false),
484            ChatstateSource::Group { from, participant } => {
485                (from.clone(), participant.clone(), true)
486            }
487        };
488
489        let (state, media) = match stanza.state {
490            ReceivedChatState::Typing => (ChatPresence::Composing, ChatPresenceMedia::Text),
491            ReceivedChatState::RecordingAudio => {
492                (ChatPresence::Composing, ChatPresenceMedia::Audio)
493            }
494            ReceivedChatState::Idle => (ChatPresence::Paused, ChatPresenceMedia::Text),
495        };
496
497        self.core.event_bus.dispatch(Event::ChatPresence(
498            ChatPresenceUpdate::builder()
499                .source(MessageSource {
500                    chat,
501                    sender,
502                    is_from_me: false,
503                    is_group,
504                    addressing_mode: None,
505                    sender_alt: None,
506                    recipient_alt: None,
507                    broadcast_list_owner: None,
508                    recipient: None,
509                })
510                .state(state)
511                .media(media)
512                .build(),
513        ));
514
515        // Invoke legacy callback handlers
516        let event = ChatStateEvent::from_stanza(stanza);
517        let handlers = self.chatstate_handlers.read().await.clone();
518        for handler in handlers {
519            let event_clone = event.clone();
520            self.runtime
521                .spawn(Box::pin(async move {
522                    (handler)(event_clone);
523                }))
524                .detach();
525        }
526    }
527
528    /// Whether delivery receipts should be sent active (rendered as ticks) vs
529    /// `type="inactive"`. Mirrors whatsmeow's `sendActiveReceipts != 0`.
530    pub(crate) fn receipts_are_active(&self) -> bool {
531        self.send_active_receipts.load(Ordering::Acquire) != 0
532    }
533
534    /// Force active delivery receipts even when offline (whatsmeow's
535    /// `SetForceActiveDeliveryReceipts`); off restores the default.
536    pub fn set_force_active_delivery_receipts(&self, active: bool) {
537        self.send_active_receipts
538            .store(if active { 2 } else { 0 }, Ordering::Release);
539    }
540
541    /// CAS so a forced value (2) is preserved (whatsmeow's `CompareAndSwap`).
542    pub(crate) fn mark_receipts_active_on_presence(&self) {
543        let _ =
544            self.send_active_receipts
545                .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire);
546    }
547
548    pub(crate) fn mark_receipts_inactive_on_presence(&self) {
549        let _ =
550            self.send_active_receipts
551                .compare_exchange(1, 0, Ordering::AcqRel, Ordering::Acquire);
552    }
553}
554
555/// Build the outgoing `secret_encrypted_message` (MESSAGE_EDIT) envelope: encrypt the
556/// protocolMessage(MESSAGE_EDIT) under the original message's secret and wrap it with
557/// `messageContextInfo.messageSecret`, matching WAWebGenerateSecretMessageEditProto.
558fn build_secret_message_edit(
559    to: &Jid,
560    original_id: &str,
561    participant: Option<String>,
562    self_jid_str: &str,
563    message_secret: &[u8],
564    new_content: wa::Message,
565) -> Result<wa::Message, anyhow::Error> {
566    let inner = crate::send::build_edit_message(
567        to,
568        original_id.to_string(),
569        participant.clone(),
570        new_content,
571        wacore::time::now_millis(),
572    );
573
574    // You can only edit your own message, so original-sender == editor == self.
575    let ctx = wacore::message_edit::MessageEditContext {
576        original_msg_id: original_id,
577        original_sender_jid: self_jid_str,
578        editor_jid: self_jid_str,
579    };
580    let (enc_payload, iv) =
581        wacore::message_edit::encrypt_message_edit(&inner, message_secret, &ctx)?;
582
583    Ok(wa::Message {
584        secret_encrypted_message: buffa::MessageField::some(wa::message::SecretEncryptedMessage {
585            target_message_key: buffa::MessageField::some(wa::MessageKey {
586                remote_jid: Some(to.to_string()),
587                from_me: Some(true),
588                id: Some(original_id.to_string()),
589                participant,
590            }),
591            enc_payload: Some(enc_payload),
592            enc_iv: Some(iv.to_vec()),
593            secret_enc_type: Some(
594                wa::message::secret_encrypted_message::SecretEncType::MessageEdit,
595            ),
596            remote_key_id: None,
597        }),
598        message_context_info: buffa::MessageField::some(wa::MessageContextInfo {
599            message_secret: Some(message_secret.to_vec()),
600            ..Default::default()
601        }),
602        ..Default::default()
603    })
604}
605
606#[cfg(test)]
607mod secret_message_edit_tests {
608    use super::*;
609
610    #[test]
611    fn secret_message_edit_roundtrip() {
612        let secret = [0x33u8; 32];
613        let to: Jid = "5511777777777@s.whatsapp.net".parse().unwrap();
614        let self_str = "5511999999999@s.whatsapp.net";
615        let new_content = wa::Message {
616            conversation: Some("edited!".into()),
617            ..Default::default()
618        };
619
620        let envelope =
621            build_secret_message_edit(&to, "ORIGID", None, self_str, &secret, new_content).unwrap();
622
623        let sem = envelope.secret_encrypted_message.as_option().unwrap();
624        assert_eq!(
625            sem.secret_enc_type,
626            Some(wa::message::secret_encrypted_message::SecretEncType::MessageEdit)
627        );
628        // The envelope carries the original secret (WAWebGenerateSecretMessageEditProto).
629        assert_eq!(
630            envelope
631                .message_context_info
632                .as_option()
633                .and_then(|c| c.message_secret.as_deref()),
634            Some(&secret[..])
635        );
636
637        // The recipient decrypts with the original message's secret + same ctx.
638        let ctx = wacore::message_edit::MessageEditContext {
639            original_msg_id: "ORIGID",
640            original_sender_jid: self_str,
641            editor_jid: self_str,
642        };
643        let inner = wacore::message_edit::decrypt_message_edit(
644            sem.enc_payload.as_deref().unwrap(),
645            sem.enc_iv.as_deref().unwrap(),
646            &secret,
647            &ctx,
648        )
649        .unwrap();
650        let edited = inner
651            .protocol_message
652            .into_option()
653            .and_then(|pm| pm.edited_message.into_option())
654            .and_then(|m| m.conversation);
655        assert_eq!(edited.as_deref(), Some("edited!"));
656    }
657
658    #[test]
659    fn secret_message_edit_wrong_secret_fails_to_decrypt() {
660        let secret = [0x33u8; 32];
661        let to: Jid = "5511777777777@s.whatsapp.net".parse().unwrap();
662        let self_str = "5511999999999@s.whatsapp.net";
663        let envelope = build_secret_message_edit(
664            &to,
665            "ORIGID",
666            None,
667            self_str,
668            &secret,
669            wa::Message {
670                conversation: Some("edited!".into()),
671                ..Default::default()
672            },
673        )
674        .unwrap();
675        let sem = envelope.secret_encrypted_message.as_option().unwrap();
676        let ctx = wacore::message_edit::MessageEditContext {
677            original_msg_id: "ORIGID",
678            original_sender_jid: self_str,
679            editor_jid: self_str,
680        };
681        assert!(
682            wacore::message_edit::decrypt_message_edit(
683                sem.enc_payload.as_deref().unwrap(),
684                sem.enc_iv.as_deref().unwrap(),
685                &[0x00u8; 32],
686                &ctx,
687            )
688            .is_err()
689        );
690    }
691}