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