rustybook-messenger 0.2.1

Messenger client for Rustybook
Documentation
use reqwest::header::HeaderValue;
use rustybook_http::client::Request as HttpRequest;
use serde_json::Value;

use super::State;
use super::http::HttpRequestMeta;
use crate::error::MessengerError;
use crate::http::graphql::strip_json_guard;

impl State {
    pub async fn fetch_sequence_id(&self) -> Result<u64, MessengerError> {
        let queries = serde_json::json!({
            "q0": {
                "doc_id": "1349387578499440",
                "query_params": {
                    "limit": 1,
                    "tags": ["INBOX"],
                    "before": serde_json::Value::Null,
                    "includeDeliveryReceipts": false,
                    "includeSeqID": true
                }
            }
        })
        .to_string();

        let mut form_fields = vec![
            ("method", "GET".to_string()),
            ("response_format", "json".to_string()),
            (
                "batch_name",
                "MessengerGraphQLThreadlistFetcher".to_string(),
            ),
            ("queries", queries),
            ("__user", self.user_id.clone()),
            ("__a", "1".to_string()),
            ("__req", "1".to_string()),
        ];
        if let Some(revision) = self.client_revision {
            form_fields.push(("__rev", revision.to_string()));
        }
        if let Some(fb_dtsg) = &self.fb_dtsg {
            form_fields.push(("fb_dtsg", fb_dtsg.clone()));
        }
        if let Some(jazoest) = &self.jazoest {
            form_fields.push(("jazoest", jazoest.clone()));
        }
        if let Some(lsd) = &self.lsd {
            form_fields.push(("lsd", lsd.clone()));
        }

        let body = encode_form_fields(&form_fields);

        let mut headers = self.base_headers()?;
        headers.insert(reqwest::header::ACCEPT, HeaderValue::from_static("*/*"));
        headers.insert(
            reqwest::header::HeaderName::from_static("origin"),
            HeaderValue::from_static("https://www.facebook.com"),
        );
        headers.insert(
            reqwest::header::REFERER,
            HeaderValue::from_static("https://www.facebook.com/"),
        );
        headers.insert(
            reqwest::header::CONTENT_TYPE,
            HeaderValue::from_static("application/x-www-form-urlencoded"),
        );

        let mut request = HttpRequest::post("https://www.facebook.com/api/graphqlbatch/")
            .headers(headers)
            .body(body.clone());

        if let Some(lsd) = &self.lsd {
            let lsd_header = HeaderValue::from_str(lsd)
                .map_err(|error| MessengerError::State(format!("invalid lsd header: {error}")))?;
            request = request.header(
                reqwest::header::HeaderName::from_static("x-fb-lsd"),
                lsd_header,
            );
        }

        let text = self
            .send(
                request,
                HttpRequestMeta {
                    label: "graphql_batch_sequence".to_string(),
                    method: "POST".to_string(),
                    url: "https://www.facebook.com/api/graphqlbatch/".to_string(),
                },
            )
            .await?;

        if text.trim().is_empty() {
            return Err(MessengerError::State(
                "empty graphql batch response".to_string(),
            ));
        }

        extract_sequence_id_from_graphql_response(&text)
    }
}

pub(super) fn encode_form_fields(fields: &[(&str, String)]) -> String {
    let mut output = String::new();
    for (index, (key, value)) in fields.iter().enumerate() {
        if index > 0 {
            output.push('&');
        }
        output.push_str(key);
        output.push('=');
        output.push_str(&url_encode_component(value));
    }
    output
}

pub(super) fn extract_sequence_id_from_graphql_response(text: &str) -> Result<u64, MessengerError> {
    let parsed = strip_json_guard(text).trim();
    if parsed.is_empty() {
        return Err(MessengerError::State(
            "empty graphql batch response".to_string(),
        ));
    }

    if let Ok(value) = serde_json::from_str::<Value>(parsed)
        && let Some(sequence_id) = find_sync_sequence_id(&value)
    {
        return Ok(sequence_id);
    }

    for line in parsed.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        let Ok(value) = serde_json::from_str::<Value>(line) else {
            continue;
        };
        if let Some(sequence_id) = find_sync_sequence_id(&value) {
            return Ok(sequence_id);
        }
    }

    Err(MessengerError::State(
        "missing sync_sequence_id from graphql response".to_string(),
    ))
}

fn url_encode_component(value: &str) -> String {
    let mut output = String::with_capacity(value.len());
    for byte in value.bytes() {
        let is_unreserved =
            byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~');
        if is_unreserved {
            output.push(byte as char);
        } else {
            output.push('%');
            output.push_str(&format!("{byte:02X}"));
        }
    }
    output
}

fn find_sync_sequence_id(value: &Value) -> Option<u64> {
    match value {
        Value::Object(map) => {
            if let Some(sequence) = map.get("sync_sequence_id")
                && let Some(id) = value_to_u64(sequence)
            {
                return Some(id);
            }
            for nested in map.values() {
                if let Some(id) = find_sync_sequence_id(nested) {
                    return Some(id);
                }
            }
            None
        }
        Value::Array(items) => {
            for item in items {
                if let Some(id) = find_sync_sequence_id(item) {
                    return Some(id);
                }
            }
            None
        }
        _ => None,
    }
}

fn value_to_u64(value: &Value) -> Option<u64> {
    match value {
        Value::Number(number) => number.as_u64(),
        Value::String(text) => text.parse::<u64>().ok(),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::{
        encode_form_fields,
        extract_sequence_id_from_graphql_response,
    };

    #[test]
    fn extracts_sequence_id_from_array_response() {
        let text = r#"for (;;);[{"o0":{"data":{"viewer":{"message_threads":{"sync_sequence_id":"12345"}}}}}]"#;
        let sequence_id = extract_sequence_id_from_graphql_response(text)
            .unwrap_or_else(|error| panic!("failed to extract sequence id: {error}"));
        assert_eq!(sequence_id, 12345);
    }

    #[test]
    fn extracts_sequence_id_from_json_lines_response() {
        let text = r#"for (;;);
{"o0":{"data":{"viewer":{"message_threads":{"sync_sequence_id":"777"}}}}}
{"q0":{"response":{"ok":true}}}
"#;
        let sequence_id = extract_sequence_id_from_graphql_response(text)
            .unwrap_or_else(|error| panic!("failed to extract sequence id: {error}"));
        assert_eq!(sequence_id, 777);
    }

    #[test]
    fn encodes_form_fields() {
        let fields = vec![("a", "value".to_string()), ("b", "a+b&c=d".to_string())];
        let encoded = encode_form_fields(&fields);
        assert_eq!(encoded, "a=value&b=a%2Bb%26c%3Dd");
    }
}