diff --git a/src/bot.rs b/src/bot.rs index c03fada6e..0e3da0ef5 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -122,7 +122,7 @@ impl MessageContext { pub async fn send_message( &self, message: wa::Message, - ) -> Result { + ) -> Result { self.client .send_message(&self.info.source.chat, message) .await @@ -132,7 +132,7 @@ impl MessageContext { pub async fn reply( &self, text: impl Into, - ) -> Result { + ) -> Result { self.send_message(wa::Message::text(text)).await } @@ -140,7 +140,7 @@ impl MessageContext { pub async fn reply_quoting( &self, text: impl Into, - ) -> Result { + ) -> Result { let context = self.build_quote_context(); self.send_message(wa::Message::text_with_context(text, context)) .await @@ -178,7 +178,7 @@ impl MessageContext { &self, original_message_id: impl Into, new_message: wa::Message, - ) -> Result { + ) -> Result { self.client .edit_message(&self.info.source.chat, original_message_id, new_message) .await @@ -190,7 +190,7 @@ impl MessageContext { &self, message_id: impl Into, revoke_type: crate::send::RevokeType, - ) -> Result<(), anyhow::Error> { + ) -> Result<(), crate::send::SendError> { self.client .revoke_message(&self.info.source.chat, message_id, revoke_type) .await @@ -200,7 +200,10 @@ impl MessageContext { /// reaction. The target key (including the group/status participant) is /// taken from [`MessageContext::message_key`]. #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.bot.react", level = "debug", skip_all, fields(chat = %self.info.source.chat.observe()), err(Debug)))] - pub async fn react(&self, emoji: &str) -> Result { + pub async fn react( + &self, + emoji: &str, + ) -> Result { self.client .send_reaction(&self.info.source.chat, self.message_key(), emoji) .await diff --git a/src/client.rs b/src/client.rs index 74b1d0559..a5b4f5414 100644 --- a/src/client.rs +++ b/src/client.rs @@ -289,6 +289,13 @@ impl std::fmt::Display for MemoryDiagnostics { } } +/// Shared base error for transport/connection concerns. +/// +/// The DRY foundation every per-domain error builds on (each domain embeds it +/// via `#[from]`): it carries the cases common to every network operation — +/// `NotConnected`, `NotLoggedIn`, IQ failures, socket / encrypt-send errors. It +/// is NOT an umbrella over the whole API; the per-domain typed errors remain +/// the public return types. #[derive(Debug, Error)] #[non_exhaustive] pub enum ClientError { @@ -302,6 +309,13 @@ pub enum ClientError { AlreadyConnected, #[error("client is not logged in")] NotLoggedIn, + #[error("IQ request failed: {0}")] + Iq(#[from] crate::request::IqError), + /// Last-resort catch-all for internal failures threaded through `?` that do + /// not (yet) have a dedicated variant. Transparent so the underlying + /// error's `Display`/source chain is preserved. + #[error(transparent)] + Internal(#[from] anyhow::Error), } impl ClientError { @@ -309,6 +323,14 @@ impl ClientError { match self { ClientError::NotConnected => true, ClientError::EncryptSend(e) => e.is_transport_unavailable(), + // Transport loss can now arrive wrapped in an IQ failure (the base + // error gained `Iq`); unwrap it so retry/reconnect still triggers. + ClientError::Iq(e) => match e { + crate::request::IqError::NotConnected => true, + crate::request::IqError::EncryptSend(e) => e.is_transport_unavailable(), + crate::request::IqError::ClientState(client) => client.is_transport_unavailable(), + _ => false, + }, _ => false, } } diff --git a/src/client/context_impl.rs b/src/client/context_impl.rs index 9e52a1d26..9d3b221f8 100644 --- a/src/client/context_impl.rs +++ b/src/client/context_impl.rs @@ -49,7 +49,7 @@ impl SendContextResolver for Client { } async fn resolve_group_info(&self, jid: &Jid) -> Result, anyhow::Error> { - self.groups().query_info(jid).await + Ok(self.groups().query_info(jid).await?) } async fn get_lid_for_phone(&self, phone_user: &str) -> Option { diff --git a/src/client/iq_ops.rs b/src/client/iq_ops.rs index e7cf17a3d..856fe8e1d 100644 --- a/src/client/iq_ops.rs +++ b/src/client/iq_ops.rs @@ -128,14 +128,15 @@ impl Client { &self, chat: wacore_binary::Jid, duration: u32, - ) -> Result { + ) -> Result { // 1:1 only: groups use Groups::set_ephemeral (a separate IQ). Sending the // EPHEMERAL_SETTING body to a group/status/newsletter would produce a // message that does not change the chat's timer, so fail fast instead. if !(chat.is_pn() || chat.is_lid()) { - anyhow::bail!( + return Err(crate::send::SendError::InvalidRequest( "set_chat_disappearing_timer is 1:1-only; use Groups::set_ephemeral for groups" - ); + .into(), + )); } let msg = build_ephemeral_setting_message(duration, wacore::time::now_secs_u64() as i64); self.send_message(chat, msg).await diff --git a/src/client/messaging.rs b/src/client/messaging.rs index d7d3034d3..57d72be1f 100644 --- a/src/client/messaging.rs +++ b/src/client/messaging.rs @@ -61,7 +61,7 @@ impl Client { to: impl Into, original_id: impl Into, new_content: wa::Message, - ) -> Result { + ) -> Result { self.edit_message_inner(to.into(), original_id.into(), new_content) .await } @@ -72,19 +72,20 @@ impl Client { to: Jid, original_id: String, new_content: wa::Message, - ) -> Result { + ) -> Result { // WhatsApp Web uses getMeUserLidOrJidForChat(chat, EditMessage) which // returns LID for LID-addressing groups and PN otherwise. let participant = if to.is_group() { Some( self.get_own_jid_for_group(&to) - .await? + .await + .map_err(crate::send::SendError::from_anyhow)? .to_non_ad() .to_string(), ) } else { if self.get_pn().is_none() { - return Err(anyhow::Error::from(ClientError::NotLoggedIn)); + return Err(crate::send::SendError::NotLoggedIn); } None }; @@ -112,7 +113,8 @@ impl Client { vec![], None, ) - .await?; + .await + .map_err(crate::send::SendError::from_anyhow)?; Ok(original_id) } @@ -131,7 +133,7 @@ impl Client { original_id: impl Into, message_secret: &[u8], new_content: wa::Message, - ) -> Result { + ) -> Result { self.edit_message_encrypted_inner( to.into(), original_id.into(), @@ -148,26 +150,31 @@ impl Client { original_id: String, message_secret: &[u8], new_content: wa::Message, - ) -> Result { + ) -> Result { + use crate::send::SendError; // Newsletters/channels are plaintext (no message-secret addon crypto) and the // E2E send path rejects them, so an encrypted edit can't apply there; fail with // a clear boundary error instead of the cryptic downstream rejection. - anyhow::ensure!( - !to.is_newsletter(), - "edit_message_encrypted is not valid for newsletters/channels; use edit_message" - ); - anyhow::ensure!( - message_secret.len() == 32, - "message_secret must be exactly 32 bytes, got {}", - message_secret.len() - ); + if to.is_newsletter() { + return Err(SendError::InvalidRequest( + "edit_message_encrypted is not valid for newsletters/channels; use newsletter().edit_message" + .into(), + )); + } + if message_secret.len() != 32 { + return Err(SendError::InvalidRequest(format!( + "message_secret must be exactly 32 bytes, got {}", + message_secret.len() + ))); + } let self_jid = if to.is_group() { - self.get_own_jid_for_group(&to).await?.to_non_ad() - } else { - self.get_pn() - .ok_or_else(|| anyhow::Error::from(ClientError::NotLoggedIn))? + self.get_own_jid_for_group(&to) + .await + .map_err(SendError::from_anyhow)? .to_non_ad() + } else { + self.get_pn().ok_or(SendError::NotLoggedIn)?.to_non_ad() }; let participant = if to.is_group() { Some(self_jid.to_string()) @@ -194,7 +201,8 @@ impl Client { vec![], None, ) - .await?; + .await + .map_err(SendError::from_anyhow)?; Ok(original_id) } diff --git a/src/features/blocking.rs b/src/features/blocking.rs index 8952af5ca..7a13f8519 100644 --- a/src/features/blocking.rs +++ b/src/features/blocking.rs @@ -6,10 +6,27 @@ use crate::client::Client; use crate::request::IqError; use log::debug; +use thiserror::Error; pub use wacore::iq::blocklist::BlocklistEntry; use wacore::iq::blocklist::{GetBlocklistSpec, UpdateBlocklistSpec}; use wacore_binary::Jid; +/// Error returned by blocklist operations. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum BlockingError { + /// The IQ to the server failed (transport, timeout, server rejection). + #[error(transparent)] + Iq(#[from] IqError), + /// The target JID is not a user JID, or has no resolvable LID↔PN mapping + /// (modern WA requires both sides for a block). + #[error("invalid blocklist target: {0}")] + InvalidJid(String), + /// Catch-all for internal failures (e.g. LID/PN store lookup). + #[error(transparent)] + Internal(#[from] anyhow::Error), +} + /// Feature handle for blocklist operations. pub struct Blocking<'a> { client: &'a Client, @@ -22,22 +39,15 @@ impl<'a> Blocking<'a> { /// Resolve `bare` (LID or PN) into the `(lid, pn)` pair the server expects /// on blocklist stanzas. - async fn resolve_lid_pn(&self, bare: Jid) -> Result<(Jid, Jid), IqError> { + async fn resolve_lid_pn(&self, bare: Jid) -> Result<(Jid, Jid), BlockingError> { if !(bare.is_lid() || bare.is_pn()) { - return Err(IqError::EncodeError(anyhow::anyhow!( - "blocklist: jid is neither PN nor LID" - ))); + return Err(BlockingError::InvalidJid( + "jid is neither PN nor LID".into(), + )); } - let entry = self - .client - .get_lid_pn_entry(&bare) - .await - .map_err(IqError::EncodeError)? - .ok_or_else(|| { - IqError::EncodeError(anyhow::anyhow!( - "blocklist: no LID↔PN mapping for provided jid" - )) - })?; + let entry = self.client.get_lid_pn_entry(&bare).await?.ok_or_else(|| { + BlockingError::InvalidJid("no LID↔PN mapping for provided jid".into()) + })?; Ok(if bare.is_lid() { (bare, Jid::pn(&*entry.phone_number)) } else { @@ -47,7 +57,7 @@ impl<'a> Blocking<'a> { /// Block a contact. Accepts either LID or PN; the wire stanza always /// carries both (`jid=LID, pn_jid=PN`) — modern WA rejects PN-only blocks. - pub async fn block(&self, jid: &Jid) -> Result<(), IqError> { + pub async fn block(&self, jid: &Jid) -> Result<(), BlockingError> { debug!(target: "Blocking", "Blocking contact"); let (lid_jid, pn_jid) = self.resolve_lid_pn(jid.to_non_ad()).await?; self.client @@ -59,9 +69,16 @@ impl<'a> Blocking<'a> { /// Unblock a contact. Stanza only needs the LID, but PN input is accepted /// and resolved through the mapping. - pub async fn unblock(&self, jid: &Jid) -> Result<(), IqError> { + pub async fn unblock(&self, jid: &Jid) -> Result<(), BlockingError> { debug!(target: "Blocking", "Unblocking contact"); - let (lid_jid, _) = self.resolve_lid_pn(jid.to_non_ad()).await?; + // The unblock stanza only needs the LID, so a LID input must not require a + // PN↔LID mapping (resolve_lid_pn hard-fails when none exists). + let bare = jid.to_non_ad(); + let lid_jid = if bare.is_lid() { + bare + } else { + self.resolve_lid_pn(bare).await?.0 + }; self.client .execute(UpdateBlocklistSpec::unblock(&lid_jid)) .await?; @@ -70,7 +87,7 @@ impl<'a> Blocking<'a> { } /// Get the full blocklist. - pub async fn get_blocklist(&self) -> anyhow::Result> { + pub async fn get_blocklist(&self) -> Result, BlockingError> { debug!(target: "Blocking", "Fetching blocklist..."); let entries = self.client.execute(GetBlocklistSpec).await?; debug!(target: "Blocking", "Fetched {} blocked contacts", entries.len()); @@ -81,7 +98,7 @@ impl<'a> Blocking<'a> { /// /// Compares only the user part of the JID, ignoring device ID, since blocking /// applies to the entire user account, not individual devices. - pub async fn is_blocked(&self, jid: &Jid) -> anyhow::Result { + pub async fn is_blocked(&self, jid: &Jid) -> Result { let blocklist = self.get_blocklist().await?; let bare = jid.to_non_ad(); diff --git a/src/features/chat_actions.rs b/src/features/chat_actions.rs index 07ad447eb..690d3b4dc 100644 --- a/src/features/chat_actions.rs +++ b/src/features/chat_actions.rs @@ -8,6 +8,7 @@ use crate::appstate_sync::Mutation; use crate::client::Client; use anyhow::Result; use log::debug; +use thiserror::Error; use wacore::appstate::patch_decode::WAPatchName; use wacore::appstate::schemas::{self, IndexPart, Schema}; use wacore::types::events::{ @@ -17,6 +18,21 @@ use wacore::types::events::{ use wacore_binary::{Jid, JidExt}; use waproto::whatsapp as wa; +/// Error returned by app-state (syncd) mutations — the shared failure domain +/// of chat actions ([`ChatActions`]) and labels ([`crate::Labels`]). +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum AppStateError { + /// The mutation arguments are invalid (e.g. a past mute timestamp, a + /// non-phone-number contact id, a missing group participant, an empty + /// label id). + #[error("invalid app-state request: {0}")] + InvalidRequest(String), + /// Encoding, key lookup, or sending the app-state patch failed. + #[error(transparent)] + Internal(#[from] anyhow::Error), +} + /// WA Web uses `-1` for indefinite mute. const MUTE_INDEFINITE: i64 = -1; @@ -375,7 +391,7 @@ impl<'a> ChatActions<'a> { &self, jid: &Jid, message_range: Option, - ) -> Result<()> { + ) -> Result<(), AppStateError> { debug!("Archiving chat {jid}"); self.send_archive_mutation(jid, true, message_range).await } @@ -384,45 +400,49 @@ impl<'a> ChatActions<'a> { &self, jid: &Jid, message_range: Option, - ) -> Result<()> { + ) -> Result<(), AppStateError> { debug!("Unarchiving chat {jid}"); self.send_archive_mutation(jid, false, message_range).await } - pub async fn pin_chat(&self, jid: &Jid) -> Result<()> { + pub async fn pin_chat(&self, jid: &Jid) -> Result<(), AppStateError> { debug!("Pinning chat {jid}"); self.send_pin_mutation(jid, true).await } - pub async fn unpin_chat(&self, jid: &Jid) -> Result<()> { + pub async fn unpin_chat(&self, jid: &Jid) -> Result<(), AppStateError> { debug!("Unpinning chat {jid}"); self.send_pin_mutation(jid, false).await } - pub async fn mute_chat(&self, jid: &Jid) -> Result<()> { + pub async fn mute_chat(&self, jid: &Jid) -> Result<(), AppStateError> { debug!("Muting chat {jid} indefinitely"); self.send_mute_mutation(jid, true, MUTE_INDEFINITE).await } /// Must be in the future. Use [`mute_chat`](Self::mute_chat) for indefinite. - pub async fn mute_chat_until(&self, jid: &Jid, mute_end_timestamp_ms: i64) -> Result<()> { + pub async fn mute_chat_until( + &self, + jid: &Jid, + mute_end_timestamp_ms: i64, + ) -> Result<(), AppStateError> { if mute_end_timestamp_ms <= 0 { - anyhow::bail!( - "mute_end_timestamp_ms must be a positive future timestamp (use mute_chat() for indefinite)" - ); + return Err(AppStateError::InvalidRequest( + "mute_end_timestamp_ms must be a positive future timestamp (use mute_chat() for indefinite)".into(), + )); } let now_ms = wacore::time::now_millis(); if mute_end_timestamp_ms <= now_ms { - anyhow::bail!( + return Err(AppStateError::InvalidRequest(format!( "mute_end_timestamp_ms is in the past ({mute_end_timestamp_ms} <= {now_ms})" - ); + ))); } debug!("Muting chat {jid} until {mute_end_timestamp_ms}"); self.send_mute_mutation(jid, true, mute_end_timestamp_ms) .await } - pub async fn unmute_chat(&self, jid: &Jid) -> Result<()> { + pub async fn unmute_chat(&self, jid: &Jid) -> Result<(), AppStateError> { debug!("Unmuting chat {jid}"); self.send_mute_mutation(jid, false, 0).await } @@ -434,7 +454,7 @@ impl<'a> ChatActions<'a> { participant_jid: Option<&Jid>, message_id: &str, from_me: bool, - ) -> Result<()> { + ) -> Result<(), AppStateError> { debug!("Starring message {message_id} in {chat_jid}"); self.send_star_mutation(chat_jid, participant_jid, message_id, from_me, true) .await @@ -446,7 +466,7 @@ impl<'a> ChatActions<'a> { participant_jid: Option<&Jid>, message_id: &str, from_me: bool, - ) -> Result<()> { + ) -> Result<(), AppStateError> { debug!("Unstarring message {message_id} in {chat_jid}"); self.send_star_mutation(chat_jid, participant_jid, message_id, from_me, false) .await @@ -458,7 +478,7 @@ impl<'a> ChatActions<'a> { jid: &Jid, read: bool, message_range: Option, - ) -> Result<()> { + ) -> Result<(), AppStateError> { debug!( "Marking chat {jid} as {}", if read { "read" } else { "unread" } @@ -482,7 +502,7 @@ impl<'a> ChatActions<'a> { jid: &Jid, delete_media: bool, message_range: Option, - ) -> Result<()> { + ) -> Result<(), AppStateError> { debug!("Deleting chat {jid}"); let delete_media_str = if delete_media { "1" } else { "0" }; let value = wa::SyncActionValue { @@ -510,7 +530,7 @@ impl<'a> ChatActions<'a> { delete_starred: bool, delete_media: bool, message_range: Option, - ) -> Result<()> { + ) -> Result<(), AppStateError> { debug!("Clearing chat {jid}"); // WA Web's $ClearChatSync$p_3 encodes both flags as "1"/"0". let delete_starred_str = if delete_starred { "1" } else { "0" }; @@ -532,7 +552,7 @@ impl<'a> ChatActions<'a> { /// Mute or unmute a contact/group/newsletter's status updates across devices /// (WA Web's userStatusMute). `muted = true` hides their status. - pub async fn set_user_status_mute(&self, jid: &Jid, muted: bool) -> Result<()> { + pub async fn set_user_status_mute(&self, jid: &Jid, muted: bool) -> Result<(), AppStateError> { debug!("Setting userStatusMute for {jid} -> {muted}"); let value = wa::SyncActionValue { user_status_mute_action: Some(wa::sync_action_value::UserStatusMuteAction { @@ -557,7 +577,7 @@ impl<'a> ChatActions<'a> { from_me: bool, delete_media: bool, message_timestamp: Option, - ) -> Result<()> { + ) -> Result<(), AppStateError> { debug!("Deleting message {message_id} for me in {chat_jid}"); let (chat, participant) = message_key_owned(chat_jid, participant_jid, from_me)?; let value = wa::SyncActionValue { @@ -598,11 +618,11 @@ impl<'a> ChatActions<'a> { full_name: Option, first_name: Option, save_on_primary_addressbook: bool, - ) -> Result<()> { + ) -> Result<(), AppStateError> { if !is_valid_contact_id(jid) { - anyhow::bail!( - "save_contact: contact id must be a bare phone-number JID (not a LID, group, or device-specific JID)" - ); + return Err(AppStateError::InvalidRequest( + "save_contact: contact id must be a bare phone-number JID (not a LID, group, or device-specific JID)".into(), + )); } debug!("Saving contact {jid}"); let value = wa::SyncActionValue { @@ -626,7 +646,7 @@ impl<'a> ChatActions<'a> { jid: &Jid, archived: bool, message_range: Option, - ) -> Result<()> { + ) -> Result<(), AppStateError> { let value = wa::SyncActionValue { archive_chat_action: Some(wa::sync_action_value::ArchiveChatAction { archived: Some(archived), @@ -641,7 +661,7 @@ impl<'a> ChatActions<'a> { .await } - async fn send_pin_mutation(&self, jid: &Jid, pinned: bool) -> Result<()> { + async fn send_pin_mutation(&self, jid: &Jid, pinned: bool) -> Result<(), AppStateError> { let value = wa::SyncActionValue { pin_action: Some(wa::sync_action_value::PinAction { pinned: Some(pinned), @@ -660,7 +680,7 @@ impl<'a> ChatActions<'a> { jid: &Jid, muted: bool, mute_end_timestamp_ms: i64, - ) -> Result<()> { + ) -> Result<(), AppStateError> { // -1 = indefinite, 0 = unmuted, positive = expiry ms let mute_end = if muted { Some(mute_end_timestamp_ms) @@ -689,7 +709,7 @@ impl<'a> ChatActions<'a> { message_id: &str, from_me: bool, starred: bool, - ) -> Result<()> { + ) -> Result<(), AppStateError> { let (chat, participant) = message_key_owned(chat_jid, participant_jid, from_me)?; let value = wa::SyncActionValue { star_action: Some(wa::sync_action_value::StarAction { @@ -727,7 +747,7 @@ impl Client { index: &[u8], value: &wa::SyncActionValue, version: i32, - ) -> Result<()> { + ) -> Result<(), AppStateError> { use rand::Rng; use wacore::appstate::encode::encode_record; @@ -737,8 +757,13 @@ impl Client { .get_latest_sync_key_id() .await .map_err(|e| anyhow::anyhow!(e))? - .ok_or_else(|| anyhow::anyhow!("No app state sync key available"))?; - let keys = proc.get_app_state_key(&key_id).await?; + .ok_or_else(|| { + AppStateError::InvalidRequest("no app state sync key available".into()) + })?; + let keys = proc + .get_app_state_key(&key_id) + .await + .map_err(|e| AppStateError::Internal(e.into()))?; let mut iv = [0u8; 16]; rand::make_rng::().fill_bytes(&mut iv); @@ -754,7 +779,8 @@ impl Client { ); self.send_app_state_patch(collection.as_str(), vec![mutation]) - .await + .await?; + Ok(()) } /// Send any app-state (syncd) `Set` action, driven by a generated @@ -792,7 +818,7 @@ impl Client { schema: &Schema, index_args: &[&str], value: &wa::SyncActionValue, - ) -> Result<()> { + ) -> Result<(), AppStateError> { let index = build_action_index(schema, index_args)?; let collection = collection_patch_name(schema.collection); self.send_app_state_mutation(collection, &index, value, schema.version as i32) diff --git a/src/features/chatstate.rs b/src/features/chatstate.rs index e188dd5d2..2e2479f71 100644 --- a/src/features/chatstate.rs +++ b/src/features/chatstate.rs @@ -1,11 +1,21 @@ //! Chat state (typing indicators) feature. -use crate::client::Client; +use crate::client::{Client, ClientError}; use log::debug; +use thiserror::Error; use wacore::WireEnum; use wacore_binary::Jid; use wacore_binary::builder::NodeBuilder; +/// Error returned by chat-state (typing indicator) operations. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum ChatStateError { + /// Connection/transport failure sending the `` stanza. + #[error(transparent)] + Client(#[from] ClientError), +} + /// Chat state type for typing indicators. #[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)] #[non_exhaustive] @@ -29,26 +39,23 @@ impl<'a> Chatstate<'a> { } /// Send a chat state update to a recipient. - pub async fn send( - &self, - to: &Jid, - state: ChatStateType, - ) -> Result<(), crate::client::ClientError> { + pub async fn send(&self, to: &Jid, state: ChatStateType) -> Result<(), ChatStateError> { debug!(target: "Chatstate", "Sending {} to {}", state, to); let node = self.build_chatstate_node(to, state); - self.client.send_node(node).await + self.client.send_node(node).await?; + Ok(()) } - pub async fn send_composing(&self, to: &Jid) -> Result<(), crate::client::ClientError> { + pub async fn send_composing(&self, to: &Jid) -> Result<(), ChatStateError> { self.send(to, ChatStateType::Composing).await } - pub async fn send_recording(&self, to: &Jid) -> Result<(), crate::client::ClientError> { + pub async fn send_recording(&self, to: &Jid) -> Result<(), ChatStateError> { self.send(to, ChatStateType::Recording).await } - pub async fn send_paused(&self, to: &Jid) -> Result<(), crate::client::ClientError> { + pub async fn send_paused(&self, to: &Jid) -> Result<(), ChatStateError> { self.send(to, ChatStateType::Paused).await } diff --git a/src/features/comments.rs b/src/features/comments.rs index 874c6ab42..b44bf786b 100644 --- a/src/features/comments.rs +++ b/src/features/comments.rs @@ -11,12 +11,11 @@ //! dispatched as their inner body `Message`; the parent post key surfaces on //! `MessageInfo::comment_target`. -use anyhow::{Result, anyhow}; use wacore_binary::Jid; use waproto::whatsapp as wa; use crate::client::Client; -use crate::send::SendResult; +use crate::send::{SendError, SendResult}; pub struct Comments<'a> { client: &'a Client, @@ -38,7 +37,7 @@ impl<'a> Comments<'a> { chat: impl Into, parent_key: wa::MessageKey, text: &str, - ) -> Result { + ) -> Result { let chat = &chat.into(); // WA Web encryptExtendedTextComment: the body is an extendedTextMessage. let body = wa::Message { @@ -57,7 +56,7 @@ impl<'a> Comments<'a> { chat: impl Into, mut parent_key: wa::MessageKey, body: wa::Message, - ) -> Result { + ) -> Result { let chat = &chat.into(); let client = self.client; let (author, secret) = client @@ -66,14 +65,14 @@ impl<'a> Comments<'a> { let parent_id = parent_key .id .clone() - .ok_or_else(|| anyhow!("parent message key missing id"))?; + .ok_or_else(|| SendError::InvalidRequest("parent message key missing id".into()))?; // WA Web comments are authored under the LID identity // (getMeLidUserOrThrow); fall back to PN only when no LID is known. let commenter = client .get_lid() .or_else(|| client.get_pn()) .map(|j| j.to_non_ad()) - .ok_or_else(|| anyhow!("not logged in"))?; + .ok_or(SendError::NotLoggedIn)?; let (enc_payload, iv) = wacore::comment::encrypt_comment_with_secret( &body, @@ -90,9 +89,9 @@ impl<'a> Comments<'a> { } // Fresh secret so the comment can itself receive encrypted add-ons. - let comment_secret: Vec = { + let comment_secret: [u8; 32] = { use rand::Rng; - let mut secret = vec![0u8; 32]; + let mut secret = [0u8; 32]; rand::make_rng::().fill_bytes(&mut secret); secret }; @@ -104,7 +103,7 @@ impl<'a> Comments<'a> { enc_iv: Some(iv.to_vec()), })), message_context_info: Some(Box::new(wa::MessageContextInfo { - message_secret: Some(comment_secret.clone()), + message_secret: Some(comment_secret.to_vec()), ..Default::default() })), ..Default::default() @@ -114,16 +113,12 @@ impl<'a> Comments<'a> { // The send path only persists reporting-token secrets, so store the // comment's own secret here or we could never decrypt add-ons // targeting our own comment. - let secret: [u8; 32] = comment_secret - .as_slice() - .try_into() - .expect("comment secret is 32 bytes"); client .persist_outbound_msg_secret( chat, &commenter, &result.message_id, - &secret, + &comment_secret, wacore::msg_secret::RetentionClass::Text, ) .await; diff --git a/src/features/community.rs b/src/features/community.rs index 697f3b3d4..59fbcca73 100644 --- a/src/features/community.rs +++ b/src/features/community.rs @@ -4,10 +4,13 @@ //! Uses the `w:g2` IQ namespace for mutations and MEX (GraphQL) for metadata queries. use crate::client::Client; +use crate::features::groups::GroupError; use crate::features::groups::GroupMetadata; use crate::features::groups::GroupParticipant; use crate::features::mex::{MexError, mex_request}; +use crate::request::IqError; use log::warn; +use thiserror::Error; use wacore::iq::groups::{ DeleteCommunityIq, GetLinkedGroupsParticipantsIq, GroupCreateIq, GroupCreateOptions, JoinLinkedGroupIq, LinkSubgroupsIq, QueryLinkedGroupIq, UnlinkSubgroupsIq, @@ -15,6 +18,24 @@ use wacore::iq::groups::{ use wacore::iq::mex_operations::{fetch_all_subgroups, query_subgroup_participant_count}; use wacore_binary::Jid; +/// Error returned by community operations. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum CommunityError { + /// A `w:g2` IQ to the server failed. + #[error(transparent)] + Iq(#[from] IqError), + /// A MEX (GraphQL) metadata query/mutation failed or returned bad data. + #[error(transparent)] + Mex(#[from] MexError), + /// A delegated group operation failed (e.g. setting the community description). + #[error(transparent)] + Group(#[from] GroupError), + /// The request was malformed or the server response was missing required data. + #[error("invalid community request: {0}")] + InvalidRequest(String), +} + // Types /// Classification of a group within the community hierarchy. @@ -125,7 +146,7 @@ impl<'a> Community<'a> { pub async fn create( &self, options: CreateCommunityOptions, - ) -> Result { + ) -> Result { let description = options.description.clone(); let create_options = GroupCreateOptions { @@ -157,7 +178,7 @@ impl<'a> Community<'a> { } /// Deactivate (delete) a community. Subgroups are unlinked but not deleted. - pub async fn deactivate(&self, community_jid: impl Into) -> Result<(), anyhow::Error> { + pub async fn deactivate(&self, community_jid: impl Into) -> Result<(), CommunityError> { let community_jid = &community_jid.into(); self.client .execute(DeleteCommunityIq::new(community_jid)) @@ -170,7 +191,7 @@ impl<'a> Community<'a> { &self, community_jid: impl Into, subgroup_jids: &[Jid], - ) -> Result { + ) -> Result { let community_jid = &community_jid.into(); let response = self .client @@ -200,7 +221,7 @@ impl<'a> Community<'a> { community_jid: impl Into, subgroup_jids: &[Jid], remove_orphan_members: bool, - ) -> Result { + ) -> Result { let community_jid = &community_jid.into(); let response = self .client @@ -232,7 +253,7 @@ impl<'a> Community<'a> { pub async fn get_subgroups( &self, community_jid: &Jid, - ) -> Result, MexError> { + ) -> Result, CommunityError> { let response = self .client .mex() @@ -242,9 +263,9 @@ impl<'a> Community<'a> { })) .await?; - let data = response - .data - .ok_or_else(|| MexError::PayloadParsing("missing data field".into()))?; + let data = response.data.ok_or_else(|| { + CommunityError::InvalidRequest("MEX response missing data field".into()) + })?; let group_query = &data["xwa2_group_query_by_id"]; let mut subgroups = Vec::new(); @@ -277,7 +298,7 @@ impl<'a> Community<'a> { pub async fn get_subgroup_participant_counts( &self, community_jid: &Jid, - ) -> Result, MexError> { + ) -> Result, CommunityError> { let response = self .client .mex() @@ -289,9 +310,9 @@ impl<'a> Community<'a> { })) .await?; - let data = response - .data - .ok_or_else(|| MexError::PayloadParsing("missing data field".into()))?; + let data = response.data.ok_or_else(|| { + CommunityError::InvalidRequest("MEX response missing data field".into()) + })?; let group_query = &data["xwa2_group_query_by_id"]; let edges_ref = group_query @@ -328,7 +349,7 @@ impl<'a> Community<'a> { &self, community_jid: impl Into, subgroup_jid: impl Into, - ) -> Result { + ) -> Result { let community_jid = &community_jid.into(); let subgroup_jid = &subgroup_jid.into(); let response = self @@ -343,7 +364,7 @@ impl<'a> Community<'a> { &self, community_jid: impl Into, subgroup_jid: impl Into, - ) -> Result { + ) -> Result { let community_jid = &community_jid.into(); let subgroup_jid = &subgroup_jid.into(); let response = self @@ -357,7 +378,7 @@ impl<'a> Community<'a> { pub async fn get_linked_groups_participants( &self, community_jid: impl Into, - ) -> Result, anyhow::Error> { + ) -> Result, CommunityError> { let community_jid = &community_jid.into(); let response = self .client diff --git a/src/features/contacts.rs b/src/features/contacts.rs index 6a7314e9e..872457a2d 100644 --- a/src/features/contacts.rs +++ b/src/features/contacts.rs @@ -5,9 +5,9 @@ use crate::client::Client; use crate::request::IqError; -use anyhow::{Result, bail}; use log::debug; use std::collections::HashMap; +use thiserror::Error; use wacore::iq::contacts::{ProfilePictureSpec, ProfilePictureType}; use wacore::iq::usync::{IsOnWhatsAppQueryType, IsOnWhatsAppSpec, IsOnWhatsAppUser, UserInfoSpec}; use wacore_binary::{Jid, JidExt}; @@ -17,9 +17,24 @@ pub use wacore::iq::contacts::ProfilePicture; pub use wacore::iq::usync::{IsOnWhatsAppResult, UserInfo, UsyncSubprotocolError}; pub use wacore::stanza::business::VerifiedName; -fn ensure_is_on_whatsapp_jids_supported(jids: &[Jid]) -> Result<()> { +/// Error returned by contact-information operations (existence checks, +/// profile pictures, user info). +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum ContactError { + /// The usync/profile IQ to the server failed. + #[error(transparent)] + Iq(#[from] IqError), + /// An input JID is not supported for this query (only PN and LID are). + #[error("unsupported contact JID: {0}")] + InvalidJid(String), +} + +fn ensure_is_on_whatsapp_jids_supported(jids: &[Jid]) -> Result<(), ContactError> { if let Some(jid) = jids.iter().find(|jid| !jid.is_pn() && !jid.is_lid()) { - bail!("is_on_whatsapp only supports PN and LID JIDs, got {jid}"); + return Err(ContactError::InvalidJid(format!( + "is_on_whatsapp only supports PN and LID JIDs, got {jid}" + ))); } Ok(()) } @@ -96,7 +111,10 @@ impl<'a> Contacts<'a> { /// Accepts both PN JIDs (`Jid::pn("1234567890")`) and LID JIDs (`Jid::lid("100000001")`). /// PN and LID queries use different protocols (matching WA Web ExistsJob), so mixed /// inputs are split into separate requests. - pub async fn is_on_whatsapp(&self, jids: &[Jid]) -> Result> { + pub async fn is_on_whatsapp( + &self, + jids: &[Jid], + ) -> Result, ContactError> { if jids.is_empty() { return Ok(Vec::new()); } @@ -153,7 +171,7 @@ impl<'a> Contacts<'a> { &self, jid: &Jid, preview: bool, - ) -> Result> { + ) -> Result, ContactError> { debug!( "get_profile_picture: fetching {} picture for {}", if preview { "preview" } else { "full" }, @@ -201,7 +219,10 @@ impl<'a> Contacts<'a> { } } - pub async fn get_user_info(&self, jids: &[Jid]) -> Result> { + pub async fn get_user_info( + &self, + jids: &[Jid], + ) -> Result, ContactError> { if jids.is_empty() { return Ok(HashMap::new()); } diff --git a/src/features/events.rs b/src/features/events.rs index f20ffe94e..91bda812f 100644 --- a/src/features/events.rs +++ b/src/features/events.rs @@ -1,6 +1,5 @@ //! Event creation and response (RSVP). -use anyhow::{Result, anyhow}; use wacore::event; use wacore_binary::{Jid, JidExt}; use waproto::whatsapp as wa; @@ -8,7 +7,7 @@ use waproto::whatsapp as wa; pub use waproto::whatsapp::message::event_response_message::EventResponseType; use crate::client::Client; -use crate::send::SendResult; +use crate::send::{SendError, SendResult}; /// Parameters for creating an event message. Only `name` is required. #[derive(Debug, Clone, Default)] @@ -38,10 +37,12 @@ impl<'a> Events<'a> { &self, to: impl Into, params: EventCreationParams, - ) -> Result<(SendResult, Vec)> { + ) -> Result<(SendResult, Vec), SendError> { let to = &to.into(); if params.name.trim().is_empty() { - return Err(anyhow!("Event name must not be empty")); + return Err(SendError::InvalidRequest( + "event name must not be empty".into(), + )); } let mut message = wa::Message { @@ -77,12 +78,17 @@ impl<'a> Events<'a> { message_secret: &[u8], response: EventResponseType, extra_guest_count: Option, - ) -> Result { + ) -> Result { let chat_jid = &chat_jid.into(); - let my_jid = self - .client - .get_pn() - .ok_or_else(|| anyhow!("Not logged in — cannot determine own JID"))?; + // The event secret keys the RSVP's HKDF; a wrong length is caller error, + // not an internal failure, so reject it before the encrypt call. + if message_secret.len() != 32 { + return Err(SendError::InvalidRequest(format!( + "event message_secret must be 32 bytes, got {}", + message_secret.len() + ))); + } + let my_jid = self.client.get_pn().ok_or(SendError::NotLoggedIn)?; let my_base = my_jid.to_non_ad(); let responder = self diff --git a/src/features/groups.rs b/src/features/groups.rs index fd9633b5c..5ee60dcf0 100644 --- a/src/features/groups.rs +++ b/src/features/groups.rs @@ -1,7 +1,9 @@ use crate::client::Client; use crate::features::mex::{MexError, mex_request}; +use crate::request::IqError; use std::collections::HashMap; use std::sync::Arc; +use thiserror::Error; use wacore::client::context::GroupInfo; use wacore::iq::contacts::SetProfilePictureSpec; // Returned by set/remove_profile_picture; re-exported so callers don't reach @@ -30,6 +32,27 @@ pub use wacore::iq::groups::{ MembershipRequest, ParticipantChangeResponse, ParticipantType, PictureType, }; +/// Error returned by group operations (metadata queries, participant and +/// settings mutations, invites, profile pictures). +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum GroupError { + /// A `w:g2` IQ to the server failed (transport, timeout, server rejection). + #[error(transparent)] + Iq(#[from] IqError), + /// A MEX (GraphQL) group-property mutation failed. + #[error(transparent)] + Mex(#[from] MexError), + /// The request was malformed (e.g. empty invite code, batch over the limit, + /// expired V4 invite, non-group JID where one is required). + #[error("invalid group request: {0}")] + InvalidRequest(String), + /// Catch-all for internal failures (LID/PN resolution, the protocol-message + /// send path behind `update_member_label`, cache plumbing). + #[error(transparent)] + Internal(#[from] anyhow::Error), +} + /// Typed `update` payload for the `update_group_property` mex mutation. The /// generated mirror types this op's `update` as a `String`, but it is a one-of /// object; this enum's `#[serde(rename_all = "snake_case")]` emits the exact @@ -219,7 +242,7 @@ impl<'a> Groups<'a> { Self { client } } - pub async fn query_info(&self, jid: &Jid) -> Result, anyhow::Error> { + pub async fn query_info(&self, jid: &Jid) -> Result, GroupError> { if let Some(cached) = self.client.get_group_cache().await.get(jid).await { return Ok(cached); } @@ -244,7 +267,9 @@ impl<'a> Groups<'a> { { GroupInfoOutcome::NotModified => { let info = Arc::new(persisted.ok_or_else(|| { - anyhow::anyhow!("server returned not-modified group but nothing was cached") + GroupError::InvalidRequest( + "server returned not-modified group but nothing was cached".into(), + ) })?); self.client .get_group_cache() @@ -323,7 +348,7 @@ impl<'a> Groups<'a> { Ok(info) } - pub async fn get_participating(&self) -> Result, anyhow::Error> { + pub async fn get_participating(&self) -> Result, GroupError> { let response = self.client.execute(GroupParticipatingIq::new()).await?; let result = response @@ -339,12 +364,12 @@ impl<'a> Groups<'a> { Ok(result) } - pub async fn get_metadata(&self, jid: &Jid) -> Result { + pub async fn get_metadata(&self, jid: &Jid) -> Result { // No phash is sent, so the server always returns the full group. match self.client.execute(GroupQueryIq::new(jid)).await? { GroupInfoOutcome::Full(group) => Ok(GroupMetadata::from(*group)), - GroupInfoOutcome::NotModified => Err(anyhow::anyhow!( - "group query returned not-modified without a phash" + GroupInfoOutcome::NotModified => Err(GroupError::InvalidRequest( + "group query returned not-modified without a phash".into(), )), } } @@ -352,7 +377,7 @@ impl<'a> Groups<'a> { pub async fn create_group( &self, mut options: GroupCreateOptions, - ) -> Result { + ) -> Result { // Resolve phone numbers for LID participants that don't have one let mut resolved_participants = Vec::with_capacity(options.participants.len()); @@ -363,7 +388,10 @@ impl<'a> Groups<'a> { .get_lid_pn_entry(&participant.jid) .await? .ok_or_else(|| { - anyhow::anyhow!("Missing phone number mapping for LID {}", participant.jid) + GroupError::InvalidRequest(format!( + "missing phone number mapping for LID {}", + participant.jid + )) })?; participant.with_phone_number(Jid::pn(&*entry.phone_number)) } else { @@ -395,7 +423,7 @@ impl<'a> Groups<'a> { &self, jid: impl Into, subject: GroupSubject, - ) -> Result<(), anyhow::Error> { + ) -> Result<(), GroupError> { let jid = &jid.into(); Ok(self .client @@ -412,7 +440,7 @@ impl<'a> Groups<'a> { jid: impl Into, description: Option, prev: Option<&str>, - ) -> Result<(), anyhow::Error> { + ) -> Result<(), GroupError> { let jid = &jid.into(); Ok(self .client @@ -420,7 +448,7 @@ impl<'a> Groups<'a> { .await?) } - pub async fn leave(&self, jid: impl Into) -> Result<(), anyhow::Error> { + pub async fn leave(&self, jid: impl Into) -> Result<(), GroupError> { let jid = &jid.into(); self.client.execute(LeaveGroupIq::new(jid)).await?; self.client.get_group_cache().await.invalidate(jid).await; @@ -442,7 +470,7 @@ impl<'a> Groups<'a> { &self, jid: impl Into, participants: &[Jid], - ) -> Result, anyhow::Error> { + ) -> Result, GroupError> { let jid = &jid.into(); let iq = if self .client @@ -481,7 +509,7 @@ impl<'a> Groups<'a> { &self, jid: impl Into, participants: &[Jid], - ) -> Result, anyhow::Error> { + ) -> Result, GroupError> { let jid = &jid.into(); let result = self .client @@ -514,7 +542,7 @@ impl<'a> Groups<'a> { &self, jid: impl Into, participants: &[Jid], - ) -> Result<(), anyhow::Error> { + ) -> Result<(), GroupError> { let jid = &jid.into(); Ok(self .client @@ -526,7 +554,7 @@ impl<'a> Groups<'a> { &self, jid: impl Into, participants: &[Jid], - ) -> Result<(), anyhow::Error> { + ) -> Result<(), GroupError> { let jid = &jid.into(); Ok(self .client @@ -538,7 +566,7 @@ impl<'a> Groups<'a> { &self, jid: impl Into, reset: bool, - ) -> Result { + ) -> Result { let jid = &jid.into(); Ok(self .client @@ -547,7 +575,7 @@ impl<'a> Groups<'a> { } /// Lock the group so only admins can change group info. - pub async fn set_locked(&self, jid: impl Into, locked: bool) -> Result<(), anyhow::Error> { + pub async fn set_locked(&self, jid: impl Into, locked: bool) -> Result<(), GroupError> { let jid = &jid.into(); let spec = if locked { SetGroupLockedIq::lock(jid) @@ -562,7 +590,7 @@ impl<'a> Groups<'a> { &self, jid: impl Into, announce: bool, - ) -> Result<(), anyhow::Error> { + ) -> Result<(), GroupError> { let jid = &jid.into(); let spec = if announce { SetGroupAnnouncementIq::announce(jid) @@ -580,7 +608,7 @@ impl<'a> Groups<'a> { &self, jid: impl Into, expiration: u32, - ) -> Result<(), anyhow::Error> { + ) -> Result<(), GroupError> { let jid = &jid.into(); let spec = match std::num::NonZeroU32::new(expiration) { Some(exp) => SetGroupEphemeralIq::enable(jid, exp), @@ -594,7 +622,7 @@ impl<'a> Groups<'a> { &self, jid: impl Into, mode: MembershipApprovalMode, - ) -> Result<(), anyhow::Error> { + ) -> Result<(), GroupError> { let jid = &jid.into(); Ok(self .client @@ -603,12 +631,9 @@ impl<'a> Groups<'a> { } /// Join a group using an invite code. - pub async fn join_with_invite_code( - &self, - code: &str, - ) -> Result { + pub async fn join_with_invite_code(&self, code: &str) -> Result { let code = extract_invite_code(code) - .ok_or_else(|| anyhow::anyhow!("invalid or empty invite code"))?; + .ok_or_else(|| GroupError::InvalidRequest("invalid or empty invite code".into()))?; Ok(self.client.execute(AcceptGroupInviteIq::new(code)).await?) } @@ -619,13 +644,15 @@ impl<'a> Groups<'a> { code: &str, expiration: i64, admin_jid: impl Into, - ) -> Result { + ) -> Result { let group_jid = &group_jid.into(); let admin_jid = &admin_jid.into(); if expiration > 0 { let now = wacore::time::now_millis() / 1000; if expiration < now { - anyhow::bail!("V4 invite has expired (expiration={expiration}, now={now})"); + return Err(GroupError::InvalidRequest(format!( + "V4 invite has expired (expiration={expiration}, now={now})" + ))); } } Ok(self @@ -640,9 +667,9 @@ impl<'a> Groups<'a> { } /// Get group metadata from an invite code without joining. - pub async fn get_invite_info(&self, code: &str) -> Result { + pub async fn get_invite_info(&self, code: &str) -> Result { let code = extract_invite_code(code) - .ok_or_else(|| anyhow::anyhow!("invalid or empty invite code"))?; + .ok_or_else(|| GroupError::InvalidRequest("invalid or empty invite code".into()))?; let group = self.client.execute(GetGroupInviteInfoIq::new(code)).await?; Ok(GroupMetadata::from(group)) } @@ -651,7 +678,7 @@ impl<'a> Groups<'a> { pub async fn get_membership_requests( &self, jid: impl Into, - ) -> Result, anyhow::Error> { + ) -> Result, GroupError> { let jid = &jid.into(); Ok(self .client @@ -664,7 +691,7 @@ impl<'a> Groups<'a> { &self, jid: impl Into, participants: &[Jid], - ) -> Result, anyhow::Error> { + ) -> Result, GroupError> { let jid = &jid.into(); Ok(self .client @@ -677,7 +704,7 @@ impl<'a> Groups<'a> { &self, jid: impl Into, participants: &[Jid], - ) -> Result, anyhow::Error> { + ) -> Result, GroupError> { let jid = &jid.into(); Ok(self .client @@ -690,7 +717,7 @@ impl<'a> Groups<'a> { &self, jid: impl Into, mode: MemberAddMode, - ) -> Result<(), anyhow::Error> { + ) -> Result<(), GroupError> { let jid = &jid.into(); Ok(self .client @@ -703,7 +730,7 @@ impl<'a> Groups<'a> { &self, jid: impl Into, restrict: bool, - ) -> Result<(), anyhow::Error> { + ) -> Result<(), GroupError> { let jid = &jid.into(); Ok(self .client @@ -716,7 +743,7 @@ impl<'a> Groups<'a> { &self, jid: impl Into, allow: bool, - ) -> Result<(), anyhow::Error> { + ) -> Result<(), GroupError> { let jid = &jid.into(); Ok(self .client @@ -729,7 +756,7 @@ impl<'a> Groups<'a> { &self, jid: impl Into, enabled: bool, - ) -> Result<(), anyhow::Error> { + ) -> Result<(), GroupError> { let jid = &jid.into(); Ok(self .client @@ -742,13 +769,14 @@ impl<'a> Groups<'a> { &self, jid: &Jid, mode: MemberLinkMode, - ) -> Result<(), MexError> { + ) -> Result<(), GroupError> { let value = match mode { MemberLinkMode::AdminLink => "ADMIN_LINK", MemberLinkMode::AllMemberLink => "ALL_MEMBER_LINK", }; - self.mex_update_group_property(jid, GroupPropertyUpdate::MemberLinkMode(value)) - .await + Ok(self + .mex_update_group_property(jid, GroupPropertyUpdate::MemberLinkMode(value)) + .await?) } /// Set who can share message history with new members (via MEX). @@ -756,25 +784,27 @@ impl<'a> Groups<'a> { &self, jid: &Jid, mode: MemberShareHistoryMode, - ) -> Result<(), MexError> { + ) -> Result<(), GroupError> { let value = match mode { MemberShareHistoryMode::AdminShare => "ADMIN_SHARE", MemberShareHistoryMode::AllMemberShare => "ALL_MEMBER_SHARE", }; - self.mex_update_group_property(jid, GroupPropertyUpdate::MemberShareGroupHistoryMode(value)) - .await + Ok(self + .mex_update_group_property(jid, GroupPropertyUpdate::MemberShareGroupHistoryMode(value)) + .await?) } /// Enable or disable limit sharing in the group (via MEX). - pub async fn set_limit_sharing(&self, jid: &Jid, enabled: bool) -> Result<(), MexError> { - self.mex_update_group_property( - jid, - GroupPropertyUpdate::LimitSharing(LimitSharingUpdate { - limit_sharing_enabled: enabled, - limit_sharing_trigger: "CHAT_SETTING", - }), - ) - .await + pub async fn set_limit_sharing(&self, jid: &Jid, enabled: bool) -> Result<(), GroupError> { + Ok(self + .mex_update_group_property( + jid, + GroupPropertyUpdate::LimitSharing(LimitSharingUpdate { + limit_sharing_enabled: enabled, + limit_sharing_trigger: "CHAT_SETTING", + }), + ) + .await?) } /// Cancel pending membership requests (from the requesting user's side). @@ -782,7 +812,7 @@ impl<'a> Groups<'a> { &self, jid: impl Into, participants: &[Jid], - ) -> Result, anyhow::Error> { + ) -> Result, GroupError> { let jid = &jid.into(); Ok(self .client @@ -795,7 +825,7 @@ impl<'a> Groups<'a> { &self, jid: impl Into, participants: &[Jid], - ) -> Result, anyhow::Error> { + ) -> Result, GroupError> { let jid = &jid.into(); Ok(self .client @@ -804,7 +834,7 @@ impl<'a> Groups<'a> { } /// Acknowledge a group notification. - pub async fn acknowledge(&self, jid: impl Into) -> Result<(), anyhow::Error> { + pub async fn acknowledge(&self, jid: impl Into) -> Result<(), GroupError> { let jid = &jid.into(); Ok(self.client.execute(AcknowledgeGroupIq::new(jid)).await?) } @@ -813,13 +843,14 @@ impl<'a> Groups<'a> { pub async fn batch_get_info( &self, jids: Vec, - ) -> Result, anyhow::Error> { - anyhow::ensure!( - jids.len() <= wacore::iq::groups::BATCH_GROUP_INFO_LIMIT, - "batch_get_info: {} groups exceeds limit of {}", - jids.len(), - wacore::iq::groups::BATCH_GROUP_INFO_LIMIT, - ); + ) -> Result, GroupError> { + if jids.len() > wacore::iq::groups::BATCH_GROUP_INFO_LIMIT { + return Err(GroupError::InvalidRequest(format!( + "batch_get_info: {} groups exceeds limit of {}", + jids.len(), + wacore::iq::groups::BATCH_GROUP_INFO_LIMIT, + ))); + } let raw = self.client.execute(BatchGetGroupInfoIq::new(jids)).await?; Ok(raw .into_iter() @@ -839,13 +870,14 @@ impl<'a> Groups<'a> { &self, group_jids: Vec, picture_type: PictureType, - ) -> Result, anyhow::Error> { - anyhow::ensure!( - group_jids.len() <= wacore::iq::groups::BATCH_PROFILE_PICTURES_LIMIT, - "get_profile_pictures: {} groups exceeds limit of {}", - group_jids.len(), - wacore::iq::groups::BATCH_PROFILE_PICTURES_LIMIT, - ); + ) -> Result, GroupError> { + if group_jids.len() > wacore::iq::groups::BATCH_PROFILE_PICTURES_LIMIT { + return Err(GroupError::InvalidRequest(format!( + "get_profile_pictures: {} groups exceeds limit of {}", + group_jids.len(), + wacore::iq::groups::BATCH_PROFILE_PICTURES_LIMIT, + ))); + } let groups = group_jids .into_iter() .map(|jid| (jid, picture_type)) @@ -872,7 +904,7 @@ impl<'a> Groups<'a> { &self, group_jid: impl Into, image_data: Vec, - ) -> Result { + ) -> Result { let group_jid = &group_jid.into(); Ok(self .client @@ -884,7 +916,7 @@ impl<'a> Groups<'a> { pub async fn remove_profile_picture( &self, group_jid: impl Into, - ) -> Result { + ) -> Result { let group_jid = &group_jid.into(); Ok(self .client @@ -933,12 +965,12 @@ impl<'a> Groups<'a> { &self, group_jid: impl Into, label: impl Into, - ) -> Result<(), anyhow::Error> { + ) -> Result<(), GroupError> { let group_jid = &group_jid.into(); if !group_jid.is_group() { - return Err(anyhow::anyhow!( + return Err(GroupError::InvalidRequest(format!( "update_member_label requires a group JID, got {group_jid}" - )); + ))); } let msg = wacore::send::build_member_label_message(label.into(), wacore::time::now_secs()); // This low-level send bypasses send_message_with_options (no reporting @@ -957,7 +989,8 @@ impl<'a> Groups<'a> { meta.into_iter().collect(), None, ) - .await + .await?; + Ok(()) } async fn resolve_participant_tokens(&self, jids: &[Jid]) -> Vec { diff --git a/src/features/labels.rs b/src/features/labels.rs index 3192d1f90..6bcf65fb3 100644 --- a/src/features/labels.rs +++ b/src/features/labels.rs @@ -10,7 +10,7 @@ use crate::appstate_sync::Mutation; use crate::client::Client; -use anyhow::Result; +use crate::features::chat_actions::AppStateError; use log::debug; use wacore::appstate::schemas; use wacore::types::events::{Event, LabelAssociationUpdate, LabelEditUpdate}; @@ -103,12 +103,21 @@ impl<'a> Labels<'a> { /// Create or update a label. App state is an upsert keyed by `label_id`, so /// this both creates a new label and renames/recolors an existing one. /// `color` is a WhatsApp color index. - pub async fn create_label(&self, label_id: &str, name: &str, color: i32) -> Result<()> { + pub async fn create_label( + &self, + label_id: &str, + name: &str, + color: i32, + ) -> Result<(), AppStateError> { if label_id.is_empty() { - anyhow::bail!("label_id cannot be empty"); + return Err(AppStateError::InvalidRequest( + "label_id cannot be empty".into(), + )); } if name.is_empty() { - anyhow::bail!("label name cannot be empty"); + return Err(AppStateError::InvalidRequest( + "label name cannot be empty".into(), + )); } // Don't log the label name (user content); the id/color are enough to trace. debug!( @@ -132,9 +141,11 @@ impl<'a> Labels<'a> { /// Delete a label. Chats keep their association rows; WA Web prunes them /// from the local DB on receipt of the delete. - pub async fn delete_label(&self, label_id: &str) -> Result<()> { + pub async fn delete_label(&self, label_id: &str) -> Result<(), AppStateError> { if label_id.is_empty() { - anyhow::bail!("label_id cannot be empty"); + return Err(AppStateError::InvalidRequest( + "label_id cannot be empty".into(), + )); } debug!("Deleting label {label_id}"); let value = wa::SyncActionValue { @@ -151,18 +162,33 @@ impl<'a> Labels<'a> { } /// Associate a label with a chat. - pub async fn add_chat_label(&self, label_id: &str, chat_jid: &Jid) -> Result<()> { + pub async fn add_chat_label( + &self, + label_id: &str, + chat_jid: &Jid, + ) -> Result<(), AppStateError> { self.send_association(label_id, chat_jid, true).await } /// Remove a label association from a chat. - pub async fn remove_chat_label(&self, label_id: &str, chat_jid: &Jid) -> Result<()> { + pub async fn remove_chat_label( + &self, + label_id: &str, + chat_jid: &Jid, + ) -> Result<(), AppStateError> { self.send_association(label_id, chat_jid, false).await } - async fn send_association(&self, label_id: &str, chat_jid: &Jid, labeled: bool) -> Result<()> { + async fn send_association( + &self, + label_id: &str, + chat_jid: &Jid, + labeled: bool, + ) -> Result<(), AppStateError> { if label_id.is_empty() { - anyhow::bail!("label_id cannot be empty"); + return Err(AppStateError::InvalidRequest( + "label_id cannot be empty".into(), + )); } debug!( "{} label {label_id} {} chat {chat_jid}", diff --git a/src/features/media_reupload.rs b/src/features/media_reupload.rs index cc8b8e89a..cdf37a2f6 100644 --- a/src/features/media_reupload.rs +++ b/src/features/media_reupload.rs @@ -7,9 +7,9 @@ //! Reference: WAWebRequestMediaReuploadManager. use crate::client::{Client, ClientError, NodeFilter}; -use anyhow::Result; use log::debug; use std::time::Duration; +use thiserror::Error; pub use wacore::media_retry::MediaRetryResult; use wacore::media_retry::{ build_media_retry_receipt, encrypt_media_retry_receipt, parse_media_retry_notification, @@ -18,6 +18,28 @@ use wacore_binary::{Jid, JidExt as _}; const MEDIA_RETRY_TIMEOUT: Duration = Duration::from_secs(30); +/// Error returned by the media reupload request flow. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum MediaReuploadError { + /// Connection/transport failure sending the server-error receipt. + #[error(transparent)] + Client(#[from] ClientError), + /// The client is not logged in. + #[error("client is not logged in")] + NotLoggedIn, + /// The request is not applicable to this message (e.g. a newsletter message + /// carries no media keys). + #[error("invalid media reupload request: {0}")] + InvalidRequest(String), + /// The server did not return a `mediaretry` notification in time. + #[error("media retry notification timed out")] + Timeout, + /// Catch-all for internal failures (receipt encryption, response parsing). + #[error(transparent)] + Internal(#[from] anyhow::Error), +} + /// Parameters for a media reupload request. pub struct MediaReuploadRequest<'a> { /// The message ID containing the media. @@ -51,12 +73,16 @@ impl<'a> MediaReupload<'a> { /// 2. Send `` with encrypted payload + `` metadata /// 3. Wait for `` response /// 4. Decrypt response and extract new `directPath` - pub async fn request(&self, req: &MediaReuploadRequest<'_>) -> Result { + pub async fn request( + &self, + req: &MediaReuploadRequest<'_>, + ) -> Result { // WA Web: ServerErrorReceiptJob rejects newsletter messages (no media keys). - anyhow::ensure!( - !req.chat_jid.is_newsletter(), - "media reupload is not supported for newsletter messages" - ); + if req.chat_jid.is_newsletter() { + return Err(MediaReuploadError::InvalidRequest( + "media reupload is not supported for newsletter messages".into(), + )); + } debug!( "[media][rmr] Requesting media reupload for msg {} in chat {}", @@ -71,7 +97,7 @@ impl<'a> MediaReupload<'a> { let own_jid = device_snapshot .pn .as_ref() - .ok_or(ClientError::NotLoggedIn)?; + .ok_or(MediaReuploadError::NotLoggedIn)?; // Register waiter BEFORE sending (to avoid race) let waiter = self.client.wait_for_node( @@ -102,8 +128,10 @@ impl<'a> MediaReupload<'a> { let notification_node = wacore::runtime::timeout(&*self.client.runtime, MEDIA_RETRY_TIMEOUT, waiter) .await - .map_err(|_| anyhow::anyhow!("media retry notification timed out after 30s"))? - .map_err(|_| anyhow::anyhow!("media retry waiter cancelled"))?; + .map_err(|_| MediaReuploadError::Timeout)? + .map_err(|_| { + MediaReuploadError::Internal(anyhow::anyhow!("media retry waiter cancelled")) + })?; debug!( "[media][rmr] Received mediaretry notification for {}", @@ -111,7 +139,10 @@ impl<'a> MediaReupload<'a> { ); // Parse and decrypt the response - parse_media_retry_notification(notification_node.get(), req.media_key) + Ok(parse_media_retry_notification( + notification_node.get(), + req.media_key, + )?) } } diff --git a/src/features/mod.rs b/src/features/mod.rs index 4e586d316..0a9f9805c 100644 --- a/src/features/mod.rs +++ b/src/features/mod.rs @@ -19,55 +19,60 @@ mod signal; pub(crate) mod status; mod tctoken; -pub use blocking::{Blocking, BlocklistEntry}; +pub use blocking::{Blocking, BlockingError, BlocklistEntry}; -pub use chat_actions::{ChatActions, SyncActionMessageRange, message_key, message_range}; +pub use chat_actions::{ + AppStateError, ChatActions, SyncActionMessageRange, message_key, message_range, +}; pub use community::{ - Community, CommunitySubgroup, CreateCommunityOptions, CreateCommunityResult, GroupType, - LinkSubgroupsResult, UnlinkSubgroupsResult, group_type, + Community, CommunityError, CommunitySubgroup, CreateCommunityOptions, CreateCommunityResult, + GroupType, LinkSubgroupsResult, UnlinkSubgroupsResult, group_type, }; -pub use chatstate::{ChatStateType, Chatstate}; +pub use chatstate::{ChatStateError, ChatStateType, Chatstate}; pub use comments::Comments; pub use contacts::{ - Contacts, IsOnWhatsAppResult, ProfilePicture, UserInfo, UsyncSubprotocolError, VerifiedName, + ContactError, Contacts, IsOnWhatsAppResult, ProfilePicture, UserInfo, UsyncSubprotocolError, + VerifiedName, }; pub use events::{EventCreationParams, EventResponseType, Events}; pub use groups::{ - BatchGroupResult, CreateGroupResult, GroupCreateOptions, GroupDescription, GroupJoinError, - GroupMetadata, GroupParticipant, GroupParticipantOptions, GroupProfilePicture, GroupSubject, - Groups, GrowthLockInfo, InviteInfoError, JoinGroupResult, MemberAddMode, MemberLinkMode, - MemberShareHistoryMode, MembershipApprovalMode, MembershipRequest, ParticipantChangeResponse, - ParticipantType, PictureType, + BatchGroupResult, CreateGroupResult, GroupCreateOptions, GroupDescription, GroupError, + GroupJoinError, GroupMetadata, GroupParticipant, GroupParticipantOptions, GroupProfilePicture, + GroupSubject, Groups, GrowthLockInfo, InviteInfoError, JoinGroupResult, MemberAddMode, + MemberLinkMode, MemberShareHistoryMode, MembershipApprovalMode, MembershipRequest, + ParticipantChangeResponse, ParticipantType, PictureType, }; pub use labels::Labels; -pub use media_reupload::{MediaRetryResult, MediaReupload, MediaReuploadRequest}; +pub use media_reupload::{ + MediaRetryResult, MediaReupload, MediaReuploadError, MediaReuploadRequest, +}; pub use message_edit::{EncryptedEdit, SecretEncKind, SecretEncrypted}; pub use mex::{Mex, MexError, MexErrorExtensions, MexGraphQLError, MexRequest, MexResponse}; pub use newsletter::{ - Newsletter, NewsletterMessage, NewsletterMessageType, NewsletterMetadata, + Newsletter, NewsletterError, NewsletterMessage, NewsletterMessageType, NewsletterMetadata, NewsletterReactionCount, NewsletterRole, NewsletterState, NewsletterVerification, }; -pub use polls::{PollOptionResult, PollVoteCiphertext, Polls}; +pub use polls::{PollError, PollOptionResult, PollVoteCiphertext, Polls}; pub use presence::{Presence, PresenceError, PresenceStatus}; -pub use profile::{Profile, SetProfilePictureResponse}; +pub use profile::{Profile, ProfileError, SetProfilePictureResponse}; pub use status::{Status, StatusPrivacySetting, StatusSendOptions}; -pub use signal::Signal; +pub use signal::{Signal, SignalError}; pub use wacore::message_processing::EncType; -pub use tctoken::TcToken; +pub use tctoken::{TcToken, TcTokenError}; diff --git a/src/features/newsletter.rs b/src/features/newsletter.rs index 0ba5f5f3d..7774b140b 100644 --- a/src/features/newsletter.rs +++ b/src/features/newsletter.rs @@ -6,8 +6,10 @@ use wacore::WireEnum; -use crate::client::Client; +use crate::client::{Client, ClientError}; use crate::features::mex::{MexError, mex_request}; +use crate::request::IqError; +use thiserror::Error; use wacore::iq::mex_operations::{ create_newsletter, fetch_all_newsletters_metadata, fetch_newsletter, join_newsletter, leave_newsletter, update_newsletter, update_newsletter_user_setting, @@ -20,6 +22,45 @@ use wacore_binary::builder::NodeBuilder; use wacore_binary::{NodeContent, NodeContentRef, NodeRef}; use waproto::whatsapp as wa; +/// Error returned by newsletter (channel) operations. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum NewsletterError { + /// A MEX (GraphQL) query/mutation failed or returned malformed data. + #[error(transparent)] + Mex(#[from] MexError), + /// An IQ (message history, live updates) failed. + #[error(transparent)] + Iq(#[from] IqError), + /// Connection/transport failure sending a plaintext stanza (edit/revoke). + #[error(transparent)] + Client(#[from] ClientError), + /// The request was malformed (e.g. a non-newsletter JID, an empty target + /// message id, or a missing element in the server response). + #[error("invalid newsletter request: {0}")] + InvalidRequest(String), + /// Catch-all for internal failures with no dedicated variant. + #[error(transparent)] + Internal(#[from] anyhow::Error), +} + +impl NewsletterError { + /// Recover the concrete typed error from an `anyhow` bubbled up by a helper + /// that still threads `anyhow` (e.g. `send_server_reaction`), so transport + /// failures stay matchable as `Client`/`Iq` instead of collapsing into the + /// `Internal` catch-all via the blanket `#[from] anyhow::Error`. + pub(crate) fn from_anyhow(err: anyhow::Error) -> Self { + match err.downcast::() { + Ok(ClientError::Iq(iq)) => NewsletterError::Iq(iq), + Ok(client) => NewsletterError::Client(client), + Err(other) => match other.downcast::() { + Ok(iq) => NewsletterError::Iq(iq), + Err(other) => NewsletterError::Internal(other), + }, + } + } +} + // Types #[derive(Debug, Clone, PartialEq, Eq, WireEnum)] @@ -124,7 +165,7 @@ impl<'a> Newsletter<'a> { } /// List all newsletters the user is subscribed to. - pub async fn list_subscribed(&self) -> Result, MexError> { + pub async fn list_subscribed(&self) -> Result, NewsletterError> { let response = self .client .mex() @@ -135,18 +176,18 @@ impl<'a> Newsletter<'a> { let data = response .data - .ok_or_else(|| MexError::PayloadParsing("missing data".into()))?; + .ok_or_else(|| NewsletterError::InvalidRequest("missing data".into()))?; let newsletters = data["xwa2_newsletter_subscribed"] .as_array() .ok_or_else(|| { - MexError::PayloadParsing("missing xwa2_newsletter_subscribed array".into()) + NewsletterError::InvalidRequest("missing xwa2_newsletter_subscribed array".into()) })?; newsletters.iter().map(parse_newsletter_metadata).collect() } /// Fetch metadata for a newsletter by its JID. - pub async fn get_metadata(&self, jid: &Jid) -> Result { + pub async fn get_metadata(&self, jid: &Jid) -> Result { let response = self .client .mex() @@ -165,10 +206,10 @@ impl<'a> Newsletter<'a> { let data = response .data - .ok_or_else(|| MexError::PayloadParsing("missing data".into()))?; + .ok_or_else(|| NewsletterError::InvalidRequest("missing data".into()))?; let newsletter = &data["xwa2_newsletter"]; if newsletter.is_null() { - return Err(MexError::PayloadParsing(format!( + return Err(NewsletterError::InvalidRequest(format!( "newsletter not found: {}", jid ))); @@ -183,7 +224,7 @@ impl<'a> Newsletter<'a> { &self, name: &str, description: Option<&str>, - ) -> Result { + ) -> Result { let response = self .client .mex() @@ -198,10 +239,10 @@ impl<'a> Newsletter<'a> { let data = response .data - .ok_or_else(|| MexError::PayloadParsing("missing data".into()))?; + .ok_or_else(|| NewsletterError::InvalidRequest("missing data".into()))?; let newsletter = &data["xwa2_newsletter_create"]; if newsletter.is_null() { - return Err(MexError::PayloadParsing( + return Err(NewsletterError::InvalidRequest( "newsletter creation failed".into(), )); } @@ -211,7 +252,7 @@ impl<'a> Newsletter<'a> { /// Join (subscribe to) a newsletter. /// /// Returns the newsletter metadata with the viewer's role set to `Subscriber`. - pub async fn join(&self, jid: &Jid) -> Result { + pub async fn join(&self, jid: &Jid) -> Result { let response = self .client .mex() @@ -222,10 +263,10 @@ impl<'a> Newsletter<'a> { let data = response .data - .ok_or_else(|| MexError::PayloadParsing("missing data".into()))?; + .ok_or_else(|| NewsletterError::InvalidRequest("missing data".into()))?; let newsletter = &data["xwa2_newsletter_join_v2"]; if newsletter.is_null() { - return Err(MexError::PayloadParsing(format!( + return Err(NewsletterError::InvalidRequest(format!( "failed to join newsletter: {}", jid ))); @@ -234,7 +275,7 @@ impl<'a> Newsletter<'a> { } /// Leave (unsubscribe from) a newsletter. - pub async fn leave(&self, jid: &Jid) -> Result<(), MexError> { + pub async fn leave(&self, jid: &Jid) -> Result<(), NewsletterError> { let response = self .client .mex() @@ -245,9 +286,9 @@ impl<'a> Newsletter<'a> { let data = response .data - .ok_or_else(|| MexError::PayloadParsing("missing data".into()))?; + .ok_or_else(|| NewsletterError::InvalidRequest("missing data".into()))?; if data["xwa2_newsletter_leave_v2"].is_null() { - return Err(MexError::PayloadParsing(format!( + return Err(NewsletterError::InvalidRequest(format!( "failed to leave newsletter: {}", jid ))); @@ -261,7 +302,7 @@ impl<'a> Newsletter<'a> { jid: &Jid, name: Option<&str>, description: Option<&str>, - ) -> Result { + ) -> Result { let response = self .client .mex() @@ -278,10 +319,10 @@ impl<'a> Newsletter<'a> { let data = response .data - .ok_or_else(|| MexError::PayloadParsing("missing data".into()))?; + .ok_or_else(|| NewsletterError::InvalidRequest("missing data".into()))?; let newsletter = &data["xwa2_newsletter_update"]; if newsletter.is_null() { - return Err(MexError::PayloadParsing(format!( + return Err(NewsletterError::InvalidRequest(format!( "failed to update newsletter: {}", jid ))); @@ -291,14 +332,14 @@ impl<'a> Newsletter<'a> { /// Mute or unmute a newsletter's follower-activity notifications /// (WA Web's `MUTE_FOLLOWER_ACTIVITY`). `muted = true` silences them. - pub async fn set_follower_mute(&self, jid: &Jid, muted: bool) -> Result<(), MexError> { + pub async fn set_follower_mute(&self, jid: &Jid, muted: bool) -> Result<(), NewsletterError> { self.set_user_setting_mute(jid, "MUTE_FOLLOWER_ACTIVITY", muted) .await } /// Mute or unmute a newsletter's admin-activity notifications /// (WA Web's `MUTE_ADMIN_ACTIVITY`). Only meaningful for owners/admins. - pub async fn set_admin_mute(&self, jid: &Jid, muted: bool) -> Result<(), MexError> { + pub async fn set_admin_mute(&self, jid: &Jid, muted: bool) -> Result<(), NewsletterError> { self.set_user_setting_mute(jid, "MUTE_ADMIN_ACTIVITY", muted) .await } @@ -308,7 +349,7 @@ impl<'a> Newsletter<'a> { jid: &Jid, mute_type: &str, muted: bool, - ) -> Result<(), MexError> { + ) -> Result<(), NewsletterError> { let response = self .client .mex() @@ -320,9 +361,9 @@ impl<'a> Newsletter<'a> { let data = response .data - .ok_or_else(|| MexError::PayloadParsing("missing data".into()))?; + .ok_or_else(|| NewsletterError::InvalidRequest("missing data".into()))?; if data["xwa2_newsletter_update_user_setting"].is_null() { - return Err(MexError::PayloadParsing(format!( + return Err(NewsletterError::InvalidRequest(format!( "failed to update newsletter user setting: {jid}" ))); } @@ -333,7 +374,7 @@ impl<'a> Newsletter<'a> { pub async fn get_metadata_by_invite( &self, invite_code: &str, - ) -> Result { + ) -> Result { let response = self .client .mex() @@ -352,10 +393,10 @@ impl<'a> Newsletter<'a> { let data = response .data - .ok_or_else(|| MexError::PayloadParsing("missing data".into()))?; + .ok_or_else(|| NewsletterError::InvalidRequest("missing data".into()))?; let newsletter = &data["xwa2_newsletter"]; if newsletter.is_null() { - return Err(MexError::PayloadParsing(format!( + return Err(NewsletterError::InvalidRequest(format!( "newsletter not found for invite: {}", invite_code ))); @@ -370,7 +411,10 @@ impl<'a> Newsletter<'a> { /// The server will send `` stanzas with /// `` children, dispatched as `Event::NewsletterLiveUpdate`. /// Returns the subscription duration in seconds. - pub async fn subscribe_live_updates(&self, jid: impl Into) -> Result { + pub async fn subscribe_live_updates( + &self, + jid: impl Into, + ) -> Result { let jid = &jid.into(); let iq = InfoQuery::set( NEWSLETTER_XMLNS, @@ -401,10 +445,12 @@ impl<'a> Newsletter<'a> { jid: &Jid, server_id: u64, reaction: &str, - ) -> Result<(), anyhow::Error> { + ) -> Result<(), NewsletterError> { self.client .send_server_reaction(jid, server_id, reaction) .await + .map_err(NewsletterError::from_anyhow)?; + Ok(()) } /// Edit a message in a newsletter (channel). Channels are plaintext (not E2E). @@ -419,16 +465,16 @@ impl<'a> Newsletter<'a> { jid: &Jid, message_id: impl Into, new_content: wa::Message, - ) -> Result<(), anyhow::Error> { + ) -> Result<(), NewsletterError> { if !jid.is_newsletter() { - return Err(anyhow::anyhow!( - "edit_message is only valid for newsletter (channel) JIDs; use Client::edit_message for DM/group" + return Err(NewsletterError::InvalidRequest( + "edit_message is only valid for newsletter (channel) JIDs; use Client::edit_message for DM/group".into(), )); } let id = message_id.into(); if id.is_empty() { - return Err(anyhow::anyhow!( - "newsletter edit needs a target message_id (NewsletterMessage.message_id is empty when the server omits the id)" + return Err(NewsletterError::InvalidRequest( + "newsletter edit needs a target message_id (NewsletterMessage.message_id is empty when the server omits the id)".into(), )); } let node = crate::send::build_newsletter_edit_node( @@ -448,16 +494,16 @@ impl<'a> Newsletter<'a> { &self, jid: &Jid, message_id: impl Into, - ) -> Result<(), anyhow::Error> { + ) -> Result<(), NewsletterError> { if !jid.is_newsletter() { - return Err(anyhow::anyhow!( - "revoke_message is only valid for newsletter (channel) JIDs; use Client::revoke_message for DM/group" + return Err(NewsletterError::InvalidRequest( + "revoke_message is only valid for newsletter (channel) JIDs; use Client::revoke_message for DM/group".into(), )); } let id = message_id.into(); if id.is_empty() { - return Err(anyhow::anyhow!( - "newsletter revoke needs a target message_id (NewsletterMessage.message_id is empty when the server omits the id)" + return Err(NewsletterError::InvalidRequest( + "newsletter revoke needs a target message_id (NewsletterMessage.message_id is empty when the server omits the id)".into(), )); } let node = @@ -475,7 +521,7 @@ impl<'a> Newsletter<'a> { jid: impl Into, count: u32, before: Option, - ) -> Result, anyhow::Error> { + ) -> Result, NewsletterError> { let jid = &jid.into(); let mut messages_node = NodeBuilder::new("messages").attr("count", count); if let Some(before_id) = before { @@ -503,11 +549,15 @@ impl Client { // JSON parsing helper -fn parse_newsletter_metadata(value: &serde_json::Value) -> Result { +fn parse_newsletter_metadata( + value: &serde_json::Value, +) -> Result { let jid_str = value["id"] .as_str() - .ok_or_else(|| MexError::PayloadParsing("missing newsletter id".into()))?; - let jid: Jid = jid_str.parse()?; + .ok_or_else(|| NewsletterError::InvalidRequest("missing newsletter id".into()))?; + let jid: Jid = jid_str + .parse() + .map_err(|e| NewsletterError::InvalidRequest(format!("invalid newsletter id: {e}")))?; let thread = &value["thread_metadata"]; @@ -614,11 +664,11 @@ pub(crate) fn parse_reaction_counts(node: &NodeRef<'_>) -> Vec, -) -> Result, anyhow::Error> { +) -> Result, NewsletterError> { // Response is the IQ result node; find child - let messages_node = response - .get_optional_child("messages") - .ok_or_else(|| anyhow::anyhow!("missing in newsletter response"))?; + let messages_node = response.get_optional_child("messages").ok_or_else(|| { + NewsletterError::InvalidRequest("missing in newsletter response".into()) + })?; let children = match messages_node.children() { Some(c) => c, diff --git a/src/features/polls.rs b/src/features/polls.rs index 7b3ba2485..81145e80c 100644 --- a/src/features/polls.rs +++ b/src/features/polls.rs @@ -2,16 +2,35 @@ use std::collections::HashMap; -use anyhow::{Result, anyhow}; +use thiserror::Error; use wacore::poll; use wacore_binary::{Jid, JidExt}; use waproto::whatsapp as wa; use crate::client::Client; -use crate::send::SendResult; +use crate::send::{SendError, SendResult}; pub use wacore::poll::PollVoteCiphertext; +/// Errors from poll operations (creation, voting, and vote decryption). +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum PollError { + /// Sending the poll/vote stanza failed (embeds the send path error). + #[error(transparent)] + Send(#[from] SendError), + /// The poll definition is invalid (option count, duplicate names, bad + /// quiz index, selectable count out of range). + #[error("invalid poll: {0}")] + InvalidPoll(String), + /// The client is not logged in, so the voter identity can't be resolved. + #[error("client is not logged in")] + NotLoggedIn, + /// Vote decryption/encryption (HKDF/GCM) failed. + #[error("poll vote crypto failed: {0}")] + Crypto(#[source] anyhow::Error), +} + #[derive(Debug, Clone)] pub struct PollOptionResult { pub name: String, @@ -34,7 +53,7 @@ impl<'a> Polls<'a> { name: &str, options: &[String], selectable_count: u32, - ) -> Result<(SendResult, Vec)> { + ) -> Result<(SendResult, Vec), PollError> { let to = &to.into(); self.create_inner(to, name, options, selectable_count, None) .await @@ -51,7 +70,7 @@ impl<'a> Polls<'a> { name: &str, options: &[String], correct_index: usize, - ) -> Result<(SendResult, Vec)> { + ) -> Result<(SendResult, Vec), PollError> { let to = &to.into(); self.create_inner(to, name, options, 1, Some(correct_index)) .await @@ -64,7 +83,7 @@ impl<'a> Polls<'a> { options: &[String], selectable_count: u32, correct_index: Option, - ) -> Result<(SendResult, Vec)> { + ) -> Result<(SendResult, Vec), PollError> { let poll_msg = build_poll_creation_message(name, options, selectable_count, correct_index)?; // WA Web: v3 for single-select, v1 for multi-select (GeneratePollCreationMessageProto.js:39-41) @@ -105,12 +124,9 @@ impl<'a> Polls<'a> { poll_creator_jid: &Jid, message_secret: &[u8], option_names: &[String], - ) -> Result { + ) -> Result { let chat_jid = &chat_jid.into(); - let my_jid = self - .client - .get_pn() - .ok_or_else(|| anyhow!("Not logged in — cannot determine own JID"))?; + let my_jid = self.client.get_pn().ok_or(PollError::NotLoggedIn)?; let my_base = my_jid.to_non_ad(); let voter_jid = self @@ -130,7 +146,8 @@ impl<'a> Polls<'a> { poll_msg_id, &creator_jid_str, &voter_jid_str, - )?; + ) + .map_err(PollError::Crypto)?; let from_me = my_base.is_same_user_as(poll_creator_jid); @@ -160,7 +177,7 @@ impl<'a> Polls<'a> { ..Default::default() }; - self.client.send_message(chat_jid, message).await + Ok(self.client.send_message(chat_jid, message).await?) } /// The voter (self) JID keys the vote's HKDF/AAD, so it must use the poll @@ -198,7 +215,7 @@ impl<'a> Polls<'a> { poll_msg_id: &str, poll_creator_jid: &Jid, voter_jid: &Jid, - ) -> Result>> { + ) -> Result>, PollError> { let creator = poll_creator_jid.to_non_ad(); let voter = voter_jid.to_non_ad(); let creator_str = creator.to_string(); @@ -218,6 +235,7 @@ impl<'a> Polls<'a> { }, fallback, ) + .map_err(PollError::Crypto) } /// Non-AD LID/PN counterpart of a user JID, or `None` when unmapped. @@ -257,7 +275,7 @@ impl<'a> Polls<'a> { message_secret: &[u8], poll_msg_id: &str, poll_creator_jid: &Jid, - ) -> Result> { + ) -> Result, PollError> { let option_hashes: Vec<([u8; 32], &str)> = poll_options .iter() .map(|name| (poll::compute_option_hash(name), name.as_str())) @@ -343,35 +361,41 @@ fn build_poll_creation_message( options: &[String], selectable_count: u32, correct_index: Option, -) -> Result { +) -> Result { if options.len() < 2 { - return Err(anyhow!("Poll must have at least 2 options")); + return Err(PollError::InvalidPoll( + "poll must have at least 2 options".into(), + )); } if options.len() > 12 { - return Err(anyhow!("Polls can have a maximum of 12 options")); + return Err(PollError::InvalidPoll( + "polls can have a maximum of 12 options".into(), + )); } if selectable_count < 1 || selectable_count > options.len() as u32 { - return Err(anyhow!( + return Err(PollError::InvalidPoll(format!( "selectable_count must be between 1 and {} (got {selectable_count})", options.len() - )); + ))); } // Duplicate names would produce identical SHA-256 hashes, making votes indistinguishable let mut seen = std::collections::HashSet::new(); for opt in options { if !seen.insert(opt) { - return Err(anyhow!("Duplicate option name: {opt}")); + return Err(PollError::InvalidPoll(format!( + "duplicate option name: {opt}" + ))); } } let (poll_type, correct_answer) = match correct_index { Some(idx) => { let correct = options.get(idx).ok_or_else(|| { - anyhow!( + PollError::InvalidPoll(format!( "correct_index {idx} out of range (poll has {} options)", options.len() - ) + )) })?; let answer = wa::message::poll_creation_message::Option { option_name: Some(correct.clone()), diff --git a/src/features/presence.rs b/src/features/presence.rs index 66dbd70cc..e14464325 100644 --- a/src/features/presence.rs +++ b/src/features/presence.rs @@ -1,4 +1,4 @@ -use crate::client::Client; +use crate::client::{Client, ClientError}; use log::{debug, warn}; use thiserror::Error; use wacore::WireEnum; @@ -12,6 +12,10 @@ use wacore_binary::builder::NodeBuilder; pub enum PresenceError { #[error("cannot send presence without a push name set")] PushNameEmpty, + /// Connection/transport failure sending the `` stanza. + #[error(transparent)] + Client(#[from] ClientError), + /// Catch-all for internal failures with no dedicated variant. #[error(transparent)] Other(#[from] anyhow::Error), } @@ -106,10 +110,8 @@ impl<'a> Presence<'a> { .unwrap_or("") ); - self.client - .send_node(node) - .await - .map_err(|e| PresenceError::Other(anyhow::Error::from(e))) + self.client.send_node(node).await?; + Ok(()) } /// Set presence to available (online). @@ -133,21 +135,18 @@ impl<'a> Presence<'a> { /// /// /// ``` - pub async fn subscribe(&self, jid: impl Into) -> Result<(), anyhow::Error> { + pub async fn subscribe(&self, jid: impl Into) -> Result<(), PresenceError> { let jid = &jid.into(); debug!("presence subscribe: subscribing to {}", jid); let node = self.build_subscription_node(jid).await; - self.client - .send_node(node) - .await - .map_err(anyhow::Error::from)?; + self.client.send_node(node).await?; self.client.track_presence_subscription(jid.clone()).await; Ok(()) } /// Re-subscribe presence if the JID has an active subscription. /// Does not modify the tracking set. - pub(crate) async fn re_subscribe_when_active(&self, jid: &Jid) -> Result<(), anyhow::Error> { + pub(crate) async fn re_subscribe_when_active(&self, jid: &Jid) -> Result<(), PresenceError> { if !self .client .presence_subscriptions @@ -159,10 +158,7 @@ impl<'a> Presence<'a> { } let node = self.build_subscription_node(jid).await; - self.client - .send_node(node) - .await - .map_err(anyhow::Error::from)?; + self.client.send_node(node).await?; Ok(()) } @@ -174,13 +170,10 @@ impl<'a> Presence<'a> { /// ```xml /// /// ``` - pub async fn unsubscribe(&self, jid: &Jid) -> Result<(), anyhow::Error> { + pub async fn unsubscribe(&self, jid: &Jid) -> Result<(), PresenceError> { debug!("presence unsubscribe: unsubscribing from {}", jid); let node = self.build_unsubscription_node(jid); - self.client - .send_node(node) - .await - .map_err(anyhow::Error::from)?; + self.client.send_node(node).await?; self.client.untrack_presence_subscription(jid).await; Ok(()) } @@ -353,8 +346,8 @@ mod tests { e ); assert!( - matches!(e, PresenceError::Other(_)), - "Expected connection error (Other), got: {}", + matches!(e, PresenceError::Client(_)), + "Expected connection error (Client), got: {}", e ); } diff --git a/src/features/profile.rs b/src/features/profile.rs index a948c84a5..b90ade18a 100644 --- a/src/features/profile.rs +++ b/src/features/profile.rs @@ -2,16 +2,36 @@ //! //! Provides APIs for changing push name (display name) and status text (about). -use crate::client::Client; +use crate::client::{Client, ClientError}; +use crate::request::IqError; use crate::store::commands::DeviceCommand; use anyhow::Result; use log::{debug, warn}; +use thiserror::Error; use wacore::iq::contacts::SetProfilePictureSpec; use wacore::iq::profile::SetStatusTextSpec; use wacore_binary::builder::NodeBuilder; pub use wacore::iq::contacts::SetProfilePictureResponse; +/// Error returned by own-profile operations (push name, status text, picture). +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum ProfileError { + /// An IQ to the server failed (status text / profile picture). + #[error(transparent)] + Iq(#[from] IqError), + /// Connection/transport failure sending a stanza (push-name presence). + #[error(transparent)] + Client(#[from] ClientError), + /// A provided argument is invalid (e.g. an empty push name). + #[error("invalid argument: {0}")] + InvalidArgument(String), + /// Catch-all for internal failures with no dedicated variant. + #[error(transparent)] + Internal(#[from] anyhow::Error), +} + /// Feature handle for profile operations. pub struct Profile<'a> { client: &'a Client, @@ -32,7 +52,7 @@ impl<'a> Profile<'a> { /// ``` /// /// Note: This sets the profile "About" text, not ephemeral text status updates. - pub async fn set_status_text(&self, text: &str) -> Result<()> { + pub async fn set_status_text(&self, text: &str) -> Result<(), ProfileError> { debug!("Setting status text (length={})", text.len()); self.client.execute(SetStatusTextSpec::new(text)).await?; @@ -54,9 +74,11 @@ impl<'a> Profile<'a> { /// ```xml /// /// ``` - pub async fn set_push_name(&self, name: &str) -> Result<()> { + pub async fn set_push_name(&self, name: &str) -> Result<(), ProfileError> { if name.is_empty() { - return Err(anyhow::anyhow!("Push name cannot be empty")); + return Err(ProfileError::InvalidArgument( + "push name cannot be empty".into(), + )); } debug!("Setting push name (length={})", name.len()); @@ -102,7 +124,7 @@ impl<'a> Profile<'a> { pub async fn set_profile_picture( &self, image_data: Vec, - ) -> Result { + ) -> Result { // for_own routes empty bytes to the remove path, matching WA Web; no panic. debug!("Setting profile picture (size={} bytes)", image_data.len()); Ok(self @@ -112,7 +134,7 @@ impl<'a> Profile<'a> { } /// Remove the user's own profile picture. - pub async fn remove_profile_picture(&self) -> Result { + pub async fn remove_profile_picture(&self) -> Result { debug!("Removing profile picture"); Ok(self .client @@ -135,7 +157,8 @@ impl<'a> Profile<'a> { // setting_pushName's index has no args (collection/version come from the schema). self.client .send_app_state_action(&schemas::SETTING_PUSH_NAME, &[], &value) - .await + .await?; + Ok(()) } } diff --git a/src/features/reaction.rs b/src/features/reaction.rs index 54d34c563..b31aeda56 100644 --- a/src/features/reaction.rs +++ b/src/features/reaction.rs @@ -8,12 +8,11 @@ //! message's `messageSecret` and emits an `enc_reaction_message` envelope. //! [`Client::send_reaction`] applies the same gate transparently. -use anyhow::anyhow; use wacore_binary::{Jid, JidExt}; use waproto::whatsapp as wa; use crate::client::Client; -use crate::send::SendResult; +use crate::send::{SendError, SendResult}; impl Client { /// React to a DM, group, or status@broadcast message. @@ -37,7 +36,7 @@ impl Client { chat: impl Into, target_key: wa::MessageKey, emoji: &str, - ) -> Result { + ) -> Result { let chat = &chat.into(); if chat.is_group() && self.is_community_announce_group(chat).await? { return self.send_enc_reaction(chat, target_key, emoji).await; @@ -58,7 +57,7 @@ impl Client { pub(crate) async fn is_community_announce_group( &self, chat: &Jid, - ) -> Result { + ) -> Result { if let Some(flag) = self.groups().query_info(chat).await?.is_community_announce { return Ok(flag); } @@ -70,14 +69,14 @@ impl Client { chat: &Jid, mut target_key: wa::MessageKey, emoji: &str, - ) -> Result { + ) -> Result { let (author, secret) = self .resolve_outgoing_addon_parent(chat, &target_key) .await?; let target_id = target_key .id .clone() - .ok_or_else(|| anyhow!("target message key missing id"))?; + .ok_or_else(|| SendError::InvalidRequest("target message key missing id".into()))?; // Receivers derive the addon key with the STANZA sender, which in a // CAG is our LID identity regardless of the parent author's namespace; // mirror the comment path (WA Web authors CAG addons under LID). @@ -85,7 +84,7 @@ impl Client { .get_lid() .or_else(|| self.get_pn()) .map(|j| j.to_non_ad()) - .ok_or_else(|| anyhow!("not logged in"))?; + .ok_or(SendError::NotLoggedIn)?; let (enc_payload, iv) = wacore::reaction::encrypt_reaction_with_secret( emoji, diff --git a/src/features/signal.rs b/src/features/signal.rs index 34f436dec..a0ed865ee 100644 --- a/src/features/signal.rs +++ b/src/features/signal.rs @@ -2,10 +2,10 @@ //! //! Encryption, decryption, session management, and participant node creation. -use anyhow::{Result, anyhow}; +use thiserror::Error; use wacore::libsignal::protocol::{ - CiphertextMessage, PreKeySignalMessage, SignalMessage, UsePQRatchet, message_decrypt, - message_encrypt, + CiphertextMessage, PreKeySignalMessage, SignalMessage, SignalProtocolError, UsePQRatchet, + message_decrypt, message_encrypt, }; use wacore::message_processing::EncType; use wacore::messages::MessageUtils; @@ -15,6 +15,22 @@ use wacore_binary::Node; use crate::client::Client; +/// Error returned by the low-level Signal protocol operations. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum SignalError { + /// A Signal protocol primitive (encrypt/decrypt/session) failed. + #[error(transparent)] + Protocol(#[from] SignalProtocolError), + /// The requested operation is not valid for this input (e.g. a sender-key + /// or message-secret envelope passed to the pairwise decrypt path). + #[error("unsupported signal operation: {0}")] + Unsupported(String), + /// Catch-all for internal failures (device resolution, cache flush). + #[error(transparent)] + Internal(#[from] anyhow::Error), +} + /// Feature handle for Signal protocol operations. pub struct Signal<'a> { client: &'a Client, @@ -32,7 +48,11 @@ impl<'a> Signal<'a> { /// /// PN JIDs are resolved to LID when a LID session exists, matching /// the internal send path. - pub async fn encrypt_message(&self, jid: &Jid, plaintext: &[u8]) -> Result<(EncType, Vec)> { + pub async fn encrypt_message( + &self, + jid: &Jid, + plaintext: &[u8], + ) -> Result<(EncType, Vec), SignalError> { // Resolve PN→LID to use the correct Signal session (matches send path) let encryption_jid = self.client.resolve_encryption_jid(jid).await; let signal_addr = encryption_jid.to_protocol_address(); @@ -53,7 +73,7 @@ impl<'a> Signal<'a> { self.client.flush_signal_cache().await?; let (_, is_prekey, bytes) = wacore::send::extract_ciphertext(encrypted) - .ok_or_else(|| anyhow!("unexpected ciphertext variant"))?; + .ok_or_else(|| SignalError::Unsupported("unexpected ciphertext variant".into()))?; let enc_type = if is_prekey { EncType::PreKeyMessage } else { @@ -74,7 +94,7 @@ impl<'a> Signal<'a> { jid: &Jid, enc_type: EncType, ciphertext: &[u8], - ) -> Result> { + ) -> Result, SignalError> { let parsed = match enc_type { EncType::PreKeyMessage => { CiphertextMessage::PreKeySignalMessage(PreKeySignalMessage::try_from(ciphertext)?) @@ -83,11 +103,13 @@ impl<'a> Signal<'a> { CiphertextMessage::SignalMessage(SignalMessage::try_from(ciphertext)?) } EncType::SenderKey => { - return Err(anyhow!("use decrypt_group_message for sender-key messages")); + return Err(SignalError::Unsupported( + "use decrypt_group_message for sender-key messages".into(), + )); } EncType::MessageSecret => { - return Err(anyhow!( - "msmsg envelopes are not Signal messages; use the bot_message path" + return Err(SignalError::Unsupported( + "msmsg envelopes are not Signal messages; use the bot_message path".into(), )); } }; @@ -141,7 +163,7 @@ impl<'a> Signal<'a> { &self, group_jid: &Jid, plaintext: &[u8], - ) -> Result<(Option>, Vec)> { + ) -> Result<(Option>, Vec), SignalError> { let own_jid = self.client.get_own_jid_for_group(group_jid).await?; let sender_addr = own_jid.to_protocol_address(); let sender_key_name = make_sender_key_name(group_jid, &sender_addr); @@ -203,7 +225,7 @@ impl<'a> Signal<'a> { group_jid: &Jid, sender_jid: &Jid, ciphertext: &[u8], - ) -> Result> { + ) -> Result, SignalError> { let sender_key_name = make_sender_key_name(group_jid, &sender_jid.to_non_ad().to_protocol_address()); @@ -225,7 +247,7 @@ impl<'a> Signal<'a> { /// /// PN JIDs are resolved to LID when a LID mapping exists, matching /// the encrypt/decrypt paths. - pub async fn validate_session(&self, jid: &Jid) -> Result { + pub async fn validate_session(&self, jid: &Jid) -> Result { let resolved = self.client.resolve_encryption_jid(jid).await; let signal_addr = resolved.to_protocol_address(); let device_snapshot = self.client.persistence_manager.get_device_snapshot(); @@ -233,7 +255,7 @@ impl<'a> Signal<'a> { .signal_cache .has_session(&signal_addr, &*device_snapshot.backend) .await - .map_err(|e| anyhow!("session check failed: {e}")) + .map_err(|e| SignalError::Internal(e.context("session check failed"))) } /// Delete Signal sessions and identity keys for the given JIDs. @@ -244,7 +266,7 @@ impl<'a> Signal<'a> { /// /// PN JIDs are resolved to LID when a LID mapping exists, matching /// the encrypt/decrypt paths. - pub async fn delete_sessions(&self, jids: &[Jid]) -> Result<()> { + pub async fn delete_sessions(&self, jids: &[Jid]) -> Result<(), SignalError> { for jid in jids { let resolved = self.client.resolve_encryption_jid(jid).await; let addr = resolved.to_protocol_address(); @@ -271,7 +293,7 @@ impl<'a> Signal<'a> { &self, recipient_jids: &[Jid], message: &waproto::whatsapp::Message, - ) -> Result<(Vec, bool)> { + ) -> Result<(Vec, bool), SignalError> { let device_jids = self.client.get_user_devices(recipient_jids).await?; self.client.ensure_e2e_sessions(&device_jids).await?; @@ -307,13 +329,14 @@ impl<'a> Signal<'a> { } /// Ensure E2E sessions exist for the given JIDs. - pub async fn assert_sessions(&self, jids: &[Jid]) -> Result<()> { - self.client.ensure_e2e_sessions(jids).await + pub async fn assert_sessions(&self, jids: &[Jid]) -> Result<(), SignalError> { + self.client.ensure_e2e_sessions(jids).await?; + Ok(()) } /// Get all known device JIDs for the given user JIDs via usync. - pub async fn get_user_devices(&self, jids: &[Jid]) -> Result> { - self.client.get_user_devices(jids).await + pub async fn get_user_devices(&self, jids: &[Jid]) -> Result, SignalError> { + Ok(self.client.get_user_devices(jids).await?) } } diff --git a/src/features/status.rs b/src/features/status.rs index 1b43cbd5a..580800b9d 100644 --- a/src/features/status.rs +++ b/src/features/status.rs @@ -3,7 +3,7 @@ use wacore_binary::Jid; use waproto::whatsapp as wa; use crate::client::Client; -use crate::send::SendResult; +use crate::send::{SendError, SendResult}; use crate::upload::UploadResponse; /// Privacy setting sent in the `` node of the status stanza. @@ -51,7 +51,7 @@ impl<'a> Status<'a> { font: i32, recipients: &[Jid], options: StatusSendOptions, - ) -> Result { + ) -> Result { let message = wa::Message { extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { text: Some(text.to_string()), @@ -78,7 +78,7 @@ impl<'a> Status<'a> { caption: Option<&str>, recipients: &[Jid], options: StatusSendOptions, - ) -> Result { + ) -> Result { let message = crate::media::image_message( upload, crate::media::ImageOptions { @@ -105,7 +105,7 @@ impl<'a> Status<'a> { caption: Option<&str>, recipients: &[Jid], options: StatusSendOptions, - ) -> Result { + ) -> Result { let message = crate::media::video_message( upload, crate::media::VideoOptions { @@ -130,7 +130,7 @@ impl<'a> Status<'a> { message: wa::Message, recipients: &[Jid], options: StatusSendOptions, - ) -> Result { + ) -> Result { self.client .send_status_message(message, recipients, options) .await @@ -145,7 +145,7 @@ impl<'a> Status<'a> { message_id: impl Into, recipients: &[Jid], options: StatusSendOptions, - ) -> Result { + ) -> Result { let message_id = message_id.into(); let to = Jid::status_broadcast(); diff --git a/src/features/tctoken.rs b/src/features/tctoken.rs index 16871cb7b..182e36cdd 100644 --- a/src/features/tctoken.rs +++ b/src/features/tctoken.rs @@ -20,10 +20,24 @@ use crate::client::Client; use crate::request::IqError; +use crate::store::error::StoreError; +use thiserror::Error; use wacore::iq::tctoken::{IssuePrivacyTokensSpec, ReceivedTcToken}; use wacore::store::traits::TcTokenEntry; use wacore_binary::Jid; +/// Error returned by trusted-contact token operations. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum TcTokenError { + /// The IQ requesting tokens from the server failed. + #[error(transparent)] + Iq(#[from] IqError), + /// A token store (persistence) operation failed. + #[error(transparent)] + Store(#[from] StoreError), +} + /// Feature handle for trusted contact token operations. pub struct TcToken<'a> { client: &'a Client, @@ -38,7 +52,7 @@ impl<'a> TcToken<'a> { /// /// Sends an IQ to the server requesting tokens for the specified JIDs (should be LID JIDs). /// Stores the received tokens and returns them. - pub async fn issue_tokens(&self, jids: &[Jid]) -> Result, IqError> { + pub async fn issue_tokens(&self, jids: &[Jid]) -> Result, TcTokenError> { if jids.is_empty() { return Ok(Vec::new()); } @@ -54,7 +68,7 @@ impl<'a> TcToken<'a> { /// /// Cutoff is AB-prop-aware via [`Client::tc_token_config()`] — the server /// may override the default 28-day window (e.g. 26 buckets = 182 days). - pub async fn prune_expired(&self) -> Result { + pub async fn prune_expired(&self) -> Result { let backend = self.client.persistence_manager.backend(); let tc_config = self.client.tc_token_config().await; let cutoff = wacore::iq::tctoken::tc_token_expiration_cutoff_with(&tc_config); @@ -68,13 +82,13 @@ impl<'a> TcToken<'a> { } /// Get a stored tc token for a JID. - pub async fn get(&self, jid: &str) -> Result, anyhow::Error> { + pub async fn get(&self, jid: &str) -> Result, TcTokenError> { let backend = self.client.persistence_manager.backend(); Ok(backend.get_tc_token(jid).await?) } /// Get all JIDs that have stored tc tokens. - pub async fn get_all_jids(&self) -> Result, anyhow::Error> { + pub async fn get_all_jids(&self) -> Result, TcTokenError> { let backend = self.client.persistence_manager.backend(); Ok(backend.get_all_tc_token_jids().await?) } diff --git a/src/lib.rs b/src/lib.rs index de34093f1..14291c917 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,6 +44,9 @@ pub mod types; pub mod client; pub(crate) mod flush_scope; pub use client::Client; +/// Shared base error for transport/connection concerns; the per-domain error +/// types embed it. +pub use client::ClientError; #[cfg(feature = "debug-diagnostics")] pub use client::MemoryDiagnostics; pub use client::NodeFilter; @@ -59,13 +62,14 @@ pub(crate) mod msg_secret_buffer; pub mod pair; pub mod pair_code; pub mod request; +pub use request::IqError; #[cfg(feature = "tokio-runtime")] pub mod runtime_impl; #[cfg(feature = "tokio-runtime")] pub use runtime_impl::TokioRuntime; pub use wacore::runtime::Runtime; pub mod send; -pub use send::{PinDuration, RevokeType, SendOptions, SendResult}; +pub use send::{PinDuration, RevokeType, SendError, SendOptions, SendResult}; pub use wacore::send::StanzaType; pub mod media; pub mod session; @@ -87,21 +91,23 @@ pub mod usync; pub mod features; pub use features::{ - BatchGroupResult, Blocking, BlocklistEntry, ChatActions, ChatStateType, Chatstate, Comments, - Community, CommunitySubgroup, Contacts, CreateCommunityOptions, CreateCommunityResult, + AppStateError, BatchGroupResult, Blocking, BlockingError, BlocklistEntry, ChatActions, + ChatStateError, ChatStateType, Chatstate, Comments, Community, CommunityError, + CommunitySubgroup, ContactError, Contacts, CreateCommunityOptions, CreateCommunityResult, CreateGroupResult, EncryptedEdit, EventCreationParams, EventResponseType, Events, - GroupCreateOptions, GroupDescription, GroupJoinError, GroupMetadata, GroupParticipant, - GroupParticipantOptions, GroupProfilePicture, GroupSubject, GroupType, Groups, GrowthLockInfo, - InviteInfoError, IsOnWhatsAppResult, JoinGroupResult, Labels, LinkSubgroupsResult, - MediaRetryResult, MediaReupload, MediaReuploadRequest, MemberAddMode, MemberLinkMode, - MemberShareHistoryMode, MembershipApprovalMode, MembershipRequest, Mex, MexError, - MexErrorExtensions, MexRequest, MexResponse, Newsletter, NewsletterMessage, - NewsletterMessageType, NewsletterMetadata, NewsletterReactionCount, NewsletterRole, - NewsletterState, NewsletterVerification, ParticipantChangeResponse, ParticipantType, - PictureType, Presence, PresenceError, PresenceStatus, Profile, ProfilePicture, SecretEncKind, - SecretEncrypted, SetProfilePictureResponse, Signal, Status, StatusPrivacySetting, - StatusSendOptions, SyncActionMessageRange, TcToken, UnlinkSubgroupsResult, UserInfo, - UsyncSubprotocolError, VerifiedName, group_type, message_key, message_range, + GroupCreateOptions, GroupDescription, GroupError, GroupJoinError, GroupMetadata, + GroupParticipant, GroupParticipantOptions, GroupProfilePicture, GroupSubject, GroupType, + Groups, GrowthLockInfo, InviteInfoError, IsOnWhatsAppResult, JoinGroupResult, Labels, + LinkSubgroupsResult, MediaRetryResult, MediaReupload, MediaReuploadError, MediaReuploadRequest, + MemberAddMode, MemberLinkMode, MemberShareHistoryMode, MembershipApprovalMode, + MembershipRequest, Mex, MexError, MexErrorExtensions, MexRequest, MexResponse, Newsletter, + NewsletterError, NewsletterMessage, NewsletterMessageType, NewsletterMetadata, + NewsletterReactionCount, NewsletterRole, NewsletterState, NewsletterVerification, + ParticipantChangeResponse, ParticipantType, PictureType, PollError, Presence, PresenceError, + PresenceStatus, Profile, ProfileError, ProfilePicture, SecretEncKind, SecretEncrypted, + SetProfilePictureResponse, Signal, SignalError, Status, StatusPrivacySetting, + StatusSendOptions, SyncActionMessageRange, TcToken, TcTokenError, UnlinkSubgroupsResult, + UserInfo, UsyncSubprotocolError, VerifiedName, group_type, message_key, message_range, }; pub mod bot; @@ -114,10 +120,11 @@ pub mod version; /// `use whatsapp_rust::prelude::*;`. pub mod prelude { pub use crate::bot::{Bot, BotBuilder, BotHandle, MessageContext}; - pub use crate::client::Client; + pub use crate::client::{Client, ClientError}; + pub use crate::request::IqError; #[cfg(feature = "tokio-runtime")] pub use crate::runtime_impl::TokioRuntime; - pub use crate::send::{SendOptions, SendResult}; + pub use crate::send::{SendError, SendOptions, SendResult}; #[cfg(feature = "sqlite-storage")] pub use crate::store::SqliteStore; pub use crate::types::events::{Event, EventKind}; diff --git a/src/message/msg_secret.rs b/src/message/msg_secret.rs index 1dabdbe65..a15261366 100644 --- a/src/message/msg_secret.rs +++ b/src/message/msg_secret.rs @@ -779,26 +779,41 @@ impl Client { &self, chat: &Jid, target_key: &wa::MessageKey, - ) -> Result<(Jid, Vec), anyhow::Error> { - use anyhow::{Context, anyhow}; + ) -> Result<(Jid, Vec), crate::send::SendError> { + use crate::send::SendError; + use wacore_binary::JidExt; let target_id = target_key .id .as_deref() - .ok_or_else(|| anyhow!("target message key missing id"))?; + .ok_or_else(|| SendError::InvalidRequest("target message key missing id".into()))?; let author: Jid = if let Some(p) = target_key.participant.as_deref() { - p.parse().context("invalid participant in target key")? + p.parse().map_err(|e| { + SendError::InvalidRequest(format!("invalid participant in target key: {e}")) + })? } else if target_key.from_me == Some(true) { self.addon_self_jid_for_chat(chat) .await - .ok_or_else(|| anyhow!("not logged in"))? + .ok_or(SendError::NotLoggedIn)? + } else if chat.is_group() { + // For group parents remote_jid is the group, not the author, so it + // can't identify the sender whose messageSecret we need. + return Err(SendError::InvalidRequest( + "target message key missing participant for group parent".into(), + )); } else { target_key .remote_jid .as_deref() - .ok_or_else(|| anyhow!("target message key missing participant and remote_jid"))? + .ok_or_else(|| { + SendError::InvalidRequest( + "target message key missing participant and remote_jid".into(), + ) + })? .parse() - .context("invalid remote_jid in target key")? + .map_err(|e| { + SendError::InvalidRequest(format!("invalid remote_jid in target key: {e}")) + })? }; let backend = self.persistence_manager.backend(); @@ -842,11 +857,11 @@ impl Client { ) .await .ok_or_else(|| { - anyhow!( + SendError::InvalidRequest(format!( "no messageSecret stored for target {target_id}; the parent \ message was not captured (received before this session, or \ msg_secret_policy disabled without a resolver)" - ) + )) })? } }; diff --git a/src/request.rs b/src/request.rs index 9291b13d6..e9bdb41ae 100644 --- a/src/request.rs +++ b/src/request.rs @@ -33,8 +33,10 @@ pub enum IqError { Socket(#[from] SocketError), #[error("encrypted send pipeline failed")] EncryptSend(#[from] EncryptSendError), + // Boxed to break the `ClientError::Iq(IqError)` <-> `IqError::ClientState` + // type cycle (both would otherwise be infinitely sized). #[error("client state prevented send")] - ClientState(#[source] ClientError), + ClientState(#[source] Box), #[error("received disconnect node during IQ wait: {0:?}")] Disconnected(Box), #[error("received a server error response: code={code}, text='{text}'")] @@ -282,9 +284,10 @@ impl Client { ClientError::Socket(s_err) => Err(IqError::Socket(s_err)), ClientError::EncryptSend(es_err) => Err(IqError::EncryptSend(es_err)), ClientError::NotConnected => Err(IqError::NotConnected), - other @ (ClientError::AlreadyConnected | ClientError::NotLoggedIn) => { - Err(IqError::ClientState(other)) - } + // The send future only ever yields the transport/state errors + // above; any other (incl. future #[non_exhaustive]) variant is + // surfaced as a client-state failure. + other => Err(IqError::ClientState(Box::new(other))), }; } diff --git a/src/send.rs b/src/send.rs index 16c36566d..01391e130 100644 --- a/src/send.rs +++ b/src/send.rs @@ -14,6 +14,98 @@ use wacore_binary::builder::NodeBuilder; use wacore_binary::{Jid, JidExt as _, Server}; use waproto::whatsapp as wa; +use crate::client::ClientError; +use crate::features::GroupError; +use crate::request::IqError; +use thiserror::Error; + +/// Error returned by the message send path ([`Client::send_message`], +/// [`Client::send_text`], [`Client::forward_message`], reactions, edits, +/// revokes, pins, polls, events, comments, status) and the bot +/// [`crate::bot::MessageContext`] helpers. +/// +/// Wraps the shared [`ClientError`] (transport/connection/IQ) and surfaces the +/// actionable send-time failure modes explicitly. `Internal` is the last-resort +/// catch-all for crypto/encoding paths that still thread `anyhow` internally. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum SendError { + /// Connection/transport/IQ failure (embeds the shared base error). + // No `#[from]`: the manual `From` impl flattens a bare `?` so + // `NotLoggedIn`/`Iq` stay matchable instead of nesting under `Client(..)`. + #[error(transparent)] + Client(ClientError), + /// The client has no PN/LID identity yet (not paired / mid LID migration). + #[error("client is not logged in")] + NotLoggedIn, + /// An IQ issued as part of the send (e.g. a group-info query) failed. + #[error("IQ request failed: {0}")] + Iq(#[from] IqError), + /// The recipient JID or send arguments are invalid for this operation + /// (e.g. a newsletter JID on the E2E path, an empty status recipient list). + #[error("invalid send request: {0}")] + InvalidRequest(String), + /// Catch-all for internal send failures (Signal encrypt, protobuf, group + /// resolution) that have no dedicated variant yet. Transparent so the + /// underlying error's `Display`/source chain is preserved. + #[error(transparent)] + Internal(#[from] anyhow::Error), +} + +impl SendError { + /// Map an `anyhow::Error` bubbled up from a helper that still threads + /// `anyhow` (e.g. `send_message_impl`, `require_pn`) into a typed + /// `SendError`, recovering the concrete [`ClientError`]. Without this the + /// blanket `#[from] anyhow::Error` would funnel a logged-out + /// `ClientError::NotLoggedIn` into the un-matchable `Internal` catch-all. + pub(crate) fn from_anyhow(err: anyhow::Error) -> Self { + // A validation deeper in the pipeline may already be a typed `SendError` + // (e.g. send_message_impl's newsletter/status guards); recover it so it + // stays matchable instead of collapsing into `Internal`. + let err = match err.downcast::() { + Ok(send) => return send, + Err(other) => other, + }; + // A group-metadata IQ in the send path (e.g. query_info) bubbles up as + // `GroupError`; flatten it before the `ClientError` check so an IQ + // failure surfaces as `SendError::Iq`, not the `Internal` catch-all. + let err = match err.downcast::() { + Ok(group) => return group.into(), + Err(other) => other, + }; + match err.downcast::() { + Ok(client) => client.into(), + Err(other) => match other.downcast::() { + Ok(iq) => SendError::Iq(iq), + Err(other) => SendError::Internal(other), + }, + } + } +} + +impl From for SendError { + fn from(err: ClientError) -> Self { + match err { + ClientError::NotLoggedIn => SendError::NotLoggedIn, + ClientError::Iq(iq) => SendError::Iq(iq), + client => SendError::Client(client), + } + } +} + +impl From for SendError { + fn from(err: GroupError) -> Self { + match err { + GroupError::Iq(iq) => SendError::Iq(iq), + GroupError::InvalidRequest(msg) => SendError::InvalidRequest(msg), + GroupError::Internal(e) => SendError::from_anyhow(e), + // No dedicated variant for MEX mutations; preserve the full typed + // error as the `Internal` source so its Display/source chain survives. + group @ GroupError::Mex(_) => SendError::Internal(group.into()), + } + } +} + /// Returns a `GroupInfo` whose participant list is guaranteed to contain our own /// sending JID, without deep-cloning the shared (cached) metadata in the common /// case where the server's participant list already includes us. @@ -412,7 +504,7 @@ impl Client { &self, to: impl Into, message: wa::Message, - ) -> Result { + ) -> Result { self.send_message_with_options_inner(to.into(), message, SendOptions::default()) .await } @@ -422,7 +514,7 @@ impl Client { &self, to: impl Into, text: impl Into, - ) -> Result { + ) -> Result { use wacore::proto_helpers::MessageBuilderExt; self.send_message_with_options_inner( to.into(), @@ -444,7 +536,7 @@ impl Client { &self, to: impl Into, message: &wa::Message, - ) -> Result { + ) -> Result { use wacore::proto_helpers::MessageExt; let body = *message.get_base_message().prepare_for_forward(); self.send_message_with_options_inner(to.into(), body, SendOptions::default()) @@ -457,7 +549,7 @@ impl Client { to: impl Into, message: wa::Message, options: SendOptions, - ) -> Result { + ) -> Result { // Thin generic shim: the large async body below stays monomorphic so // each `Into` instantiation does not duplicate the state machine. self.send_message_with_options_inner(to.into(), message, options) @@ -470,7 +562,7 @@ impl Client { to: Jid, mut message: wa::Message, options: SendOptions, - ) -> Result { + ) -> Result { let _t = wacore::telemetry::timer(wacore::telemetry::SEND_DURATION); wacore::telemetry::send(match to.server { wacore_binary::Server::Group => "group", @@ -543,7 +635,8 @@ impl Client { extra_nodes, stanza_type_override, ) - .await?; + .await + .map_err(SendError::from_anyhow)?; Ok(result) } @@ -562,12 +655,14 @@ impl Client { message: wa::Message, recipients: &[Jid], options: crate::features::status::StatusSendOptions, - ) -> Result { + ) -> Result { use wacore::client::context::GroupInfo; use wacore_binary::builder::NodeBuilder; if recipients.is_empty() { - return Err(anyhow!("Cannot send status with no recipients")); + return Err(SendError::InvalidRequest( + "cannot send status with no recipients".into(), + )); } // Status posts don't go through send_message_with_options, so count them here. @@ -580,17 +675,15 @@ impl Client { // Borrow from the held snapshot: no field clones, the Arc keeps it alive. let device_snapshot = self.persistence_manager.get_device_snapshot(); let account_info = &device_snapshot.account; - let own_jid = device_snapshot - .pn - .as_ref() - .ok_or(crate::client::ClientError::NotLoggedIn)?; + let own_jid = device_snapshot.pn.as_ref().ok_or(SendError::NotLoggedIn)?; // Status is LID-addressed (matches WA Web post-LID-migration). Without // a real device LID we can't sign or fan out correctly; refuse rather // than silently emit `addressing_mode="lid"` with a PN sender. let own_lid = device_snapshot.lid.as_ref().ok_or_else(|| { - anyhow!( - "Cannot send status: device has no LID yet. Finish pairing / LID \ + SendError::InvalidRequest( + "cannot send status: device has no LID yet. Finish pairing / LID \ migration before posting status." + .into(), ) })?; @@ -599,11 +692,10 @@ impl Client { // programming bug, not something to silently drop during resolution. for jid in recipients { if !(jid.is_pn() || jid.is_lid()) { - return Err(anyhow!( - "Invalid status recipient {}: must be a user JID (PN or LID), \ - not a group/broadcast/newsletter/hosted/etc.", - jid - )); + return Err(SendError::InvalidRequest(format!( + "invalid status recipient {jid}: must be a user JID (PN or LID), \ + not a group/broadcast/newsletter/hosted/etc." + ))); } } @@ -754,7 +846,7 @@ impl Client { ) .await? } else { - return Err(e); + return Err(e.into()); } } }; @@ -1135,7 +1227,7 @@ impl Client { to: impl Into, message_id: impl Into, revoke_type: RevokeType, - ) -> Result<(), anyhow::Error> { + ) -> Result<(), SendError> { self.revoke_message_inner(to.into(), message_id.into(), revoke_type) .await } @@ -1146,8 +1238,8 @@ impl Client { to: Jid, message_id: String, revoke_type: RevokeType, - ) -> Result<(), anyhow::Error> { - self.require_pn()?; + ) -> Result<(), SendError> { + self.require_pn().map_err(SendError::from_anyhow)?; let (from_me, participant, edit_attr) = match &revoke_type { RevokeType::Sender => { @@ -1162,7 +1254,9 @@ impl Client { RevokeType::Admin { original_sender } => { // Admin revoke requires group context if !to.is_group() { - return Err(anyhow!("Admin revoke is only valid for group chats")); + return Err(SendError::InvalidRequest( + "admin revoke is only valid for group chats".into(), + )); } // The protocolMessageKey.participant should match the original message's key exactly // Do NOT convert LID to PN - pass through unchanged like WhatsApp Web does @@ -1199,6 +1293,8 @@ impl Client { None, ) .await + .map_err(SendError::from_anyhow)?; + Ok(()) } /// Keep (or un-keep) a message in a disappearing chat for everyone. @@ -1213,7 +1309,7 @@ impl Client { chat: impl Into, key: wa::MessageKey, keep: bool, - ) -> Result { + ) -> Result { let chat = chat.into(); let message = wacore::proto_helpers::build_keep_in_chat_message( key, @@ -1229,7 +1325,7 @@ impl Client { chat: impl Into, key: wa::MessageKey, duration: PinDuration, - ) -> Result<(), anyhow::Error> { + ) -> Result<(), SendError> { self.send_pin( chat.into(), key, @@ -1244,7 +1340,7 @@ impl Client { &self, chat: impl Into, key: wa::MessageKey, - ) -> Result<(), anyhow::Error> { + ) -> Result<(), SendError> { self.send_pin( chat.into(), key, @@ -1261,7 +1357,7 @@ impl Client { key: wa::MessageKey, pin_type: wa::message::pin_in_chat_message::Type, duration_secs: u32, - ) -> Result<(), anyhow::Error> { + ) -> Result<(), SendError> { let message = wa::Message { pin_in_chat_message: Some(Box::new(wa::message::PinInChatMessage { key: Some(key), @@ -1286,6 +1382,8 @@ impl Client { None, ) .await + .map_err(SendError::from_anyhow)?; + Ok(()) } #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.send.impl", level = "debug", skip_all, fields(to = %to.observe()), err(Debug)))] @@ -1307,10 +1405,12 @@ impl Client { // / revoke_message). A newsletter JID here is a mis-routed pin/edit/revoke // (pin is not a channel op), so reject it. if to.is_newsletter() { - return Err(anyhow!( + return Err(SendError::InvalidRequest( "newsletter JIDs are not valid on the E2E send path; use \ newsletter().edit_message/revoke_message (pin is unsupported on channels)" - )); + .into(), + ) + .into()); } // status@broadcast reactions fan out pairwise to the author's devices; @@ -1324,10 +1424,11 @@ impl Client { .and_then(|p| p.parse::().ok()) .filter(|jid| jid.is_pn() || jid.is_lid()) .ok_or_else(|| { - anyhow!( + SendError::InvalidRequest( "send_message to status@broadcast requires \ reaction_message.key.participant = status author (user JID). \ Use client.status() for posting new statuses." + .into(), ) })?; (author, true) @@ -2397,6 +2498,30 @@ mod tests { ); } + // A logged-out send goes through send_message_impl, whose internal + // `ClientError::NotLoggedIn` is threaded as `anyhow`. The wrapper must + // surface the typed `SendError::NotLoggedIn`, not the `Internal` catch-all, + // so callers can match it (regression test for r3432644890). + #[tokio::test] + async fn send_message_logged_out_dm_returns_not_logged_in() { + let client = crate::test_utils::create_test_client().await; + let to: Jid = "111111111111@s.whatsapp.net".parse().unwrap(); + let err = client + .send_message( + to, + wa::Message { + conversation: Some("hi".into()), + ..Default::default() + }, + ) + .await + .expect_err("logged-out DM send must error"); + assert!( + matches!(err, SendError::NotLoggedIn), + "expected SendError::NotLoggedIn, got: {err:?}" + ); + } + #[tokio::test] async fn send_message_to_status_reaction_rejects_non_user_participant() { let client = crate::test_utils::create_test_client().await; diff --git a/tests/bench-integration/src/main.rs b/tests/bench-integration/src/main.rs index f0900c806..79c3d14f9 100644 --- a/tests/bench-integration/src/main.rs +++ b/tests/bench-integration/src/main.rs @@ -159,10 +159,10 @@ async fn bench_send_message(results: &mut BenchResults) -> anyhow::Result<()> { // -- Single send -- let m = measure(async || { - client_a + Ok(client_a .client .send_message(jid_b.clone(), text_msg("bench-send-single")) - .await + .await?) }) .await?;