funpay_client/client/
account.rs

1use crate::client::http::ReqwestGateway;
2use crate::client::poller::FunPayPoller;
3use crate::client::FunpayGateway;
4use crate::config::FunPayConfig;
5use crate::error::FunPayError;
6use crate::events::Event;
7use crate::models::enums::SubcategoryType;
8use crate::models::ids::ChatId;
9use crate::models::{
10    CategoryFilter, CategorySubcategory, MarketOffer, Message, Offer, OfferEditParams,
11    OfferFullParams, Order, OrderShortcut, Subcategory,
12};
13use crate::parsing::{
14    parse_category_filters, parse_category_subcategories, parse_market_offers, parse_message_html,
15    parse_my_offers, parse_offer_edit_params, parse_offer_full_params, parse_order_page,
16    parse_order_secrets, parse_orders_list,
17};
18use crate::storage::json::JsonFileStorage;
19use crate::storage::memory::InMemoryStorage;
20use crate::storage::StateStorage;
21use crate::utils::{extract_phpsessid, random_tag};
22use regex::Regex;
23use scraper::{Html, Selector};
24use serde_json::{json, to_string, Value};
25use std::collections::HashMap;
26use std::fmt;
27use std::sync::Arc;
28use std::time::Duration;
29use tokio::sync::broadcast::{self, Sender};
30
31#[derive(Debug, Clone)]
32struct AppData {
33    user_id: i64,
34    csrf_token: String,
35}
36
37pub struct FunPayAccount {
38    gateway: Arc<dyn FunpayGateway>,
39    pub golden_key: String,
40    user_agent: String,
41    pub id: Option<i64>,
42    pub username: Option<String>,
43    pub csrf_token: Option<String>,
44    phpsessid: Option<String>,
45    locale: Option<String>,
46    pub events_tx: Sender<Event>,
47    sorted_subcategories: HashMap<SubcategoryType, HashMap<i64, Subcategory>>,
48    storage: Arc<dyn StateStorage>,
49    polling_interval: Duration,
50    error_retry_delay: Duration,
51}
52
53impl fmt::Debug for FunPayAccount {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        f.debug_struct("FunPayAccount")
56            .field("golden_key", &"[redacted]")
57            .field("user_agent", &self.user_agent)
58            .field("id", &self.id)
59            .field("username", &self.username)
60            .finish()
61    }
62}
63
64#[derive(Clone)]
65pub struct FunPaySender {
66    gateway: Arc<dyn FunpayGateway>,
67    golden_key: String,
68    user_agent: String,
69    csrf_token: String,
70    phpsessid: Option<String>,
71    seller_id: i64,
72}
73
74impl FunPaySender {
75    pub fn seller_id(&self) -> i64 {
76        self.seller_id
77    }
78}
79
80impl FunPayAccount {
81    pub fn new(golden_key: String) -> Self {
82        Self::with_config(golden_key, FunPayConfig::default())
83    }
84
85    pub fn with_config(golden_key: String, config: FunPayConfig) -> Self {
86        let gateway: Arc<dyn FunpayGateway> = Arc::new(ReqwestGateway::with_config(&config));
87        Self::with_gateway_and_config(gateway, golden_key, config)
88    }
89
90    pub fn with_proxy(golden_key: String, proxy_url: &str) -> Self {
91        Self::with_proxy_and_config(golden_key, proxy_url, FunPayConfig::default())
92    }
93
94    pub fn with_proxy_and_config(golden_key: String, proxy_url: &str, config: FunPayConfig) -> Self {
95        let gateway: Arc<dyn FunpayGateway> =
96            Arc::new(ReqwestGateway::with_proxy_and_config(proxy_url, &config));
97        Self::with_gateway_and_config(gateway, golden_key, config)
98    }
99
100    pub fn with_gateway(gateway: Arc<dyn FunpayGateway>, golden_key: String) -> Self {
101        Self::with_gateway_and_config(gateway, golden_key, FunPayConfig::default())
102    }
103
104    pub fn with_gateway_and_config(
105        gateway: Arc<dyn FunpayGateway>,
106        golden_key: String,
107        config: FunPayConfig,
108    ) -> Self {
109        let (tx, _rx) = broadcast::channel(config.event_channel_capacity);
110        let storage: Arc<dyn StateStorage> = match config.state_storage_path {
111            Some(ref path) => Arc::new(JsonFileStorage::new(path.clone())),
112            None => Arc::new(InMemoryStorage::new()),
113        };
114        Self {
115            gateway,
116            golden_key,
117            user_agent: config.user_agent.clone(),
118            id: None,
119            username: None,
120            csrf_token: None,
121            phpsessid: None,
122            locale: None,
123            events_tx: tx,
124            sorted_subcategories: HashMap::new(),
125            storage,
126            polling_interval: config.polling_interval,
127            error_retry_delay: config.error_retry_delay,
128        }
129    }
130
131    pub fn subscribe(&self) -> broadcast::Receiver<Event> {
132        self.events_tx.subscribe()
133    }
134
135    pub async fn init(&mut self) -> Result<(), FunPayError> {
136        self.get().await
137    }
138
139    pub fn create_sender(&self) -> Result<FunPaySender, FunPayError> {
140        let csrf = self
141            .csrf_token
142            .as_ref()
143            .ok_or(FunPayError::AccountNotInitiated)?
144            .to_string();
145        let seller_id = self.id.ok_or(FunPayError::AccountNotInitiated)?;
146        Ok(FunPaySender {
147            gateway: self.gateway.clone(),
148            golden_key: self.golden_key.clone(),
149            user_agent: self.user_agent.clone(),
150            csrf_token: csrf,
151            phpsessid: self.phpsessid.clone(),
152            seller_id,
153        })
154    }
155
156    async fn get(&mut self) -> Result<(), FunPayError> {
157        let (body, set_cookies) = self
158            .gateway
159            .get_home(&self.golden_key, &self.user_agent)
160            .await?;
161        if let Some(sess) = extract_phpsessid(&set_cookies) {
162            self.phpsessid = Some(sess);
163        }
164        let html = Html::parse_document(&body);
165        let sel_body = Selector::parse("body").unwrap();
166        let mut app_data: Option<AppData> = None;
167        if let Some(b) = html.select(&sel_body).next() {
168            if let Some(attr) = b.value().attr("data-app-data") {
169                let v: Value =
170                    serde_json::from_str(attr).map_err(|e| FunPayError::Parse(e.to_string()))?;
171                let user_id = v
172                    .get("userId")
173                    .and_then(|x| x.as_i64())
174                    .ok_or_else(|| FunPayError::Parse(String::from("missing userId")))?;
175                let csrf = v
176                    .get("csrf-token")
177                    .and_then(|x| x.as_str())
178                    .ok_or_else(|| FunPayError::Parse(String::from("missing csrf-token")))?;
179                if let Some(loc) = v.get("locale").and_then(|x| x.as_str()) {
180                    self.locale = Some(loc.to_string());
181                }
182                app_data = Some(AppData {
183                    user_id,
184                    csrf_token: csrf.to_string(),
185                });
186            }
187        }
188        let sel_uname = Selector::parse("div.user-link-name").unwrap();
189        let username = html
190            .select(&sel_uname)
191            .next()
192            .map(|n| n.text().collect::<String>());
193        if username.is_none() {
194            return Err(FunPayError::Unauthorized);
195        }
196        let app = app_data.ok_or_else(|| FunPayError::Parse(String::from("missing app data")))?;
197        self.id = Some(app.user_id);
198        self.csrf_token = Some(app.csrf_token);
199        self.username = username;
200        self.setup_subcategories(&body);
201        Ok(())
202    }
203
204    fn setup_subcategories(&mut self, html: &str) {
205        let doc = Html::parse_document(html);
206        let sel_lists = Selector::parse("div.promo-game-list").unwrap();
207        let mut lists: Vec<_> = doc.select(&sel_lists).collect();
208        if lists.is_empty() {
209            return;
210        }
211        let container = if lists.len() > 1 {
212            lists.remove(1)
213        } else {
214            lists.remove(0)
215        };
216        let sel_item = Selector::parse("div.promo-game-item").unwrap();
217        let sel_ul = Selector::parse("ul.list-inline").unwrap();
218        let sel_a = Selector::parse("a").unwrap();
219        let re_id = Regex::new(r"/(?:chips|lots)/(\d+)/?").unwrap();
220        for game in container.select(&sel_item) {
221            for ul in game.select(&sel_ul) {
222                for li in ul.children() {
223                    if let Some(el) = li.value().as_element() {
224                        if el.name() != "li" {
225                            continue;
226                        }
227                    } else {
228                        continue;
229                    }
230                    if let Some(a) = li
231                        .first_child()
232                        .and_then(|n| n.value().as_element())
233                        .and_then(|_| ul.select(&sel_a).next())
234                    {
235                        let name = a.text().collect::<String>().trim().to_string();
236                        if name.is_empty() {
237                            continue;
238                        }
239                        let href = a.value().attr("href").unwrap_or("");
240                        let typ = if href.contains("chips/") {
241                            SubcategoryType::Currency
242                        } else {
243                            SubcategoryType::Common
244                        };
245                        let id = re_id
246                            .captures(href)
247                            .and_then(|c| c.get(1))
248                            .and_then(|m| m.as_str().parse::<i64>().ok());
249                        if let Some(sid) = id {
250                            let sub = Subcategory {
251                                id: Some(sid),
252                                name: name.clone(),
253                            };
254                            let entry = self.sorted_subcategories.entry(typ).or_default();
255                            entry.insert(sid, sub);
256                        }
257                    }
258                }
259            }
260        }
261    }
262
263    pub async fn start_polling_loop(&mut self) -> Result<(), FunPayError> {
264        let poller = FunPayPoller {
265            gateway: self.gateway.clone(),
266            golden_key: self.golden_key.clone(),
267            user_agent: self.user_agent.clone(),
268            id: self.id.ok_or(FunPayError::AccountNotInitiated)?,
269            username: self.username.clone(),
270            csrf_token: self
271                .csrf_token
272                .clone()
273                .ok_or(FunPayError::AccountNotInitiated)?,
274            phpsessid: self.phpsessid.clone(),
275            events_tx: self.events_tx.clone(),
276            storage: self.storage.clone(),
277            polling_interval: self.polling_interval,
278            error_retry_delay: self.error_retry_delay,
279            last_msg_event_tag: random_tag(),
280            last_order_event_tag: random_tag(),
281            last_messages: HashMap::new(),
282            last_messages_ids: HashMap::new(),
283            saved_orders: HashMap::new(),
284        };
285        poller.start().await
286    }
287}
288
289impl FunPaySender {
290    pub async fn send_chat_message(&self, chat_id: &str, content: &str) -> Result<(), FunPayError> {
291        let mut csrf_to_use = self.csrf_token.clone();
292        let mut phpsess_to_use = self.phpsessid.clone();
293        if phpsess_to_use.is_none() {
294            let (body, set_cookies) = self
295                .gateway
296                .get_chat_page(&self.golden_key, &self.user_agent, chat_id)
297                .await?;
298            let html = Html::parse_document(&body);
299            let sel_body = Selector::parse("body").unwrap();
300            if let Some(b) = html.select(&sel_body).next() {
301                if let Some(attr) = b.value().attr("data-app-data") {
302                    if let Ok(v) = serde_json::from_str::<Value>(attr) {
303                        if let Some(csrf) = v.get("csrf-token").and_then(|x| x.as_str()) {
304                            csrf_to_use = csrf.to_string();
305                        }
306                    }
307                }
308            }
309            if let Some(sess) = extract_phpsessid(&set_cookies) {
310                phpsess_to_use = Some(sess);
311            }
312        }
313        let objects_json = to_string(&vec![serde_json::json!({
314            "type": "chat_node",
315            "id": chat_id,
316            "tag": "00000000",
317            "data": {"node": chat_id, "last_message": -1, "content": ""}
318        })])
319        .unwrap();
320        let request_json = json!({
321            "action": "chat_message",
322            "data": {"node": chat_id, "last_message": -1, "content": content}
323        })
324        .to_string();
325        self.gateway
326            .post_runner(
327                &self.golden_key,
328                &self.user_agent,
329                &csrf_to_use,
330                phpsess_to_use.as_deref(),
331                &objects_json,
332                Some(&request_json),
333            )
334            .await
335            .map(|_| ())
336    }
337
338    pub async fn get_chat_messages(&self, chat_id: &str) -> Result<Vec<Message>, FunPayError> {
339        let objects_json = to_string(&vec![serde_json::json!({
340            "type": "chat_node",
341            "id": chat_id,
342            "tag": "00000000",
343            "data": {"node": chat_id, "last_message": -1, "content": ""}
344        })])
345        .unwrap();
346
347        let res = self
348            .gateway
349            .post_runner(
350                &self.golden_key,
351                &self.user_agent,
352                &self.csrf_token,
353                self.phpsessid.as_deref(),
354                &objects_json,
355                None,
356            )
357            .await?;
358
359        let objects = res
360            .get("objects")
361            .and_then(|x| x.as_array())
362            .cloned()
363            .unwrap_or_default();
364
365        for obj in objects {
366            if obj.get("type").and_then(|x| x.as_str()) != Some("chat_node") {
367                continue;
368            }
369
370            let data = match obj.get("data") {
371                Some(d) => d,
372                None => continue,
373            };
374
375            let messages = data
376                .get("messages")
377                .and_then(|x| x.as_array())
378                .cloned()
379                .unwrap_or_default();
380
381            let mut list = Vec::new();
382            for m in messages {
383                let mid = m.get("id").and_then(|x| x.as_i64()).unwrap_or(0);
384                let author_id = m.get("author").and_then(|x| x.as_i64()).unwrap_or(0);
385                let html = m.get("html").and_then(|x| x.as_str()).unwrap_or("");
386                let (text, _image) = parse_message_html(html);
387                list.push(Message {
388                    id: mid,
389                    chat_id: ChatId::from(chat_id.to_string()),
390                    chat_name: None,
391                    text,
392                    interlocutor_id: None,
393                    author_id,
394                });
395            }
396            return Ok(list);
397        }
398
399        Ok(Vec::new())
400    }
401
402    pub fn get_chat_id_for_user(&self, user_id: i64) -> String {
403        let my_id = self.seller_id;
404        let (id1, id2) = (my_id.min(user_id), my_id.max(user_id));
405        format!("users-{id1}-{id2}")
406    }
407
408    pub async fn get_order_secrets(&self, order_id: &str) -> Result<Vec<String>, FunPayError> {
409        let body = self
410            .gateway
411            .get_order_page(&self.golden_key, &self.user_agent, order_id)
412            .await?;
413        let doc = Html::parse_document(&body);
414        Ok(parse_order_secrets(&doc))
415    }
416
417    pub async fn get_order(&self, order_id: &str) -> Result<Order, FunPayError> {
418        let body = self
419            .gateway
420            .get_order_page(&self.golden_key, &self.user_agent, order_id)
421            .await?;
422        parse_order_page(&body, order_id)
423    }
424
425    pub async fn edit_offer(
426        &self,
427        offer_id: i64,
428        node_id: i64,
429        params: OfferEditParams,
430    ) -> Result<Value, FunPayError> {
431        let html = self
432            .gateway
433            .get_offer_edit_page(&self.golden_key, &self.user_agent, node_id, offer_id)
434            .await?;
435
436        let current = parse_offer_edit_params(&html);
437        log::debug!(
438            target: "funpay_client",
439            "Parsed offer {} current params: quantity={:?}, method={:?}, price={:?}",
440            offer_id,
441            current.quantity,
442            current.method,
443            current.price
444        );
445        let merged = current.merge(params);
446        log::debug!(
447            target: "funpay_client",
448            "Merged offer {} params: quantity={:?}, method={:?}, price={:?}",
449            offer_id,
450            merged.quantity,
451            merged.method,
452            merged.price
453        );
454
455        self.gateway
456            .post_offer_save(
457                &self.golden_key,
458                &self.user_agent,
459                self.phpsessid.as_deref(),
460                &self.csrf_token,
461                offer_id,
462                node_id,
463                &merged,
464            )
465            .await
466    }
467
468    pub async fn get_offer_params(
469        &self,
470        offer_id: i64,
471        node_id: i64,
472    ) -> Result<OfferFullParams, FunPayError> {
473        let html = self
474            .gateway
475            .get_offer_edit_page(&self.golden_key, &self.user_agent, node_id, offer_id)
476            .await?;
477        Ok(parse_offer_full_params(&html, offer_id, node_id))
478    }
479
480    pub async fn get_my_offers(&self, node_id: i64) -> Result<Vec<Offer>, FunPayError> {
481        let html = self
482            .gateway
483            .get_lots_trade_page(&self.golden_key, &self.user_agent, node_id)
484            .await?;
485        Ok(parse_my_offers(&html, node_id))
486    }
487
488    pub async fn get_market_offers(&self, node_id: i64) -> Result<Vec<MarketOffer>, FunPayError> {
489        let html = self
490            .gateway
491            .get_lots_page(&self.golden_key, &self.user_agent, node_id)
492            .await?;
493        Ok(parse_market_offers(&html, node_id))
494    }
495
496    pub async fn get_orders(&self) -> Result<Vec<OrderShortcut>, FunPayError> {
497        let body = self
498            .gateway
499            .get_orders_trade(&self.golden_key, &self.user_agent)
500            .await?;
501        parse_orders_list(&body, self.seller_id)
502    }
503
504    pub async fn get_category_subcategories(
505        &self,
506        node_id: i64,
507    ) -> Result<Vec<CategorySubcategory>, FunPayError> {
508        let html = self
509            .gateway
510            .get_lots_page(&self.golden_key, &self.user_agent, node_id)
511            .await?;
512        Ok(parse_category_subcategories(&html))
513    }
514
515    pub async fn get_category_filters(
516        &self,
517        node_id: i64,
518    ) -> Result<Vec<CategoryFilter>, FunPayError> {
519        let html = self
520            .gateway
521            .get_lots_page(&self.golden_key, &self.user_agent, node_id)
522            .await?;
523        Ok(parse_category_filters(&html))
524    }
525}