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