use crate::{
api::endpoint::Requester,
client::types::{
Category, CategoryType, CdnUrl, DownloadResponse, NextReviewsPage, NextSearchPage,
ReviewsFilter, ReviewsResults, SearchResults,
},
consts,
error::{
CategoryError, DetailsError, DownloadsError, IntoGoogleError, PropsError, ReviewsError,
SearchError, SearchSuggestionError,
},
proto::{self, Item, Review, SearchSuggestEntry},
};
use async_stream::try_stream;
use futures_core::stream::Stream;
use std::future::Future;
pub mod long;
pub mod short;
pub mod types;
macro_rules! async_doc {
(@internal $($code:literal),+ $(,)?) => {
concat!(
r#"```
# tokio_test::block_on(async {
# #[allow(unused_imports)] use gpipipi::Client;
# const PROPS: &str = include_str!("../../example.properties");
# let mut props_map = gpipipi::PropsMap::parse_aurora_config(PROPS)?;
# let props = props_map.remove("arm").ok_or("No 'arm' device props!")?;
# let aas_token = std::env::var("AAS_TOKEN").map_err(|_| "Set 'AAS_TOKEN' to an aas token, running the binary turns an oauth token into one!")?;
# let email = std::env::var("EMAIL").map_err(|_| "Set 'EMAIL' to the email used for the token!")?;
"#,
$($code),+,
"
# Ok::<(), Box<dyn std::error::Error>>(())
# })?;
# Ok::<(), Box<dyn std::error::Error>>(())
```"
)
};
($code:literal) => {
async_doc!(@internal $code)
};
($auth:literal, $code:literal) => {
async_doc!(@internal r#"
let auth_token_request = gpipipi::AuthTokenRequest::new(&aas_token, &email, &props);
let auth_token = auth_token_request.fetch().await?.get().ok_or("Auth token expired??")?.to_string();"#,
$code
)
};
}
pub(crate) use async_doc;
pub(crate) trait ClientRequester: Sync {
fn requester(&self) -> impl Future<Output = Result<Requester<'_>, PropsError>> + Send;
}
#[async_trait::async_trait]
#[allow(private_bounds)]
pub trait Client: ClientRequester {
#[doc = async_doc!(r#"let client = gpipipi::LongLivedClient::login(email, aas_token, props).await?;
let details = client.details("com.roblox.client", None).await?;"#)]
async fn details<A: AsRef<str> + Send>(
&self,
package_name: A,
maybe_version: Option<i64>,
) -> Result<proto::AppDetails, DetailsError> {
let maybe_version = maybe_version.map(|a| a.to_string());
let params = maybe_version.as_ref().map_or_else(
|| [("doc", package_name.as_ref()), ("", "")],
|version| [("doc", package_name.as_ref()), ("vc", version)],
);
let request = self.requester().await?.params(¶ms);
let mut response = request.fdfe_request("details", None).await?;
let response = response
.payload
.take()
.and_then(|a| a.details_response)
.and_then(|a| a.item)
.and_then(|a| a.details)
.and_then(|a| a.app_details)
.into_google_error(&response)?;
Ok(response)
}
#[doc = async_doc!(r#"let client = gpipipi::LongLivedClient::login(email, aas_token, props).await?;
let download_urls = client.downloads("com.roblox.client", Some(2036)).await?;"#)]
async fn downloads<A: AsRef<str> + Send>(
&self,
package_name: A,
version: Option<i64>,
) -> Result<DownloadResponse, DownloadsError> {
let version = if let Some(version) = version {
version
} else {
latest_version(self, package_name.as_ref()).await?
}
.to_string();
let headers = [("content-length", "0")];
let params = [
("ot", "1"),
("doc", package_name.as_ref()),
("vc", &version),
];
let mut request = self.requester().await?.headers(&headers).params(¶ms);
let mut response = request.fdfe_request("purchase", Some(Vec::new())).await?;
let delivery_token = response
.payload
.take()
.and_then(|a| a.buy_response)
.and_then(|a| a.encoded_delivery_token)
.into_google_error(&response)?;
let delivery_params = [
("doc", package_name.as_ref()),
("dtok", &delivery_token),
("vc", &version),
("ot", "1"),
];
request = request.headers(&[]).params(&delivery_params);
let mut delivery_response = request.fdfe_request("delivery", None).await?;
let app_delivery_data = delivery_response
.payload
.take()
.and_then(|a| a.delivery_response)
.and_then(|a| a.app_delivery_data)
.into_google_error(&response)?;
let base = app_delivery_data
.download_url
.zip(app_delivery_data.sha1)
.map(|(url, sha1)| CdnUrl {
filename: "base".to_string(),
url,
sha1,
})
.into_google_error(&response)?;
let splits: Vec<_> = app_delivery_data
.split_delivery_data
.into_iter()
.filter_map(|a| {
a.name
.zip(a.download_url)
.zip(a.sha1)
.map(|((filename, url), sha1)| CdnUrl {
filename,
sha1,
url,
})
})
.collect();
let obbs: Vec<_> = app_delivery_data
.additional_file
.into_iter()
.filter_map(|a| {
a.file_type
.zip(a.sha1)
.zip(a.version_code)
.zip(a.download_url)
.map(|(((ftype, sha1), version), url)| {
let prefix = if ftype == 0 { "main" } else { "patch" };
CdnUrl {
filename: format!("{prefix}.{version}.{}.obb", package_name.as_ref()),
url,
sha1,
}
})
})
.collect();
Ok(DownloadResponse { splits, obbs, base })
}
#[doc = async_doc!("let client = gpipipi::LongLivedClient::login(email, aas_token, props).await?;
let categories = client.categories(gpipipi::CategoryType::Game).await?;")]
async fn categories(&self, cat_type: CategoryType) -> Result<Vec<Category>, CategoryError> {
let type_name = format!("{cat_type:?}").to_uppercase();
let headers = [("User-Agent", consts::LEGACY_USERAGENT)];
let params = [("c", "3"), ("cat", &type_name)];
let request = self.requester().await?.headers(&headers).params(¶ms);
let mut response = request.fdfe_request("categoriesList", None).await?;
let items = response
.payload
.take()
.and_then(|a| a.list_response)
.and_then(|a| a.item)
.and_then(|mut a| a.sub_item.pop())
.into_google_error(&response)?;
Ok(items
.sub_item
.into_iter()
.filter_map(|a| {
a.title
.zip(a.annotations.and_then(|a| {
a.annotation_link
.and_then(|a| a.resolved_link)
.and_then(|a| a.browse_url)
}))
.map(|(title, browse_url)| Category {
images: a.image.into_iter().filter_map(|a| a.image_url).collect(),
cat_type,
browse_url,
title,
})
})
.collect())
}
#[doc = async_doc!(r#"let client = gpipipi::LongLivedClient::login(email, aas_token, props).await?;
let suggestions = client.search_suggest("robl").await?;"#)]
async fn search_suggest<A: AsRef<str> + Send>(
&self,
query: A,
) -> Result<Vec<SearchSuggestEntry>, SearchSuggestionError> {
let headers = [("User-Agent", consts::LEGACY_USERAGENT)];
let params = [
("q", query.as_ref()),
("ssis", "120"),
("sb", "5"),
("sst", "2"),
("c", "3"),
];
let request = self.requester().await?.headers(&headers).params(¶ms);
let mut response = request.fdfe_request("searchSuggest", None).await?;
let response = response
.payload
.take()
.and_then(|a| a.search_suggest_response)
.map(|a| a.entry)
.into_google_error(&response)?;
Ok(response)
}
#[doc = async_doc!(r#"let client = gpipipi::LongLivedClient::login(email, aas_token, props).await?;
let single_search = client.search("robl", None).await?;
let single_search2 = client.search("robl", single_search.next_page).await?;"#)]
async fn search<A: AsRef<str> + Send>(
&self,
query: A,
next_page_url: Option<NextSearchPage>,
) -> Result<SearchResults, SearchError> {
let (payload, next_page, response) = if let Some(next_page_url) = next_page_url {
let request = self.requester().await?;
let mut response = request.fdfe_request(&next_page_url.0, None).await?;
(response.payload.take(), None, response)
} else {
let params = [("q", query.as_ref()), ("ksm", "1"), ("c", "3")];
let request = self.requester().await?.params(¶ms);
let mut response = request.fdfe_request("search", None).await?;
(
response
.pre_fetch
.take()
.and_then(|a| a.response)
.and_then(|a| a.payload),
Some(
response
.payload
.take()
.and_then(|a| a.search_response)
.and_then(|a| a.next_page_url)
.map(NextSearchPage),
),
response,
)
};
let next_page = next_page.unwrap_or_else(|| {
payload
.as_ref()
.and_then(|a| a.list_response.as_ref())
.and_then(|a| a.item.as_ref())
.and_then(|a| a.container_metadata.as_ref())
.and_then(|a| a.next_page_url.clone())
.map(NextSearchPage)
});
let items = payload
.and_then(|a| a.list_response)
.and_then(|a| a.item)
.map(|a| {
a.sub_item
.into_iter()
.filter_map(|mut a| a.sub_item.pop())
.collect::<Vec<_>>()
})
.into_google_error(&response)?;
Ok(SearchResults { next_page, items })
}
#[doc = async_doc!(r#"use futures_util::TryStreamExt;
let client = gpipipi::LongLivedClient::login(email, aas_token, props).await?;
let mut search_stream = client.search_stream("robl");
# let mut count = 0;
while let Some(page) = search_stream.try_next().await? {
# count += 1;
# if count >= 3 {
# break
# }
for item in page {
println!("Found app: {:?}", item.title);
}
}"#)]
fn search_stream<'a, A: AsRef<str> + Send + 'a>(
&'a self,
query: A,
) -> impl Stream<Item = Result<Vec<Item>, SearchError>> + Send + 'a {
Box::pin(try_stream! {
let mut next_page: Option<NextSearchPage> = None;
loop {
let result = self.search(query.as_ref(), next_page).await?;
yield result.items;
next_page = result.next_page;
if next_page.is_none() {
break;
}
}
})
}
#[doc = async_doc!(r#"let client = gpipipi::LongLivedClient::login(email, aas_token, props).await?;
let reviews = client
.reviews("com.roblox.client", Some(&gpipipi::ReviewsFilter::Newest), None)
.await?;
let reviews_page2 = client
.reviews("com.roblox.client", Some(&gpipipi::ReviewsFilter::Newest), reviews.next_page)
.await?;"#)]
fn reviews<A: AsRef<str> + Send>(
&self,
package_name: A,
filter: Option<&ReviewsFilter>,
next_page_url: Option<NextReviewsPage>,
) -> impl Future<Output = Result<ReviewsResults, ReviewsError>> + Send {
async move {
let (reviews_response, next_page, response) = if let Some(next_page_url) = next_page_url
{
let request = self.requester().await?;
let mut response = request.fdfe_request(&next_page_url.0, None).await?;
let review_response = response.payload.take().and_then(|a| a.review_response);
let next_page = review_response
.as_ref()
.and_then(|a| a.next_page_url.clone())
.map(NextReviewsPage);
(review_response, next_page, response)
} else {
let params = [
("doc", package_name.as_ref()),
filter.map_or(("", ""), |filter| match filter {
ReviewsFilter::All => ("sfilter", "ALL"),
ReviewsFilter::Newest => ("sort", "0"),
ReviewsFilter::Positive => ("sent", "1"),
ReviewsFilter::Critical => ("sent", "2"),
}),
];
let request = self.requester().await?.params(¶ms);
let mut response = request.fdfe_request("rev", None).await?;
let review_response = response.payload.take().and_then(|a| a.review_response);
let next_page = review_response
.as_ref()
.and_then(|a| a.next_page_url.clone())
.map(NextReviewsPage);
(review_response, next_page, response)
};
let reviews = reviews_response
.and_then(|a| a.user_reviews_response)
.map(|a| a.review)
.into_google_error(&response)?;
Ok(ReviewsResults { next_page, reviews })
}
}
#[doc = async_doc!(r#"use futures_util::TryStreamExt;
let client = gpipipi::LongLivedClient::login(email, aas_token, props).await?;
let mut reviews_stream = client
.reviews_stream("com.roblox.client", Some(gpipipi::ReviewsFilter::Newest));
# let mut count = 0;
while let Some(page) = reviews_stream.try_next().await? {
# count += 1;
# if count >= 3 {
# break
# }
for review in page {
println!("{:?} stars: {:?}", review.star_rating, review.comment);
}
}"#)]
fn reviews_stream<'a, A: AsRef<str> + Send + 'a>(
&'a self,
package_name: A,
filter: Option<ReviewsFilter>,
) -> impl Stream<Item = Result<Vec<Review>, ReviewsError>> + Send + 'a {
Box::pin(try_stream! {
let mut next_page: Option<NextReviewsPage> = None;
loop {
let result = self.reviews(package_name.as_ref(), filter.as_ref(), next_page).await?;
yield result.reviews;
next_page = result.next_page;
if next_page.is_none() {
break;
}
}
})
}
}
async fn latest_version<A: Client + ?Sized>(
client: &A,
package_name: &str,
) -> Result<i64, DetailsError> {
let response = client
.details(package_name, None)
.await?
.version_code
.into_google_error_map(|| "no version was returned by google".to_string())?;
Ok(response)
}