funpay_client/
types.rs

1use crate::error::FunPayError;
2use crate::events::Event;
3use crate::utils::random_tag;
4use log::info;
5use scraper::{Html, Selector};
6use serde_json::Value;
7use std::collections::HashMap;
8use std::time::Duration;
9use tokio::sync::broadcast;
10
11#[derive(Debug, Clone)]
12pub struct ChatShortcut {
13    pub id: i64,
14    pub name: String,
15    pub last_message_text: Option<String>,
16    pub node_msg_id: i64,
17    pub user_msg_id: i64,
18    pub unread: bool,
19}
20
21#[derive(Debug, Clone)]
22pub struct Message {
23    pub id: i64,
24    pub chat_id: String,
25    pub chat_name: Option<String>,
26    pub text: Option<String>,
27    pub interlocutor_id: Option<i64>,
28    pub author_id: i64,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum OrderStatus {
33    Paid,
34    Closed,
35    Refunded,
36}
37
38#[derive(Debug, Clone)]
39pub struct OrderShortcut {
40    pub id: String,
41    pub status: OrderStatus,
42}
43
44#[derive(Debug, Clone)]
45struct AppData {
46    user_id: i64,
47    csrf_token: String,
48}
49
50#[derive(Debug)]
51pub struct FunPayAccount {
52    client: reqwest::Client,
53    pub golden_key: String,
54    user_agent: String,
55    pub id: Option<i64>,
56    pub username: Option<String>,
57    pub csrf_token: Option<String>,
58    last_msg_event_tag: String,
59    last_order_event_tag: String,
60    last_messages: HashMap<i64, (i64, i64, Option<String>)>,
61    last_messages_ids: HashMap<i64, i64>,
62    saved_orders: HashMap<String, OrderShortcut>,
63    pub events_tx: broadcast::Sender<Event>,
64}
65
66impl FunPayAccount {
67    pub fn new(golden_key: String) -> Self {
68        let client = reqwest::Client::builder()
69            .redirect(reqwest::redirect::Policy::limited(10))
70            .build()
71            .unwrap();
72        let (tx, _rx) = broadcast::channel(512);
73        Self {
74            client,
75            golden_key,
76            user_agent: String::from("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"),
77            id: None,
78            username: None,
79            csrf_token: None,
80            last_msg_event_tag: random_tag(),
81            last_order_event_tag: random_tag(),
82            last_messages: HashMap::new(),
83            last_messages_ids: HashMap::new(),
84            saved_orders: HashMap::new(),
85            events_tx: tx,
86        }
87    }
88
89    pub fn subscribe(&self) -> broadcast::Receiver<Event> {
90        self.events_tx.subscribe()
91    }
92
93    async fn get(&mut self) -> Result<(), FunPayError> {
94        let url = "https://funpay.com/";
95        let resp = self
96            .client
97            .get(url)
98            .header(
99                reqwest::header::COOKIE,
100                format!("golden_key={}; cookie_prefs=1", self.golden_key),
101            )
102            .header(reqwest::header::USER_AGENT, &self.user_agent)
103            .send()
104            .await?;
105        if resp.status() == reqwest::StatusCode::FORBIDDEN {
106            return Err(FunPayError::Unauthorized);
107        }
108        if !resp.status().is_success() {
109            let status = resp.status();
110            let body = resp.text().await.unwrap_or_default();
111            return Err(FunPayError::RequestFailed {
112                status,
113                body,
114                url: url.to_string(),
115            });
116        }
117        let body = resp.text().await?;
118        let html = Html::parse_document(&body);
119        let sel_body = Selector::parse("body").unwrap();
120        let mut app_data: Option<AppData> = None;
121        if let Some(b) = html.select(&sel_body).next() {
122            if let Some(attr) = b.value().attr("data-app-data") {
123                let v: Value =
124                    serde_json::from_str(attr).map_err(|e| FunPayError::Parse(e.to_string()))?;
125                let user_id = v
126                    .get("userId")
127                    .and_then(|x| x.as_i64())
128                    .ok_or_else(|| FunPayError::Parse(String::from("missing userId")))?;
129                let csrf = v
130                    .get("csrf-token")
131                    .and_then(|x| x.as_str())
132                    .ok_or_else(|| FunPayError::Parse(String::from("missing csrf-token")))?;
133                app_data = Some(AppData {
134                    user_id,
135                    csrf_token: csrf.to_string(),
136                });
137            }
138        }
139        let sel_uname = Selector::parse("div.user-link-name").unwrap();
140        let username = html
141            .select(&sel_uname)
142            .next()
143            .map(|n| n.text().collect::<String>());
144        if username.is_none() {
145            return Err(FunPayError::Unauthorized);
146        }
147        let app = app_data.ok_or_else(|| FunPayError::Parse(String::from("missing app data")))?;
148        self.id = Some(app.user_id);
149        self.csrf_token = Some(app.csrf_token);
150        self.username = username;
151        Ok(())
152    }
153
154    async fn post_runner(&self, objects_json: String) -> Result<Value, FunPayError> {
155        let csrf = self
156            .csrf_token
157            .as_ref()
158            .ok_or(FunPayError::AccountNotInitiated)?
159            .to_string();
160        let url = "https://funpay.com/runner/";
161        let payload = format!(
162            "objects={}&request=false&csrf_token={}",
163            urlencoding::encode(&objects_json),
164            urlencoding::encode(&csrf)
165        );
166        let resp = self
167            .client
168            .post(url)
169            .header(
170                reqwest::header::CONTENT_TYPE,
171                "application/x-www-form-urlencoded; charset=UTF-8",
172            )
173            .header("x-requested-with", "XMLHttpRequest")
174            .header(reqwest::header::ACCEPT, "*/*")
175            .header(
176                reqwest::header::COOKIE,
177                format!("golden_key={}; cookie_prefs=1", self.golden_key),
178            )
179            .header(reqwest::header::USER_AGENT, &self.user_agent)
180            .body(payload)
181            .send()
182            .await?;
183        if resp.status() == reqwest::StatusCode::FORBIDDEN {
184            return Err(FunPayError::Unauthorized);
185        }
186        if !resp.status().is_success() {
187            let status = resp.status();
188            let body = resp.text().await.unwrap_or_default();
189            return Err(FunPayError::RequestFailed {
190                status,
191                body,
192                url: url.to_string(),
193            });
194        }
195        let v: Value = resp.json().await?;
196        Ok(v)
197    }
198
199    fn parse_chat_bookmarks(&mut self, html: &str) -> Vec<ChatShortcut> {
200        let doc = Html::parse_fragment(html);
201        let sel_chat = Selector::parse("a.contact-item").unwrap();
202        let sel_msg = Selector::parse("div.contact-item-message").unwrap();
203        let sel_name = Selector::parse("div.media-user-name").unwrap();
204        let mut out = Vec::new();
205        for el in doc.select(&sel_chat) {
206            let id_attr = el.value().attr("data-id").unwrap_or("0");
207            let id = id_attr.parse::<i64>().unwrap_or(0);
208            let node_msg_id = el
209                .value()
210                .attr("data-node-msg")
211                .unwrap_or("0")
212                .parse::<i64>()
213                .unwrap_or(0);
214            let user_msg_id = el
215                .value()
216                .attr("data-user-msg")
217                .unwrap_or("0")
218                .parse::<i64>()
219                .unwrap_or(0);
220            let unread = el.value().classes().any(|c| c == "unread");
221            let last_message_text = el
222                .select(&sel_msg)
223                .next()
224                .map(|n| n.text().collect::<String>());
225            let name = el
226                .select(&sel_name)
227                .next()
228                .map(|n| n.text().collect::<String>())
229                .unwrap_or_default();
230            out.push(ChatShortcut {
231                id,
232                name,
233                last_message_text,
234                node_msg_id,
235                user_msg_id,
236                unread,
237            });
238        }
239        out
240    }
241
242    fn parse_events_from_updates(
243        &mut self,
244        updates: &Value,
245        first: bool,
246    ) -> (Vec<Event>, Vec<(i64, Option<String>)>) {
247        let mut events = Vec::new();
248        let mut changed_chats: Vec<ChatShortcut> = Vec::new();
249        let objects = updates
250            .get("objects")
251            .and_then(|x| x.as_array())
252            .cloned()
253            .unwrap_or_default();
254        for obj in objects {
255            let typ = obj.get("type").and_then(|x| x.as_str()).unwrap_or("");
256            if typ == "chat_bookmarks" {
257                if let Some(tag) = obj.get("tag").and_then(|x| x.as_str()) {
258                    self.last_msg_event_tag = tag.to_string();
259                }
260                let html = obj
261                    .get("data")
262                    .and_then(|x| x.get("html"))
263                    .and_then(|x| x.as_str())
264                    .unwrap_or("");
265                if html.is_empty() {
266                    continue;
267                }
268                let chats = self.parse_chat_bookmarks(html);
269                if first {
270                    for ch in chats {
271                        events.push(Event::InitialChat { chat: ch });
272                    }
273                } else {
274                    if !chats.is_empty() {
275                        events.push(Event::ChatsListChanged);
276                    }
277                    for ch in chats {
278                        let prev = self
279                            .last_messages
280                            .get(&ch.id)
281                            .cloned()
282                            .unwrap_or((-1, -1, None));
283                        if ch.node_msg_id > prev.0 {
284                            events.push(Event::LastChatMessageChanged { chat: ch.clone() });
285                            changed_chats.push(ch.clone());
286                        }
287                        self.last_messages.insert(
288                            ch.id,
289                            (ch.node_msg_id, ch.user_msg_id, ch.last_message_text.clone()),
290                        );
291                    }
292                }
293            } else if typ == "orders_counters" {
294                if let Some(tag) = obj.get("tag").and_then(|x| x.as_str()) {
295                    self.last_order_event_tag = tag.to_string();
296                }
297                let purchases = obj
298                    .get("data")
299                    .and_then(|x| x.get("buyer"))
300                    .and_then(|x| x.as_i64())
301                    .unwrap_or(0) as i32;
302                let sales = obj
303                    .get("data")
304                    .and_then(|x| x.get("seller"))
305                    .and_then(|x| x.as_i64())
306                    .unwrap_or(0) as i32;
307                events.push(Event::OrdersListChanged { purchases, sales });
308            }
309        }
310        let chats_data: Vec<(i64, Option<String>)> = if first {
311            Vec::new()
312        } else {
313            changed_chats
314                .into_iter()
315                .map(|c| (c.id, Some(c.name)))
316                .collect()
317        };
318        (events, chats_data)
319    }
320
321    pub async fn start_polling_loop(&mut self) -> std::io::Result<()> {
322        self.get()
323            .await
324            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
325        info!(target: "funpay_client", "Logged in as {}", self.username.clone().unwrap_or_default());
326        let mut first = true;
327        loop {
328            let orders = serde_json::json!({
329                "type": "orders_counters",
330                "id": self.id.unwrap_or_default(),
331                "tag": self.last_order_event_tag,
332                "data": false
333            });
334            let chats = serde_json::json!({
335                "type": "chat_bookmarks",
336                "id": self.id.unwrap_or_default(),
337                "tag": self.last_msg_event_tag,
338                "data": false
339            });
340            let objects_json = serde_json::to_string(&serde_json::json!([orders, chats])).unwrap();
341            let updates = self
342                .post_runner(objects_json)
343                .await
344                .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
345            let (evs, changed_chats) = self.parse_events_from_updates(&updates, first);
346            for ev in evs {
347                let _ = self.events_tx.send(ev);
348            }
349            if !first && !changed_chats.is_empty() {
350                match self.fetch_chats_histories(&changed_chats).await {
351                    Ok(mut histories) => {
352                        for (cid, mut msgs) in histories.drain() {
353                            if let Some(last_id) = self.last_messages_ids.get(&cid).copied() {
354                                msgs.retain(|m| m.id > last_id);
355                            }
356                            if let Some(max_id) = msgs.iter().map(|m| m.id).max() {
357                                self.last_messages_ids.insert(cid, max_id);
358                            }
359                            for m in msgs {
360                                let _ = self.events_tx.send(Event::NewMessage { message: m });
361                            }
362                        }
363                    }
364                    Err(e) => {
365                        log::error!(target: "funpay_client", "Failed to fetch chat histories: {}", e);
366                    }
367                }
368            }
369            match self.fetch_sales_list().await {
370                Ok(list) => {
371                    let mut new_map: HashMap<String, OrderShortcut> = HashMap::new();
372                    for o in list.into_iter() {
373                        new_map.insert(o.id.clone(), o);
374                    }
375                    if self.saved_orders.is_empty() {
376                        for order in new_map.values() {
377                            let _ = self.events_tx.send(Event::InitialOrder {
378                                order: order.clone(),
379                            });
380                        }
381                    } else {
382                        for (id, order) in new_map.iter() {
383                            if let Some(prev) = self.saved_orders.get(id) {
384                                if prev.status != order.status {
385                                    let _ = self.events_tx.send(Event::OrderStatusChanged {
386                                        order: order.clone(),
387                                    });
388                                }
389                            } else {
390                                let _ = self.events_tx.send(Event::NewOrder {
391                                    order: order.clone(),
392                                });
393                                if order.status == OrderStatus::Closed {
394                                    let _ = self.events_tx.send(Event::OrderStatusChanged {
395                                        order: order.clone(),
396                                    });
397                                }
398                            }
399                        }
400                    }
401                    self.saved_orders = new_map;
402                }
403                Err(e) => {
404                    log::error!(target: "funpay_client", "Failed to fetch sales list: {}", e);
405                }
406            }
407            first = false;
408            tokio::time::sleep(Duration::from_secs(1)).await;
409        }
410    }
411
412    async fn fetch_sales_list(&self) -> Result<Vec<OrderShortcut>, FunPayError> {
413        let url = "https://funpay.com/orders/trade";
414        let resp = self
415            .client
416            .get(url)
417            .header(
418                reqwest::header::COOKIE,
419                format!("golden_key={}; cookie_prefs=1", self.golden_key),
420            )
421            .header(reqwest::header::USER_AGENT, &self.user_agent)
422            .header(reqwest::header::ACCEPT, "*/*")
423            .send()
424            .await?;
425        if resp.status() == reqwest::StatusCode::FORBIDDEN {
426            return Err(FunPayError::Unauthorized);
427        }
428        if !resp.status().is_success() {
429            let status = resp.status();
430            let body = resp.text().await.unwrap_or_default();
431            return Err(FunPayError::RequestFailed {
432                status,
433                body,
434                url: url.to_string(),
435            });
436        }
437        let body = resp.text().await?;
438        let doc = Html::parse_document(&body);
439        let sel_user = Selector::parse("div.user-link-name").unwrap();
440        if doc.select(&sel_user).next().is_none() {
441            return Err(FunPayError::Unauthorized);
442        }
443        let sel_item = Selector::parse("a.tc-item").unwrap();
444        let sel_order = Selector::parse("div.tc-order").unwrap();
445        let mut out = Vec::new();
446        for a in doc.select(&sel_item) {
447            let class_list: Vec<String> = a.value().classes().map(|s| s.to_string()).collect();
448            let status = if class_list.iter().any(|c| c == "warning") {
449                OrderStatus::Refunded
450            } else if class_list.iter().any(|c| c == "info") {
451                OrderStatus::Paid
452            } else {
453                OrderStatus::Closed
454            };
455            let order_div = a.select(&sel_order).next();
456            if order_div.is_none() {
457                continue;
458            }
459            let mut id_text = order_div.unwrap().text().collect::<String>();
460            id_text = id_text.trim().to_string();
461            let id = if let Some(stripped) = id_text.strip_prefix('#') {
462                stripped.to_string()
463            } else {
464                id_text
465            };
466            out.push(OrderShortcut { id, status });
467        }
468        Ok(out)
469    }
470
471    async fn fetch_chats_histories(
472        &self,
473        chats_data: &[(i64, Option<String>)],
474    ) -> Result<HashMap<i64, Vec<Message>>, FunPayError> {
475        let mut objects = Vec::with_capacity(chats_data.len());
476        for (chat_id, _name) in chats_data.iter() {
477            objects.push(serde_json::json!({
478                "type": "chat_node",
479                "id": chat_id,
480                "tag": "00000000",
481                "data": {"node": chat_id, "last_message": -1, "content": ""}
482            }));
483        }
484        let objects_json = serde_json::to_string(&objects).unwrap();
485        let res = self.post_runner(objects_json).await?;
486        let mut out: HashMap<i64, Vec<Message>> = HashMap::new();
487        let objects = res
488            .get("objects")
489            .and_then(|x| x.as_array())
490            .cloned()
491            .unwrap_or_default();
492        for obj in objects {
493            if obj.get("type").and_then(|x| x.as_str()) != Some("chat_node") {
494                continue;
495            }
496            let id = obj.get("id").and_then(|x| x.as_i64()).unwrap_or(0);
497            let data = obj.get("data");
498            if data.is_none() {
499                out.insert(id, Vec::new());
500                continue;
501            }
502            let data = data.unwrap();
503            let messages = data
504                .get("messages")
505                .and_then(|x| x.as_array())
506                .cloned()
507                .unwrap_or_default();
508            let mut list = Vec::new();
509            for m in messages {
510                let mid = m.get("id").and_then(|x| x.as_i64()).unwrap_or(0);
511                let author_id = m.get("author").and_then(|x| x.as_i64()).unwrap_or(0);
512                let html = m.get("html").and_then(|x| x.as_str()).unwrap_or("");
513                let (text, _image) = Self::parse_message_html(html);
514                list.push(Message {
515                    id: mid,
516                    chat_id: format!("{}", id),
517                    chat_name: chats_data
518                        .iter()
519                        .find(|(cid, _)| *cid == id)
520                        .and_then(|(_, n)| n.clone()),
521                    text,
522                    interlocutor_id: None,
523                    author_id,
524                });
525            }
526            out.insert(id, list);
527        }
528        Ok(out)
529    }
530
531    fn parse_message_html(html: &str) -> (Option<String>, Option<String>) {
532        let html_owned = html.replace("<br>", "\n");
533        let doc = Html::parse_fragment(&html_owned);
534        let sel_text = Selector::parse("div.chat-msg-text").unwrap();
535        if let Some(n) = doc.select(&sel_text).next() {
536            let t = n.text().collect::<String>();
537            return (Some(t), None);
538        }
539        let sel_alert = Selector::parse("div[role=alert]").unwrap();
540        if let Some(n) = doc.select(&sel_alert).next() {
541            let t = n.text().collect::<String>();
542            return (Some(t), None);
543        }
544        let sel_img = Selector::parse("a.chat-img-link").unwrap();
545        if let Some(n) = doc.select(&sel_img).next() {
546            let href = n.value().attr("href").map(|s| s.to_string());
547            return (None, href);
548        }
549        (None, None)
550    }
551}