rustybook 0.2.0

An ergonomic Facebook client in Rust
Documentation
#![doc = include_str!("../README.md")]

#[cfg(all(feature = "cache", not(feature = "messenger")))]
compile_error!("feature `cache` requires feature `messenger`");

#[cfg(all(feature = "messenger", feature = "cache"))]
mod cache;
mod error;
#[cfg(feature = "messenger")]
mod events;
#[path = "client/extractor.rs"]
mod extractor_component;
#[cfg(feature = "messenger")]
#[path = "client/messenger.rs"]
mod messenger_component;

#[cfg(all(feature = "messenger", feature = "cache"))]
pub use cache::{
    Cache,
    CacheUpdate,
};
pub use error::*;
#[cfg(feature = "messenger")]
pub use events::{
    Context,
    EventHandler,
    Message,
    Presence,
    Typing,
    User,
};

use std::sync::Arc;

use rustybook_http::client::Client;
use serde::Deserialize;

#[cfg(feature = "messenger")]
use tokio::sync::Mutex;
#[cfg(feature = "messenger")]
use tokio::task::JoinHandle;

/// Rustybook is a client for interacting with Facebook services.
#[derive(Clone)]
pub struct Rustybook {
    inner: Arc<Inner>,
}

/// Send result returned by `Rustybook::send_text`.
#[cfg(feature = "messenger")]
#[derive(Debug, Clone)]
pub struct SendReceipt {
    pub thread_id: String,
    pub message_id: Option<String>,
    pub offline_threading_id: String,
}

impl Rustybook {
    /// Creates a new Rustybook client with default settings.
    pub fn new() -> Result<Rustybook, Error> {
        RustybookBuilder::new().build_with_client()
    }

    /// Returns a builder for configuring the client.
    pub fn builder() -> RustybookBuilder {
        RustybookBuilder::new()
    }

    /// Returns the cached current user info, if available.
    ///
    /// Available only with the `cache` feature.
    #[cfg(all(feature = "messenger", feature = "cache"))]
    pub async fn user(&self) -> Option<User> {
        self.inner.cache.user()
    }

    /// Returns cached message events.
    ///
    /// Available only with the `cache` feature.
    #[cfg(all(feature = "messenger", feature = "cache"))]
    pub async fn messages(&self) -> Vec<Message> {
        self.inner.cache.messages()
    }

    #[cfg(all(feature = "messenger", feature = "cache"))]
    async fn set_user(&self, user: User) {
        self.inner.cache.set_user(user);
    }
}

#[derive(Clone)]
struct SessionConfig {
    #[cfg(feature = "messenger")]
    user_id: String,
    cookie_header: String,
}

struct Inner {
    http: Client,
    #[cfg(feature = "messenger")]
    session: Option<SessionConfig>,
    #[cfg(feature = "messenger")]
    user_agent: Option<String>,
    #[cfg(feature = "messenger")]
    proxy: Option<String>,
    #[cfg(all(feature = "messenger", feature = "cache"))]
    cache: Cache,
    #[cfg(feature = "messenger")]
    messenger: Mutex<Option<rustybook_messenger::MessengerClient>>,
    #[cfg(feature = "messenger")]
    handler_task: Mutex<Option<JoinHandle<()>>>,
    #[cfg(feature = "messenger")]
    messenger_online: bool,
}

/// Builder for creating a Rustybook client.
pub struct RustybookBuilder {
    redirect_limit: u32,
    cookies_file_path: Option<String>,
    user_agent: Option<String>,
    proxy: Option<String>,
    #[cfg(feature = "messenger")]
    messenger_online: bool,
}

impl Default for RustybookBuilder {
    fn default() -> Self {
        Self::new()
    }
}

impl RustybookBuilder {
    /// Creates a builder with default options.
    pub fn new() -> Self {
        Self {
            redirect_limit: 10,
            cookies_file_path: None,
            user_agent: None,
            proxy: None,
            #[cfg(feature = "messenger")]
            messenger_online: true,
        }
    }

    /// Sets cookie JSON path used for shared HTTP session and messenger auth.
    pub fn cookies_file_path(mut self, path: impl Into<String>) -> Self {
        self.cookies_file_path = Some(path.into());
        self
    }

    /// Sets a custom user agent header.
    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
        self.user_agent = Some(user_agent.into());
        self
    }

    /// Sets an HTTP proxy URL.
    pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
        self.proxy = Some(proxy.into());
        self
    }

    /// Controls messenger online state advertised during MQTT connect.
    #[cfg(feature = "messenger")]
    pub fn online(mut self, online: bool) -> Self {
        self.messenger_online = online;
        self
    }

    /// Builds a Rustybook client.
    pub fn build(self) -> Result<Rustybook, Error> {
        self.build_with_client()
    }

    fn build_with_client(self) -> Result<Rustybook, Error> {
        let session = match self.cookies_file_path {
            Some(path) => Some(load_session_from_cookie_file(&path)?),
            None => None,
        };

        let mut builder = rustybook_http::ClientBuilder::new().max_redirect(self.redirect_limit);

        if let Some(user_agent) = self.user_agent.as_deref() {
            builder = builder.user_agent(user_agent.to_string());
        }

        if let Some(proxy) = self.proxy.as_deref() {
            builder = builder.proxy(proxy.to_string());
        }

        if let Some(session) = session.as_ref() {
            builder = builder.cookie_header(session.cookie_header.clone());
        }

        let http = builder.build()?;
        #[cfg(all(feature = "messenger", feature = "cache"))]
        let cache = initial_cache_from_session(&session);
        Ok(Rustybook {
            inner: Arc::new(Inner {
                http,
                #[cfg(feature = "messenger")]
                session,
                #[cfg(feature = "messenger")]
                user_agent: self.user_agent,
                #[cfg(feature = "messenger")]
                proxy: self.proxy,
                #[cfg(all(feature = "messenger", feature = "cache"))]
                cache,
                #[cfg(feature = "messenger")]
                messenger: Mutex::new(None),
                #[cfg(feature = "messenger")]
                handler_task: Mutex::new(None),
                #[cfg(feature = "messenger")]
                messenger_online: self.messenger_online,
            }),
        })
    }
}

#[derive(Debug, Deserialize)]
struct Cookie {
    name: String,
    value: String,
}

fn load_session_from_cookie_file(path: &str) -> Result<SessionConfig, Error> {
    let content = std::fs::read_to_string(path)
        .map_err(|error| Error::Config(format!("failed to read cookie file: {error}")))?;

    let cookies: Vec<Cookie> = serde_json::from_str(&content)
        .map_err(|error| Error::Config(format!("failed to parse cookie file: {error}")))?;

    let mut parts = Vec::with_capacity(cookies.len());
    let mut user_id = None;
    let mut has_xs = false;

    for cookie in &cookies {
        if cookie.name.is_empty() {
            continue;
        }

        parts.push(format!("{}={}", cookie.name, cookie.value));

        if cookie.name == "c_user" {
            user_id = Some(cookie.value.clone());
        }

        if cookie.name == "xs" {
            has_xs = true;
        }
    }

    let Some(user_id) = user_id else {
        return Err(Error::Config(
            "missing c_user cookie in cookie file".to_string(),
        ));
    };
    #[cfg(not(feature = "messenger"))]
    let _ = &user_id;

    if !has_xs {
        return Err(Error::Config(
            "missing xs cookie in cookie file".to_string(),
        ));
    }

    Ok(SessionConfig {
        #[cfg(feature = "messenger")]
        user_id,
        cookie_header: parts.join("; "),
    })
}

#[cfg(all(feature = "messenger", feature = "cache"))]
fn initial_cache_from_session(session: &Option<SessionConfig>) -> Cache {
    let user = session.as_ref().map(|session| User {
        id: session.user_id.clone(),
        name: None,
    });
    Cache::with_user(user)
}