use crate::config::GoldenPayConfig;
use crate::error::GoldenPayError;
use crate::models::{
CategoryFilter, CategorySubcategory, ChatMessage, MarketOffer, Offer, OfferDetails, OfferEdit,
OfferSaveResponse, OrderInfo, OrderPage, PriceCalculation, RunnerResponse, UserInfo,
};
use crate::offer::OfferEditBuilder;
use crate::parser::{
parse_category_filters, parse_category_subcategories, parse_chat_messages, parse_market_offers,
parse_my_offers, parse_offer_details, parse_order_page, parse_orders, parse_price_calculation,
parse_runner_objects, parse_user,
};
use crate::urls::Urls;
use crate::utils::{random_tag, retry_sleep};
use reqwest::header::{ACCEPT, CONTENT_TYPE, COOKIE, ORIGIN, REFERER, SET_COOKIE, USER_AGENT};
use reqwest::{Client, Response};
use serde_json::{Value, json};
#[derive(Clone)]
pub struct GoldenPay {
http: Client,
config: GoldenPayConfig,
urls: Urls,
}
#[derive(Clone)]
pub struct GoldenPaySession {
http: Client,
config: GoldenPayConfig,
urls: Urls,
user: UserInfo,
}
impl GoldenPay {
pub fn new(config: GoldenPayConfig) -> Result<Self, GoldenPayError> {
if config.golden_key.trim().is_empty() {
return Err(GoldenPayError::MissingGoldenKey);
}
let mut builder = Client::builder().cookie_store(false);
if let Some(proxy) = &config.proxy {
builder = builder.proxy(reqwest::Proxy::all(proxy)?);
}
Ok(Self {
http: builder.build()?,
urls: Urls::new(config.base_url.clone()),
config,
})
}
pub fn config(&self) -> &GoldenPayConfig {
&self.config
}
pub async fn connect(&self) -> Result<GoldenPaySession, GoldenPayError> {
let response = self
.request_with_retry(|| {
self.http
.get(self.urls.home())
.header(USER_AGENT, &self.config.user_agent)
.header(
COOKIE,
format!("golden_key={}; cookie_prefs=1", self.config.golden_key),
)
})
.await?;
let set_cookies = collect_set_cookies(&response);
let body = response.text().await?;
let user = parse_user(&body, &set_cookies)?;
Ok(GoldenPaySession {
http: self.http.clone(),
config: self.config.clone(),
urls: self.urls.clone(),
user,
})
}
async fn request_with_retry<F>(&self, build: F) -> Result<Response, GoldenPayError>
where
F: Fn() -> reqwest::RequestBuilder,
{
request_with_retry(&self.config, build).await
}
}
impl GoldenPaySession {
pub fn user(&self) -> &UserInfo {
&self.user
}
pub fn poll_interval(&self) -> std::time::Duration {
self.config.poll_interval
}
pub fn config(&self) -> &GoldenPayConfig {
&self.config
}
pub async fn send_message(
&self,
chat_id: &str,
text: &str,
) -> Result<RunnerResponse, GoldenPayError> {
let objects_json = serde_json::to_string(&vec![json!({
"type": "chat_node",
"id": chat_id,
"tag": random_tag(),
"data": { "node": chat_id, "last_message": -1, "content": "" }
})])?;
let request_json = json!({
"action": "chat_message",
"data": { "node": chat_id, "last_message": -1, "content": text }
})
.to_string();
let payload = format!(
"objects={}&request={}&csrf_token={}",
urlencoding::encode(&objects_json),
urlencoding::encode(&request_json),
urlencoding::encode(&self.user.csrf_token)
);
self.request_runner(payload).await
}
pub async fn fetch_orders(&self) -> Result<Vec<OrderInfo>, GoldenPayError> {
let response = self.get_html(self.urls.orders_trade()).await?;
let body = response.text().await?;
parse_orders(&body, self.user.id)
}
pub async fn fetch_paid_orders(&self) -> Result<Vec<OrderInfo>, GoldenPayError> {
Ok(self
.fetch_orders()
.await?
.into_iter()
.filter(|order| order.status == crate::models::OrderStatus::Paid)
.collect())
}
pub async fn fetch_order_page(&self, order_id: &str) -> Result<OrderPage, GoldenPayError> {
let response = self.get_html(self.urls.order_page(order_id)).await?;
let body = response.text().await?;
parse_order_page(&body, order_id)
}
pub async fn fetch_chat_messages(
&self,
chat_id: &str,
) -> Result<Vec<ChatMessage>, GoldenPayError> {
let objects_json = serde_json::to_string(&vec![json!({
"type": "chat_node",
"id": chat_id,
"tag": random_tag(),
"data": { "node": chat_id, "last_message": -1, "content": "" }
})])?;
let payload = format!(
"objects={}&request=false&csrf_token={}",
urlencoding::encode(&objects_json),
urlencoding::encode(&self.user.csrf_token)
);
let response = self.request_runner(payload).await?;
Ok(parse_chat_messages(chat_id, &response.raw))
}
pub async fn fetch_my_offers(&self, node_id: i64) -> Result<Vec<Offer>, GoldenPayError> {
let response = self.get_html(self.urls.lots_trade(node_id)).await?;
Ok(parse_my_offers(&response.text().await?, node_id))
}
pub async fn fetch_market_offers(
&self,
node_id: i64,
) -> Result<Vec<MarketOffer>, GoldenPayError> {
let response = self.get_html(self.urls.lots_page(node_id)).await?;
Ok(parse_market_offers(&response.text().await?, node_id))
}
pub async fn fetch_offer_details(
&self,
node_id: i64,
offer_id: i64,
) -> Result<OfferDetails, GoldenPayError> {
let response = self.get_html(self.urls.offer_edit(node_id, offer_id)).await?;
Ok(parse_offer_details(
&response.text().await?,
offer_id,
node_id,
))
}
pub async fn edit_offer(
&self,
node_id: i64,
offer_id: i64,
patch: OfferEdit,
) -> Result<OfferSaveResponse, GoldenPayError> {
let current = self.fetch_offer_details(node_id, offer_id).await?.current;
let merged = current.merge(patch);
let payload = build_offer_payload(&self.user.csrf_token, offer_id, node_id, &merged);
let response = self
.post_form(
self.urls.offer_save(),
payload,
Some(self.urls.offer_edit(node_id, offer_id)),
"application/json, text/javascript, */*; q=0.01",
)
.await?;
Ok(parse_offer_save_response(response.json().await?))
}
pub async fn edit_offer_with(
&self,
node_id: i64,
offer_id: i64,
builder: OfferEditBuilder,
) -> Result<OfferSaveResponse, GoldenPayError> {
self.edit_offer(node_id, offer_id, builder.build()).await
}
pub async fn calc_price(
&self,
node_id: i64,
price: f64,
) -> Result<PriceCalculation, GoldenPayError> {
let input_price = price;
let price = if price.fract() == 0.0 {
format!("{price:.0}")
} else {
let formatted = format!("{price:.2}");
formatted.trim_end_matches('0').trim_end_matches('.').to_string()
};
let payload = format!("nodeId={node_id}&price={price}");
let response = self
.post_form(
self.urls.lots_calc(),
payload,
None::<String>,
"application/json, text/javascript, */*; q=0.01",
)
.await?;
Ok(parse_price_calculation(response.json().await?, input_price))
}
pub async fn fetch_category_subcategories(
&self,
node_id: i64,
) -> Result<Vec<CategorySubcategory>, GoldenPayError> {
let response = self.get_html(self.urls.lots_page(node_id)).await?;
Ok(parse_category_subcategories(&response.text().await?))
}
pub async fn fetch_category_filters(
&self,
node_id: i64,
) -> Result<Vec<CategoryFilter>, GoldenPayError> {
let response = self.get_html(self.urls.lots_page(node_id)).await?;
Ok(parse_category_filters(&response.text().await?))
}
pub async fn fetch_category_metadata(
&self,
node_id: i64,
) -> Result<(Vec<CategorySubcategory>, Vec<CategoryFilter>), GoldenPayError> {
let response = self.get_html(self.urls.lots_page(node_id)).await?;
let body = response.text().await?;
Ok((
parse_category_subcategories(&body),
parse_category_filters(&body),
))
}
async fn request_runner(&self, payload: String) -> Result<RunnerResponse, GoldenPayError> {
let response = self
.post_form(
self.urls.runner(),
payload,
Some(format!("{}/chat/", self.urls.base())),
"*/*",
)
.await?;
Ok(parse_runner_response(response.json().await?))
}
async fn request_with_retry<F>(&self, build: F) -> Result<Response, GoldenPayError>
where
F: Fn() -> reqwest::RequestBuilder,
{
request_with_retry(&self.config, build).await
}
fn cookie_header(&self) -> String {
match &self.user.phpsessid {
Some(session) => format!(
"golden_key={}; cookie_prefs=1; PHPSESSID={session}",
self.config.golden_key
),
None => format!("golden_key={}; cookie_prefs=1", self.config.golden_key),
}
}
fn html_get(&self, url: impl Into<String>) -> reqwest::RequestBuilder {
self.http
.get(url.into())
.header(USER_AGENT, &self.config.user_agent)
.header(COOKIE, self.cookie_header())
.header(ACCEPT, "*/*")
}
async fn get_html(&self, url: impl Into<String>) -> Result<Response, GoldenPayError> {
let url = url.into();
self.request_with_retry(|| self.html_get(url.clone())).await
}
async fn post_form<R>(
&self,
url: impl Into<String>,
payload: String,
referer: Option<R>,
accept: &str,
) -> Result<Response, GoldenPayError>
where
R: Into<String>,
{
let url = url.into();
let referer = referer.map(Into::into);
self.request_with_retry(|| {
let mut request = self
.http
.post(url.clone())
.header(USER_AGENT, &self.config.user_agent)
.header(COOKIE, self.cookie_header())
.header(
CONTENT_TYPE,
"application/x-www-form-urlencoded; charset=UTF-8",
)
.header(ACCEPT, accept)
.header(ORIGIN, self.urls.base())
.header("x-requested-with", "XMLHttpRequest")
.body(payload.clone());
if let Some(referer) = &referer {
request = request.header(REFERER, referer);
}
request
})
.await
}
}
async fn request_with_retry<F>(
config: &GoldenPayConfig,
build: F,
) -> Result<Response, GoldenPayError>
where
F: Fn() -> reqwest::RequestBuilder,
{
for attempt in 1..=config.retry.max_attempts {
match ensure_success(build().send().await).await {
Ok(response) => return Ok(response),
Err(error) => {
let retryable = matches!(
error,
GoldenPayError::Http { .. }
| GoldenPayError::RequestFailed {
status: 429 | 500 | 502 | 503 | 504,
..
}
);
if !retryable || attempt == config.retry.max_attempts {
return Err(error);
}
retry_sleep(attempt, config.retry.base_delay).await;
}
}
}
Err(GoldenPayError::parse(
"request_with_retry",
"retry loop exited unexpectedly",
))
}
async fn ensure_success(
response: Result<Response, reqwest::Error>,
) -> Result<Response, GoldenPayError> {
let response = response?;
let url = response.url().to_string();
if response.status() == reqwest::StatusCode::FORBIDDEN {
return Err(GoldenPayError::Unauthorized);
}
if response.status().is_success() {
return Ok(response);
}
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
Err(GoldenPayError::RequestFailed {
method: "HTTP",
url,
status,
body,
})
}
fn collect_set_cookies(response: &Response) -> Vec<String> {
response
.headers()
.get_all(SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok().map(ToString::to_string))
.collect()
}
fn build_offer_payload(csrf_token: &str, offer_id: i64, node_id: i64, edit: &OfferEdit) -> String {
let mut parts = vec![
format!("csrf_token={}", urlencoding::encode(csrf_token)),
format!("offer_id={offer_id}"),
format!("node_id={node_id}"),
field("location", edit.location.as_deref()),
field("fields[quantity]", edit.quantity.as_deref()),
field("fields[quantity2]", edit.quantity2.as_deref()),
field("fields[method]", edit.method.as_deref()),
field("fields[type]", edit.offer_type.as_deref()),
field("server_id", edit.server_id.as_deref()),
field("fields[desc][ru]", edit.desc_ru.as_deref()),
field("fields[desc][en]", edit.desc_en.as_deref()),
field("fields[payment_msg][ru]", edit.payment_msg_ru.as_deref()),
field("fields[payment_msg][en]", edit.payment_msg_en.as_deref()),
field("fields[summary][ru]", edit.summary_ru.as_deref()),
field("fields[summary][en]", edit.summary_en.as_deref()),
field("fields[game]", edit.game.as_deref()),
field("fields[images]", edit.images.as_deref()),
field("price", edit.price.as_deref()),
];
parts.push(if edit.deactivate_after_sale.unwrap_or(false) {
field("deactivate_after_sale[]", Some("on"))
} else {
field("deactivate_after_sale", None)
});
parts.push(if edit.active.unwrap_or(true) {
field("active", Some("on"))
} else {
field("active", None)
});
parts.push(if edit.deleted.unwrap_or(false) {
"deleted=1".to_string()
} else {
"deleted=".to_string()
});
parts.join("&")
}
fn field(key: &str, value: Option<&str>) -> String {
format!(
"{}={}",
urlencoding::encode(key),
urlencoding::encode(value.unwrap_or_default())
)
}
fn parse_runner_response(raw: Value) -> RunnerResponse {
let error_message = parse_error_message(&raw);
let success = error_message.is_none();
let objects = parse_runner_objects(&raw);
RunnerResponse {
success,
error_message,
objects,
raw,
}
}
fn parse_offer_save_response(raw: Value) -> OfferSaveResponse {
let error_message = parse_error_message(&raw);
let success = error_message.is_none();
OfferSaveResponse {
success,
error_message,
raw,
}
}
fn parse_error_message(raw: &Value) -> Option<String> {
let error = raw.get("error")?;
if error.is_null() {
return None;
}
if let Some(message) = error.as_str() {
let trimmed = message.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
Some(error.to_string())
}