Skip to content
15 changes: 9 additions & 6 deletions src/bot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ impl MessageContext {
pub async fn send_message(
&self,
message: wa::Message,
) -> Result<crate::send::SendResult, anyhow::Error> {
) -> Result<crate::send::SendResult, crate::send::SendError> {
self.client
.send_message(&self.info.source.chat, message)
.await
Expand All @@ -132,15 +132,15 @@ impl MessageContext {
pub async fn reply(
&self,
text: impl Into<String>,
) -> Result<crate::send::SendResult, anyhow::Error> {
) -> Result<crate::send::SendResult, crate::send::SendError> {
self.send_message(wa::Message::text(text)).await
}

/// Reply with plain text, quoting the received message.
pub async fn reply_quoting(
&self,
text: impl Into<String>,
) -> Result<crate::send::SendResult, anyhow::Error> {
) -> Result<crate::send::SendResult, crate::send::SendError> {
let context = self.build_quote_context();
self.send_message(wa::Message::text_with_context(text, context))
.await
Expand Down Expand Up @@ -178,7 +178,7 @@ impl MessageContext {
&self,
original_message_id: impl Into<String>,
new_message: wa::Message,
) -> Result<String, anyhow::Error> {
) -> Result<String, crate::send::SendError> {
self.client
.edit_message(&self.info.source.chat, original_message_id, new_message)
.await
Expand All @@ -190,7 +190,7 @@ impl MessageContext {
&self,
message_id: impl Into<String>,
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
Expand All @@ -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<crate::send::SendResult, anyhow::Error> {
pub async fn react(
&self,
emoji: &str,
) -> Result<crate::send::SendResult, crate::send::SendError> {
self.client
.send_reaction(&self.info.source.chat, self.message_key(), emoji)
.await
Expand Down
22 changes: 22 additions & 0 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -302,13 +309,28 @@ 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),
Comment thread
jlucaso1 marked this conversation as resolved.
}

impl ClientError {
pub fn is_transport_unavailable(&self) -> bool {
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,
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/client/context_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ impl SendContextResolver for Client {
}

async fn resolve_group_info(&self, jid: &Jid) -> Result<Arc<GroupInfo>, 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<wacore_binary::CompactString> {
Expand Down
7 changes: 4 additions & 3 deletions src/client/iq_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,15 @@ impl Client {
&self,
chat: wacore_binary::Jid,
duration: u32,
) -> Result<crate::send::SendResult, anyhow::Error> {
) -> Result<crate::send::SendResult, crate::send::SendError> {
// 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
Expand Down
50 changes: 29 additions & 21 deletions src/client/messaging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ impl Client {
to: impl Into<Jid>,
original_id: impl Into<String>,
new_content: wa::Message,
) -> Result<String, anyhow::Error> {
) -> Result<String, crate::send::SendError> {
self.edit_message_inner(to.into(), original_id.into(), new_content)
.await
}
Expand All @@ -72,19 +72,20 @@ impl Client {
to: Jid,
original_id: String,
new_content: wa::Message,
) -> Result<String, anyhow::Error> {
) -> Result<String, crate::send::SendError> {
Comment thread
jlucaso1 marked this conversation as resolved.
// 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
};
Expand Down Expand Up @@ -112,7 +113,8 @@ impl Client {
vec![],
None,
)
.await?;
.await
.map_err(crate::send::SendError::from_anyhow)?;

Ok(original_id)
}
Expand All @@ -131,7 +133,7 @@ impl Client {
original_id: impl Into<String>,
message_secret: &[u8],
new_content: wa::Message,
) -> Result<String, anyhow::Error> {
) -> Result<String, crate::send::SendError> {
self.edit_message_encrypted_inner(
to.into(),
original_id.into(),
Expand All @@ -148,26 +150,31 @@ impl Client {
original_id: String,
message_secret: &[u8],
new_content: wa::Message,
) -> Result<String, anyhow::Error> {
) -> Result<String, crate::send::SendError> {
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(),
Comment thread
jlucaso1 marked this conversation as resolved.
));
}
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())
Expand All @@ -194,7 +201,8 @@ impl Client {
vec![],
None,
)
.await?;
.await
.map_err(SendError::from_anyhow)?;

Ok(original_id)
}
Expand Down
55 changes: 36 additions & 19 deletions src/features/blocking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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())
})?;
Comment thread
jlucaso1 marked this conversation as resolved.
Ok(if bare.is_lid() {
(bare, Jid::pn(&*entry.phone_number))
} else {
Expand All @@ -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
Expand All @@ -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?;
Expand All @@ -70,7 +87,7 @@ impl<'a> Blocking<'a> {
}

/// Get the full blocklist.
pub async fn get_blocklist(&self) -> anyhow::Result<Vec<BlocklistEntry>> {
pub async fn get_blocklist(&self) -> Result<Vec<BlocklistEntry>, BlockingError> {
debug!(target: "Blocking", "Fetching blocklist...");
let entries = self.client.execute(GetBlocklistSpec).await?;
debug!(target: "Blocking", "Fetched {} blocked contacts", entries.len());
Expand All @@ -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<bool> {
pub async fn is_blocked(&self, jid: &Jid) -> Result<bool, BlockingError> {
let blocklist = self.get_blocklist().await?;
let bare = jid.to_non_ad();

Expand Down
Loading
Loading