use std::error::Error;
use std::fs;
use std::path::{
Path,
PathBuf,
};
use std::time::{
SystemTime,
UNIX_EPOCH,
};
use regex::Regex;
use reqwest::header::{
COOKIE,
HeaderMap,
HeaderValue,
USER_AGENT,
};
use serde::Deserialize;
const DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36";
#[derive(Debug, Deserialize)]
struct Cookie {
name: String,
value: String,
}
#[derive(Debug, Default)]
struct PageTokens {
fb_dtsg: Option<String>,
lsd: Option<String>,
jazoest: Option<String>,
client_revision: Option<u64>,
user_id: Option<String>,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let mut args = std::env::args();
let _bin = args.next();
let Some(cookies_path) = args.next() else {
eprintln!(
"usage: cargo run -p rustybook-messenger --example bootstrap_dump -- <cookies.json> [output_dir]"
);
return Ok(());
};
let output_dir = args
.next()
.map(PathBuf::from)
.unwrap_or_else(default_output_dir);
fs::create_dir_all(&output_dir)?;
let cookie_header = load_cookie_header(&cookies_path)?;
let user_id = extract_cookie_value(&cookie_header, "c_user");
let client = reqwest::Client::builder().gzip(true).build()?;
let pages = [
("facebook_home", "https://www.facebook.com/"),
("messenger_home", "https://www.messenger.com/"),
("facebook_messages", "https://www.facebook.com/messages/t/"),
];
let mut merged = PageTokens::default();
merged.user_id = user_id.clone();
for (index, (label, url)) in pages.iter().enumerate() {
let request_headers = build_headers(&cookie_header)?;
let response = client
.get(*url)
.headers(request_headers.clone())
.send()
.await?;
let status = response.status();
let response_headers = response.headers().clone();
let body = response.text().await?;
let tokens = detect_tokens(&body)?;
if merged.fb_dtsg.is_none() {
merged.fb_dtsg = tokens.fb_dtsg.clone();
}
if merged.lsd.is_none() {
merged.lsd = tokens.lsd.clone();
}
if merged.jazoest.is_none() {
merged.jazoest = tokens.jazoest.clone();
}
if merged.client_revision.is_none() {
merged.client_revision = tokens.client_revision;
}
let prefix = format!("{:02}_{}", index + 1, label);
write_request_dump(&output_dir, &prefix, "GET", url, &request_headers, None)?;
write_response_dump(
&output_dir,
&prefix,
status.as_u16(),
&response_headers,
&body,
)?;
println!(
"{}: status={} fb_dtsg={} lsd={} jazoest={} client_revision={}",
label,
status.as_u16(),
has_value(tokens.fb_dtsg.as_deref()),
has_value(tokens.lsd.as_deref()),
has_value(tokens.jazoest.as_deref()),
tokens
.client_revision
.map(|v| v.to_string())
.unwrap_or_else(|| "no".to_string()),
);
}
if merged.jazoest.is_none() {
merged.jazoest = compute_jazoest(merged.fb_dtsg.as_deref());
}
let graphql_body = build_graphql_body(&merged);
let graphql_headers = build_headers(&cookie_header)?;
let mut graphql_request = client
.post("https://www.facebook.com/api/graphqlbatch/")
.headers(graphql_headers.clone())
.header("accept", "*/*")
.header("origin", "https://www.facebook.com")
.header("referer", "https://www.facebook.com/")
.header("content-type", "application/x-www-form-urlencoded")
.body(graphql_body.clone());
if let Some(lsd) = &merged.lsd {
graphql_request = graphql_request.header("x-fb-lsd", lsd);
}
let graphql_response = graphql_request.send().await?;
let graphql_status = graphql_response.status();
let graphql_response_headers = graphql_response.headers().clone();
let graphql_text = graphql_response.text().await?;
write_request_dump(
&output_dir,
"04_graphql_batch",
"POST",
"https://www.facebook.com/api/graphqlbatch/",
&graphql_headers,
Some(&graphql_body),
)?;
write_response_dump(
&output_dir,
"04_graphql_batch",
graphql_status.as_u16(),
&graphql_response_headers,
&graphql_text,
)?;
println!(
"graphql_batch: status={} body_len={} has_sync_sequence_id={}",
graphql_status.as_u16(),
graphql_text.len(),
if graphql_text.contains("sync_sequence_id") {
"yes"
} else {
"no"
}
);
println!("dumped artifacts to {}", output_dir.display());
println!(
"merged tokens: user_id={} fb_dtsg={} lsd={} jazoest={} client_revision={}",
merged.user_id.as_deref().unwrap_or("no"),
has_value(merged.fb_dtsg.as_deref()),
has_value(merged.lsd.as_deref()),
has_value(merged.jazoest.as_deref()),
merged
.client_revision
.map(|v| v.to_string())
.unwrap_or_else(|| "no".to_string())
);
Ok(())
}
fn default_output_dir() -> PathBuf {
let unix_ts = match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(duration) => duration.as_secs(),
Err(_) => 0,
};
PathBuf::from(format!("storage/bootstrap-debug-{unix_ts}"))
}
fn load_cookie_header(path: &str) -> Result<String, Box<dyn Error>> {
let content = fs::read_to_string(path)?;
let cookies: Vec<Cookie> = serde_json::from_str(&content)?;
let mut parts = Vec::with_capacity(cookies.len());
for cookie in cookies {
parts.push(format!("{}={}", cookie.name, cookie.value));
}
Ok(parts.join("; "))
}
fn extract_cookie_value(cookie_header: &str, key: &str) -> Option<String> {
for pair in cookie_header.split(';') {
let trimmed = pair.trim();
if let Some((name, value)) = trimmed.split_once('=') {
if name == key {
return Some(value.to_string());
}
}
}
None
}
fn build_headers(cookie_header: &str) -> Result<HeaderMap, Box<dyn Error>> {
let mut headers = HeaderMap::new();
headers.insert(COOKIE, HeaderValue::from_str(cookie_header)?);
headers.insert(USER_AGENT, HeaderValue::from_static(DEFAULT_USER_AGENT));
Ok(headers)
}
fn detect_tokens(html: &str) -> Result<PageTokens, Box<dyn Error>> {
Ok(PageTokens {
fb_dtsg: extract_first(
html,
&[
r#""DTSGInitialData".*?"token":"([^"]+)""#,
r#""DTSGInitData"(?:\s*,\s*\[\])?(?:\s*,\s*)\{[^}]*"token"\s*:\s*"([^"]+)""#,
r#""fb_dtsg":\{"value":"([^"]+)""#,
r#"name="fb_dtsg"\s+value="([^"]+)""#,
],
)?,
lsd: extract_first(
html,
&[
r#""LSD"\s*,\s*\[\s*\]\s*,\s*\{\s*"token"\s*:\s*"([^"]+)""#,
r#""lsd":\{"token":"([^"]+)""#,
r#"name="lsd"\s+value="([^"]+)""#,
],
)?,
jazoest: extract_first(
html,
&[
r#""jazoest":\{"value":"([^"]+)""#,
r#"name="jazoest"\s+value="([^"]+)""#,
],
)?,
client_revision: extract_client_revision(html)?,
user_id: None,
})
}
fn extract_first(html: &str, patterns: &[&str]) -> Result<Option<String>, Box<dyn Error>> {
for pattern in patterns {
let regex = Regex::new(pattern)?;
if let Some(value) = regex
.captures(html)
.and_then(|captures| captures.get(1).map(|capture| capture.as_str().to_string()))
{
return Ok(Some(value));
}
}
Ok(None)
}
fn extract_client_revision(html: &str) -> Result<Option<u64>, Box<dyn Error>> {
let patterns = [
r#""client_revision"\s*:\s*(\d+)"?"#,
r#""__spin_r"\s*:\s*(\d+)"?"#,
];
for pattern in patterns {
let regex = Regex::new(pattern)?;
if let Some(value) = regex
.captures(html)
.and_then(|captures| captures.get(1).map(|capture| capture.as_str().to_string()))
.and_then(|value| value.parse::<u64>().ok())
{
return Ok(Some(value));
}
}
Ok(None)
}
fn compute_jazoest(fb_dtsg: Option<&str>) -> Option<String> {
let token = fb_dtsg?;
let sum: u32 = token.bytes().map(u32::from).sum();
Some(format!("2{sum}"))
}
fn build_graphql_body(tokens: &PageTokens) -> String {
let user_id = tokens.user_id.clone().unwrap_or_else(|| "0".to_string());
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 fields = vec![
("method", "GET".to_string()),
("response_format", "json".to_string()),
(
"batch_name",
"MessengerGraphQLThreadlistFetcher".to_string(),
),
("queries", queries),
("__user", user_id),
("__a", "1".to_string()),
("__req", "1".to_string()),
];
if let Some(rev) = tokens.client_revision {
fields.push(("__rev", rev.to_string()));
}
if let Some(value) = &tokens.fb_dtsg {
fields.push(("fb_dtsg", value.clone()));
}
if let Some(value) = &tokens.jazoest {
fields.push(("jazoest", value.clone()));
}
if let Some(value) = &tokens.lsd {
fields.push(("lsd", value.clone()));
}
encode_form_fields(&fields)
}
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
}
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);
continue;
}
output.push('%');
output.push(to_hex(byte >> 4));
output.push(to_hex(byte & 0x0f));
}
output
}
fn to_hex(nibble: u8) -> char {
match nibble {
0..=9 => (b'0' + nibble) as char,
10..=15 => (b'A' + (nibble - 10)) as char,
_ => '0',
}
}
fn write_request_dump(
output_dir: &Path,
prefix: &str,
method: &str,
url: &str,
headers: &HeaderMap,
body: Option<&str>,
) -> Result<(), Box<dyn Error>> {
let mut content = String::new();
content.push_str(method);
content.push(' ');
content.push_str(url);
content.push('\n');
content.push_str("headers:\n");
for (name, value) in headers {
if let Ok(value_str) = value.to_str() {
content.push_str(name.as_str());
content.push_str(": ");
content.push_str(value_str);
content.push('\n');
}
}
if let Some(body_text) = body {
content.push_str("\nbody:\n");
content.push_str(body_text);
content.push('\n');
}
let file = output_dir.join(format!("{prefix}.request.txt"));
fs::write(file, content)?;
Ok(())
}
fn write_response_dump(
output_dir: &Path,
prefix: &str,
status: u16,
headers: &HeaderMap,
body: &str,
) -> Result<(), Box<dyn Error>> {
let mut meta = String::new();
meta.push_str("status: ");
meta.push_str(&status.to_string());
meta.push('\n');
meta.push_str("headers:\n");
for (name, value) in headers {
if let Ok(value_str) = value.to_str() {
meta.push_str(name.as_str());
meta.push_str(": ");
meta.push_str(value_str);
meta.push('\n');
}
}
let meta_file = output_dir.join(format!("{prefix}.response_meta.txt"));
let body_file = output_dir.join(format!("{prefix}.response_body.html"));
fs::write(meta_file, meta)?;
fs::write(body_file, body)?;
Ok(())
}
fn has_value(value: Option<&str>) -> &'static str {
if value.is_some() { "yes" } else { "no" }
}