Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/bot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,7 @@ impl EventHandler for CallbackBusAdapter {
///
/// Dropping the handle aborts the bot task. Keep it alive for as long as the
/// bot should run, and prefer [`BotHandle::shutdown`] to stop it.
#[must_use = "dropping the handle aborts the bot; bind it and await it, or call .shutdown()"]
pub struct BotHandle {
client: Arc<Client>,
done_rx: futures::channel::oneshot::Receiver<()>,
Expand Down Expand Up @@ -446,6 +447,41 @@ async fn run_metered<F: std::future::Future<Output = ()>>(
}
}

/// A configured WhatsApp session with its event handlers already wired,
/// ready to be started.
///
/// This is the high-level entry point and what most applications should use.
/// Build one with [`Bot::builder`]: the typestate [`BotBuilder`] takes the
/// storage backend (the only required dependency with the default cargo
/// features), the pairing callbacks, and the message/event handlers, then
/// hands back a `Bot`.
///
/// Starting it is a single call. [`Bot::run`] drives the session on the
/// current task until logout or shutdown; [`Bot::spawn`] starts it on the
/// runtime instead and returns a [`BotHandle`] you can await, shut down
/// gracefully, or abort. Both consume the `Bot`, so handlers are registered
/// at build time, not afterwards.
///
/// ```no_run
/// # use whatsapp_rust::prelude::*;
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
/// let bot = Bot::builder()
/// .with_backend(SqliteStore::new("whatsapp.db").await?)
/// .on_message(|ctx| async move {
/// let _ = ctx.reply("pong").await;
/// })
/// .build()
/// .await?;
///
/// bot.run().await;
/// # Ok(())
/// # }
/// ```
///
/// Handlers registered through the builder (`on_message`, `on_event`, …)
/// receive typed [`Event`](wacore::types::events::Event) payloads. Anything the
/// builder does not expose is reachable on the underlying client via
/// [`Bot::client`], which stays valid after the bot is started.
pub struct Bot {
client: Arc<Client>,
sync_task_receiver: Option<async_channel::Receiver<crate::sync_task::MajorSyncTask>>,
Expand Down Expand Up @@ -607,6 +643,7 @@ impl Bot {
/// into compile-time errors. With the default cargo features, transport, HTTP
/// client and runtime start [`Provided`] (Tokio WebSocket, ureq, Tokio), so
/// only the backend is required.
#[must_use = "call .build() to produce the Bot; the builder does nothing on its own"]
pub struct BotBuilder<
B = MissingBackend,
T = MissingTransport,
Expand Down
37 changes: 37 additions & 0 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,43 @@ impl ResponseWaiterMap {
}
}

/// A single WhatsApp session: the connection, the Signal state, and every
/// protocol operation built on top of them.
///
/// This is the low-level entry point. Build one with
/// [`ClientBuilder`](crate::client::ClientBuilder), which
/// takes the four platform dependencies (storage backend, transport factory,
/// HTTP client, async runtime) and validates them at runtime. Most applications
/// should use [`Bot`](crate::bot::Bot) instead and reach the client through
/// [`Bot::client`](crate::bot::Bot::client); `Client` is what remains when you
/// need to drive the lifecycle yourself, from an FFI host, or from a wrapper
/// that cannot express typestate generics.
///
/// The client is always used behind an `Arc` (most methods take `self: &Arc<Self>`)
/// and is cheap to clone and share across tasks.
///
/// # Lifecycle
///
/// [`Client::run`] owns the session: it connects, keeps the socket alive, and
/// reconnects with backoff until [`Client::disconnect`] is called or the device
/// is logged out. [`Client::connect`] performs a single connection attempt
/// without the supervision loop, for hosts that manage retries themselves.
///
/// # Events
///
/// Everything the server reports (messages, receipts, pairing progress,
/// connection state) is delivered as an [`Event`](wacore::types::events::Event)
/// on the event bus. Register a handler with [`Client::subscribe`] (explicit
/// [`EventInterest`](wacore::types::events::EventInterest) filter) or
/// [`Client::subscribe_handler`].
///
/// # Sending
///
/// [`Client::send_message`] covers the common path;
/// [`Client::send_message_with_options`] takes a [`SendOptions`](crate::send::SendOptions)
/// for message-id pinning, ephemeral expiration, and cache freshness. Domain
/// operations hang off accessors such as [`Client::groups`], [`Client::contacts`],
/// and [`Client::presence`].
pub struct Client {
pub(crate) runtime: Arc<dyn Runtime>,
pub(crate) core: wacore::client::CoreClient,
Expand Down
11 changes: 7 additions & 4 deletions src/client/accessors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,18 +325,21 @@ impl Client {
// itself is an Arc refcount bump (no lock against writers). Callers that
// only need a borrow can hold `persistence_manager().get_device_snapshot()`
// and read fields directly.
pub fn get_push_name(&self) -> String {
/// This device's push name (the display name peers see).
pub fn push_name(&self) -> String {
self.persistence_manager
.get_device_snapshot()
.push_name
.clone()
}

pub fn get_pn(&self) -> Option<Jid> {
/// This device's phone-number JID, or `None` before pairing completes.
pub fn pn(&self) -> Option<Jid> {
self.persistence_manager.get_device_snapshot().pn.clone()
}

pub fn get_lid(&self) -> Option<Jid> {
/// This device's LID JID, or `None` before pairing completes.
pub fn lid(&self) -> Option<Jid> {
self.persistence_manager.get_device_snapshot().lid.clone()
}

Expand Down Expand Up @@ -369,7 +372,7 @@ impl Client {
}

pub(crate) fn require_pn(&self) -> Result<Jid> {
self.get_pn().ok_or(ClientError::NotLoggedIn.into())
self.pn().ok_or(ClientError::NotLoggedIn.into())
}

/// Resolve our own JID for a group, respecting its addressing mode.
Expand Down
1 change: 1 addition & 0 deletions src/client/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ pub enum ClientBuilderError {
/// Unlike [`crate::bot::BotBuilder`], this builder deliberately does not use
/// typestate. FFI and embedded hosts can populate dependencies dynamically and
/// receive a typed error without encoding Rust generic state in their wrapper.
#[must_use = "call .build() to produce the Client; the builder does nothing on its own"]
pub struct ClientBuilder {
runtime: Option<Arc<dyn Runtime>>,
persistence_manager: Option<Arc<PersistenceManager>>,
Expand Down
4 changes: 2 additions & 2 deletions src/client/messaging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ impl Client {
.to_string(),
)
} else {
if self.get_pn().is_none() {
if self.pn().is_none() {
return Err(crate::send::SendError::NotLoggedIn);
}
None
Expand Down Expand Up @@ -213,7 +213,7 @@ impl Client {
.map_err(SendError::from_anyhow)?
.to_non_ad()
} else {
self.get_pn().ok_or(SendError::NotLoggedIn)?.to_non_ad()
self.pn().ok_or(SendError::NotLoggedIn)?.to_non_ad()
};
let participant = if to.is_group() {
Some(self_jid.to_string())
Expand Down
4 changes: 2 additions & 2 deletions src/features/comments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ impl<'a> Comments<'a> {
// 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())
.lid()
.or_else(|| client.pn())
.map(|j| j.to_non_ad())
.ok_or(SendError::NotLoggedIn)?;

Expand Down
4 changes: 2 additions & 2 deletions src/features/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ impl<'a> Events<'a> {
message_secret.len()
)));
}
let my_jid = self.client.get_pn().ok_or(SendError::NotLoggedIn)?;
let my_jid = self.client.pn().ok_or(SendError::NotLoggedIn)?;
let my_base = my_jid.to_non_ad();

let responder = self
Expand Down Expand Up @@ -142,7 +142,7 @@ impl<'a> Events<'a> {
if !event_creator_jid.is_lid() {
return own_pn.clone();
}
match self.client.get_lid() {
match self.client.lid() {
Some(lid) => lid.to_non_ad(),
None => own_pn.clone(),
}
Expand Down
37 changes: 30 additions & 7 deletions src/features/groups.rs
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,19 @@ impl<'a> Groups<'a> {
Self { client }
}

/// Query the cached, send-oriented view of a group.
///
/// Returns the slim [`GroupInfo`] the encryption path needs: participant
/// JIDs, the group's addressing mode, the LID/PN mapping, and whether it is
/// a community announcement group. A cached entry is returned as-is, so a
/// repeated call is free. Only a cache miss goes to the network, and it
/// sends the persisted participant phash, so an unchanged group costs a
/// `not-modified` answer instead of a full metadata download.
///
/// This is the right call for routing and encrypting a message. For the
/// user-facing fields (subject, description, admin roles, group settings)
/// use [`Groups::get_metadata`], and to control staleness explicitly use
/// [`Groups::query_info_with_freshness`].
Comment thread
coderabbitai[bot] marked this conversation as resolved.
pub async fn query_info(&self, jid: &Jid) -> Result<Arc<GroupInfo>, GroupError> {
self.query_info_with_freshness(jid, crate::cache::Freshness::CachePreferred)
.await
Expand Down Expand Up @@ -609,6 +622,19 @@ impl<'a> Groups<'a> {
Ok(result)
}

/// Fetch the complete, user-facing metadata of a group.
///
/// Returns an owned [`GroupMetadata`]: subject, description, creator,
/// per-participant admin roles, and ephemeral and membership settings. In a
/// LID-addressed group, participant phone numbers the server left out are
/// backfilled from known LID/PN mappings on a best-effort basis; a
/// participant with no known mapping keeps `phone_number: None`. The query
/// always hits the network (no phash is sent, so the server never answers
/// `not-modified`) and the result does not populate the group cache.
///
/// This is the right call for displaying or auditing a group. When you only
/// need the participant list to send a message, prefer the cached
/// [`Groups::query_info`].
Comment thread
coderabbitai[bot] marked this conversation as resolved.
pub async fn get_metadata(&self, jid: &Jid) -> Result<GroupMetadata, GroupError> {
// No phash is sent, so the server always returns the full group.
match self.client.execute(GroupQueryIq::new(jid)).await? {
Expand Down Expand Up @@ -944,10 +970,7 @@ impl<'a> Groups<'a> {
Ok(self
.client
.execute(AcceptGroupInviteV4Iq::new(
group_jid.clone(),
code.to_string(),
expiration,
admin_jid.clone(),
group_jid, code, expiration, admin_jid,
))
.await?)
}
Expand Down Expand Up @@ -1137,7 +1160,7 @@ impl<'a> Groups<'a> {
wacore::iq::groups::BATCH_GROUP_INFO_LIMIT,
)));
}
let raw = self.client.execute(BatchGetGroupInfoIq::new(jids)).await?;
let raw = self.client.execute(BatchGetGroupInfoIq::new(&jids)).await?;
Ok(raw
.into_iter()
.map(|r| match r {
Expand All @@ -1164,13 +1187,13 @@ impl<'a> Groups<'a> {
wacore::iq::groups::BATCH_PROFILE_PICTURES_LIMIT,
)));
}
let groups = group_jids
let groups: Vec<(Jid, PictureType)> = group_jids
.into_iter()
.map(|jid| (jid, picture_type))
.collect();
Ok(self
.client
.execute(GetGroupProfilePicturesIq::with_type(groups))
.execute(GetGroupProfilePicturesIq::with_type(&groups))
.await?)
}

Expand Down
6 changes: 3 additions & 3 deletions src/features/polls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ impl<'a> Polls<'a> {
option_names: &[String],
) -> Result<SendResult, PollError> {
let chat_jid = &chat_jid.into();
let my_jid = self.client.get_pn().ok_or(PollError::NotLoggedIn)?;
let my_jid = self.client.pn().ok_or(PollError::NotLoggedIn)?;
let my_base = my_jid.to_non_ad();

let voter_jid = self
Expand Down Expand Up @@ -193,7 +193,7 @@ impl<'a> Polls<'a> {
if !poll_creator_jid.is_lid() {
return own_pn.clone();
}
match self.client.get_lid() {
match self.client.lid() {
Some(lid) => lid.to_non_ad(),
None => {
log::warn!(
Expand Down Expand Up @@ -536,7 +536,7 @@ mod tests {
#[tokio::test]
async fn voter_falls_back_to_pn_when_own_lid_unknown() {
let client: Arc<Client> = create_test_client().await;
// No SetLid, so get_lid() is None.
// No SetLid, so lid() is None.
let own_pn = Jid::pn("5511999999999");
let creator = Jid::lid("111000111000111");

Expand Down
4 changes: 2 additions & 2 deletions src/features/reaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ impl Client {
// CAG is our LID identity regardless of the parent author's namespace;
// mirror the comment path (WA Web authors CAG addons under LID).
let reactor = self
.get_lid()
.or_else(|| self.get_pn())
.lid()
.or_else(|| self.pn())
.map(|j| j.to_non_ad())
.ok_or(SendError::NotLoggedIn)?;

Expand Down
4 changes: 2 additions & 2 deletions src/handlers/call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -354,8 +354,8 @@ impl StanzaHandler for CallHandler {
#[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.recv.call_offer_ack", level = "debug", skip_all, fields(peer = %call.from.observe()), err(Debug)))]
async fn send_offer_ack_receipt(client: &Client, call: &IncomingCall) -> anyhow::Result<()> {
let own_from = match call.from.server {
Server::Lid => client.get_lid(),
_ => client.get_pn(),
Server::Lid => client.lid(),
_ => client.pn(),
};

let Some(receipt) = build_offer_ack_receipt(call, own_from.as_ref()) else {
Expand Down
2 changes: 1 addition & 1 deletion src/history_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,7 @@ impl Client {
media_key: &[u8],
) -> Result<(), anyhow::Error> {
let own_jid = self
.get_pn()
.pn()
.ok_or(crate::client::ClientError::NotLoggedIn)?
.to_non_ad();
let (ciphertext, iv) =
Expand Down
15 changes: 8 additions & 7 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,19 +166,20 @@ pub use features::{
AppStateError, BatchGroupResult, Blocking, BlockingError, BlocklistEntry, ChatActions,
ChatStateError, ChatStateType, Chatstate, Comments, Community, CommunityError,
CommunitySubgroup, ContactError, Contacts, CreateCommunityOptions, CreateCommunityResult,
CreateGroupResult, EncryptedEdit, EventCreationParams, EventResponseType, Events,
CreateGroupResult, EncType, EncryptedEdit, EventCreationParams, EventResponseType, Events,
GroupAppealStatus, GroupCreateOptions, GroupDescription, GroupEphemeralSettings, GroupError,
GroupJoinError, GroupMetadata, GroupParticipant, GroupParticipantDetails,
GroupParticipantOptions, GroupProfilePicture, GroupSubject, GroupType, Groups, GrowthLockInfo,
InviteInfoError, IsOnWhatsAppResult, JoinGroupResult, Labels, LinkSubgroupsResult,
MediaRetryResult, MediaReupload, MediaReuploadError, MediaReuploadRequest, MemberAddMode,
MemberLinkMode, MemberShareHistoryMode, MembershipApprovalMode, MembershipRequest,
MessageRetransmission, Mex, MexError, MexErrorExtensions, MexRequest, MexResponse, NackReason,
Newsletter, NewsletterError, NewsletterMessage, NewsletterMessageType, NewsletterMetadata,
NewsletterReactionCount, NewsletterRole, NewsletterState, NewsletterVerification,
ParticipantChangeResponse, ParticipantType, PictureType, PollError, Presence, PresenceError,
PresenceStatus, Profile, ProfileError, ProfilePicture, ReachoutTimelock, RetryReason,
RetryRequestError, RetryRequestOptions, RetryRequestOutcome, SecretEncKind, SecretEncrypted,
MessageRetransmission, Mex, MexError, MexErrorExtensions, MexGraphQLError, MexRequest,
MexResponse, NackReason, Newsletter, NewsletterError, NewsletterMessage, NewsletterMessageType,
NewsletterMetadata, NewsletterReactionCount, NewsletterRole, NewsletterState,
NewsletterVerification, ParticipantChangeResponse, ParticipantType, PictureType, PollError,
PollOptionResult, PollVoteCiphertext, Polls, Presence, PresenceError, PresenceStatus, Profile,
ProfileError, ProfilePicture, ReachoutTimelock, RetryReason, RetryRequestError,
RetryRequestOptions, RetryRequestOutcome, SecretEncKind, SecretEncrypted,
SetProfilePictureResponse, Signal, SignalError, SignalSessionInfo, SignalSessionMigration,
StanzaRejection, StanzaResponseError, Status, StatusPrivacySetting, StatusSendOptions,
SyncActionMessageRange, TcToken, TcTokenError, UnlinkSubgroupsResult, UserInfo,
Expand Down
Loading
Loading