├── .gitignore ├── .readthedocs.yml ├── FunPayAPI ├── __init__.py ├── account.py ├── common │ ├── __init__.py │ ├── enums.py │ ├── exceptions.py │ └── utils.py ├── types.py └── updater │ ├── __init__.py │ ├── events.py │ └── runner.py ├── LICENSE ├── README.md ├── doc_req.txt ├── docs ├── Makefile ├── make.bat └── source │ ├── HOW_chats.rst │ ├── _static │ ├── FunPayAPI.png │ └── FunPayAPI_darkmode.png │ ├── account.rst │ ├── conf.py │ ├── enums.rst │ ├── events.rst │ ├── index.rst │ ├── install.rst │ ├── quickstart.rst │ ├── runner.rst │ └── types.rst ├── requirements.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # pycharm 2 | .idea/ 3 | 4 | # enviroment 5 | venv/ 6 | 7 | # Byte-compiled / optimized files 8 | __pycache__/ 9 | 10 | # Sphinx 11 | docs/build/ -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.11" 13 | 14 | # Build documentation in the docs/ directory with Sphinx 15 | sphinx: 16 | configuration: docs/source/conf.py 17 | 18 | # If using Sphinx, optionally build your docs in additional formats such as PDF 19 | # formats: 20 | # - pdf 21 | 22 | # Optionally declare the Python requirements required to build your docs 23 | python: 24 | install: 25 | - requirements: doc_req.txt -------------------------------------------------------------------------------- /FunPayAPI/__init__.py: -------------------------------------------------------------------------------- 1 | from .account import Account 2 | from .updater.runner import Runner 3 | from .updater import events 4 | from .common import exceptions, utils, enums 5 | from . import types 6 | -------------------------------------------------------------------------------- /FunPayAPI/account.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING, Literal, Any, Optional, IO 3 | if TYPE_CHECKING: 4 | from .updater.runner import Runner 5 | 6 | from requests_toolbelt import MultipartEncoder 7 | from bs4 import BeautifulSoup 8 | from datetime import datetime, timedelta 9 | import requests 10 | import logging 11 | import random 12 | import string 13 | import json 14 | import time 15 | import re 16 | 17 | from . import types 18 | from .common import exceptions, utils, enums 19 | 20 | 21 | logger = logging.getLogger("FunPayAPI.account") 22 | PRIVATE_CHAT_ID_RE = re.compile(r"users-\d+-\d+$") 23 | 24 | 25 | class Account: 26 | """ 27 | Класс для управления аккаунтом FunPay. 28 | 29 | :param golden_key: токен (golden_key) аккаунта. 30 | :type golden_key: :obj:`str` 31 | 32 | :param user_agent: user-agent браузера, с которого был произведен вход в аккаунт. 33 | :type user_agent: :obj:`str` 34 | 35 | :param requests_timeout: тайм-аут ожидания ответа на запросы. 36 | :type requests_timeout: :obj:`int` or :obj:`float` 37 | 38 | :param proxy: прокси для запросов. 39 | :type proxy: :obj:`dict` {:obj:`str`: :obj:`str` or :obj:`None` 40 | """ 41 | def __init__(self, golden_key: str, user_agent: str | None = None, 42 | requests_timeout: int | float = 10, proxy: Optional[dict] = None): 43 | self.golden_key: str = golden_key 44 | """Токен (golden_key) аккаунта.""" 45 | self.user_agent: str | None = user_agent 46 | """User-agent браузера, с которого был произведен вход в аккаунт.""" 47 | self.requests_timeout: int | float = requests_timeout 48 | """Тайм-аут ожидания ответа на запросы.""" 49 | self.proxy = proxy 50 | 51 | self.html: str | None = None 52 | """HTML основной страницы FunPay.""" 53 | self.app_data: dict | None = None 54 | """Appdata.""" 55 | self.id: int | None = None 56 | """ID аккаунта.""" 57 | self.username: str | None = None 58 | """Никнейм аккаунта.""" 59 | self.active_sales: int | None = None 60 | """Активные продажи.""" 61 | self.active_purchases: int | None = None 62 | """Активные покупки.""" 63 | 64 | self.csrf_token: str | None = None 65 | """CSRF токен.""" 66 | self.phpsessid: str | None = None 67 | """PHPSESSID сессии.""" 68 | self.last_update: int | None = None 69 | """Последнее время обновления аккаунта.""" 70 | 71 | self.__initiated: bool = False 72 | 73 | self.__saved_chats: dict[int, types.ChatShortcut] = {} 74 | self.runner: Runner | None = None 75 | """Объект Runner'а.""" 76 | 77 | self.__categories: list[types.Category] = [] 78 | self.__sorted_categories: dict[int, types.Category] = {} 79 | 80 | self.__subcategories: list[types.SubCategory] = [] 81 | self.__sorted_subcategories: dict[types.SubCategoryTypes, dict[int, types.SubCategory]] = { 82 | types.SubCategoryTypes.COMMON: {}, 83 | types.SubCategoryTypes.CURRENCY: {} 84 | } 85 | 86 | self.__bot_character = "⁤" 87 | """Если сообщение начинается с этого символа, значит оно отправлено ботом.""" 88 | 89 | def method(self, request_method: Literal["post", "get"], api_method: str, headers: dict, payload: Any, 90 | exclude_phpsessid: bool = False, raise_not_200: bool = False) -> requests.Response: 91 | """ 92 | Отправляет запрос к FunPay. Добавляет в заголовки запроса user_agent и куки. 93 | 94 | :param request_method: метод запроса ("get" / "post"). 95 | :type request_method: :obj:`str` `post` or `get` 96 | 97 | :param api_method: метод API / полная ссылка. 98 | :type api_method: :obj:`str` 99 | 100 | :param headers: заголовки запроса. 101 | :type headers: :obj:`dict` 102 | 103 | :param payload: полезная нагрузка. 104 | :type payload: :obj:`dict` 105 | 106 | :param exclude_phpsessid: исключить ли PHPSESSID из добавляемых куки? 107 | :type exclude_phpsessid: :obj:`bool` 108 | 109 | :param raise_not_200: возбуждать ли исключение, если статус код ответа != 200? 110 | :type raise_not_200: :obj:`bool` 111 | 112 | :return: объект ответа. 113 | :rtype: :class:`requests.Response` 114 | """ 115 | headers["cookie"] = f"golden_key={self.golden_key}" 116 | headers["cookie"] += f"; PHPSESSID={self.phpsessid}" if self.phpsessid and not exclude_phpsessid else "" 117 | if self.user_agent: 118 | headers["user-agent"] = self.user_agent 119 | link = api_method if api_method.startswith("https://funpay.com") else "https://funpay.com/" + api_method 120 | response = getattr(requests, request_method)(link, headers=headers, data=payload, timeout=self.requests_timeout, 121 | proxies=self.proxy or {}) 122 | 123 | if response.status_code == 403: 124 | raise exceptions.UnauthorizedError(response) 125 | elif response.status_code != 200 and raise_not_200: 126 | raise exceptions.RequestFailedError(response) 127 | return response 128 | 129 | def get(self, update_phpsessid: bool = False) -> Account: 130 | """ 131 | Получает / обновляет данные об аккаунте. Необходимо вызывать каждые 40-60 минут, дабы обновить 132 | :py:obj:`.Account.phpsessid`. 133 | 134 | :param update_phpsessid: обновить :py:obj:`.Account.phpsessid` или использовать старый. 135 | :type update_phpsessid: :obj:`bool`, опционально 136 | 137 | :return: объект аккаунта с обновленными данными. 138 | :rtype: :class:`FunPayAPI.account.Account` 139 | """ 140 | response = self.method("get", "https://funpay.com", {}, {}, update_phpsessid, raise_not_200=True) 141 | 142 | html_response = response.content.decode() 143 | parser = BeautifulSoup(html_response, "html.parser") 144 | 145 | username = parser.find("div", {"class": "user-link-name"}) 146 | if not username: 147 | raise exceptions.UnauthorizedError(response) 148 | 149 | self.username = username.text 150 | self.app_data = json.loads(parser.find("body").get("data-app-data")) 151 | self.id = self.app_data["userId"] 152 | self.csrf_token = self.app_data["csrf-token"] 153 | 154 | active_sales = parser.find("span", {"class": "badge badge-trade"}) 155 | self.active_sales = int(active_sales.text) if active_sales else 0 156 | 157 | active_purchases = parser.find("span", {"class": "badge badge-orders"}) 158 | self.active_purchases = int(active_purchases.text) if active_purchases else 0 159 | 160 | cookies = response.cookies.get_dict() 161 | if update_phpsessid or not self.phpsessid: 162 | self.phpsessid = cookies["PHPSESSID"] 163 | if not self.is_initiated: 164 | self.__setup_categories(html_response) 165 | 166 | self.last_update = int(time.time()) 167 | self.html = html_response 168 | self.__initiated = True 169 | return self 170 | 171 | def get_subcategory_public_lots(self, subcategory_type: enums.SubCategoryTypes, subcategory_id: int) -> list[types.LotShortcut]: 172 | """ 173 | Получает список всех опубликованных лотов переданной подкатегории. 174 | 175 | :param subcategory_type: тип подкатегории. 176 | :type subcategory_type: :class:`FunPayAPI.enums.SubCategoryTypes` 177 | 178 | :param subcategory_id: ID подкатегории. 179 | :type subcategory_id: :obj:`int` 180 | 181 | :return: список всех опубликованных лотов переданной подкатегории. 182 | :rtype: :obj:`list` of :class:`FunPayAPI.types.LotShortcut` 183 | """ 184 | if not self.is_initiated: 185 | raise exceptions.AccountNotInitiatedError() 186 | 187 | meth = f"lots/{subcategory_id}/" if subcategory_type is enums.SubCategoryTypes.COMMON else f"chips/{subcategory_id}/" 188 | response = self.method("get", meth, {"accept": "*/*"}, {}, raise_not_200=True) 189 | html_response = response.content.decode() 190 | parser = BeautifulSoup(html_response, "html.parser") 191 | 192 | username = parser.find("div", {"class": "user-link-name"}) 193 | if not username: 194 | raise exceptions.UnauthorizedError(response) 195 | 196 | offers = parser.find_all("a", {"class": "tc-item"}) 197 | if not offers: 198 | return [] 199 | 200 | subcategory_obj = self.get_subcategory(subcategory_type, subcategory_id) 201 | result = [] 202 | for offer in offers: 203 | offer_id = offer["href"].split("id=")[1] 204 | description = offer.find("div", {"class": "tc-desc-text"}) 205 | description = description.text if description else None 206 | server = offer.find("div", {"class": "tc-server hidden-xxs"}) 207 | if not server: 208 | server = offer.find("div", {"class": "tc-server hidden-xs"}) 209 | server = server.text if server else None 210 | 211 | if subcategory_type is types.SubCategoryTypes.COMMON: 212 | price = float(offer.find("div", {"class": "tc-price"})["data-s"]) 213 | else: 214 | price = float(offer.find("div", {"class": "tc-price"}).find("div").text.split()[0]) 215 | lot_obj = types.LotShortcut(offer_id, server, description, price, subcategory_obj, str(offer)) 216 | result.append(lot_obj) 217 | return result 218 | 219 | def get_balance(self, lot_id: int = 18853876) -> types.Balance: 220 | """ 221 | Получает информацию о балансе пользователя. 222 | 223 | :param lot_id: ID лота, на котором проверять баланс. 224 | :type lot_id: :obj:`int`, опционально 225 | 226 | :return: информацию о балансе пользователя. 227 | :rtype: :class:`FunPayAPI.types.Balance` 228 | """ 229 | if not self.is_initiated: 230 | raise exceptions.AccountNotInitiatedError() 231 | response = self.method("get", f"lots/offer?id={lot_id}", {"accept": "*/*"}, {}, raise_not_200=True) 232 | html_response = response.content.decode() 233 | parser = BeautifulSoup(html_response, "html.parser") 234 | 235 | username = parser.find("div", {"class": "user-link-name"}) 236 | if not username: 237 | raise exceptions.UnauthorizedError(response) 238 | 239 | balances = parser.find("select", {"name": "method"}) 240 | balance = types.Balance(float(balances["data-balance-total-rub"]), float(balances["data-balance-rub"]), 241 | float(balances["data-balance-total-usd"]), float(balances["data-balance-usd"]), 242 | float(balances["data-balance-total-eur"]), float(balances["data-balance-eur"])) 243 | return balance 244 | 245 | def get_chat_history(self, chat_id: int | str, last_message_id: int = 99999999999999999999999, 246 | interlocutor_username: Optional[str] = None, from_id: int = 0) -> list[types.Message]: 247 | """ 248 | Получает историю указанного чата (до 100 последних сообщений). 249 | 250 | :param chat_id: ID чата (или его текстовое обозначение). 251 | :type chat_id: :obj:`int` or :obj:`str` 252 | 253 | :param last_message_id: ID сообщения, с которого начинать историю (фильтр FunPay). 254 | :type last_message_id: :obj:`int` 255 | 256 | :param interlocutor_username: никнейм собеседника. Не нужно указывать для получения истории публичного чата. 257 | Так же не обязательно, но желательно указывать для получения истории личного чата. 258 | :type interlocutor_username: :obj:`str` or :obj:`None`, опционально. 259 | 260 | :param from_id: все сообщения с ID < переданного не попадут в возвращаемый список сообщений. 261 | :type from_id: :obj:`int`, опционально. 262 | 263 | :return: история указанного чата. 264 | :rtype: :obj:`list` of :class:`FunPayAPI.types.Message` 265 | """ 266 | if not self.is_initiated: 267 | raise exceptions.AccountNotInitiatedError() 268 | 269 | headers = { 270 | "accept": "*/*", 271 | "x-requested-with": "XMLHttpRequest" 272 | } 273 | payload = { 274 | "node": chat_id, 275 | "last_message": last_message_id 276 | } 277 | response = self.method("get", f"chat/history?node={chat_id}&last_message={last_message_id}", 278 | headers, payload, raise_not_200=True) 279 | 280 | json_response = response.json() 281 | if not json_response.get("chat") or not json_response["chat"].get("messages"): 282 | return [] 283 | if isinstance(chat_id, int): 284 | interlocutor_id = int(json_response["chat"]["node"]["name"].split("-")[2]) 285 | else: 286 | interlocutor_id = None 287 | return self.__parse_messages(json_response["chat"]["messages"], chat_id, interlocutor_id, 288 | interlocutor_username, from_id) 289 | 290 | def get_chats_histories(self, chats_data: dict[int | str, str | None]) -> dict[int, list[types.Message]]: 291 | """ 292 | Получает историю сообщений сразу нескольких чатов 293 | (до 50 сообщений на личный чат, до 25 сообщений на публичный чат). 294 | 295 | :param chats_data: ID чатов и никнеймы собеседников (None, если никнейм неизвестен)\n 296 | Например: {48392847: "SLLMK", 58392098: "Amongus", 38948728: None} 297 | :type chats_data: :obj:`dict` {:obj:`int` or :obj:`str`: :obj:`str` or :obj:`None`} 298 | 299 | :return: словарь с историями чатов в формате {ID чата: [список сообщений]} 300 | :rtype: :obj:`dict` {:obj:`int`: :obj:`list` of :class:`FunPayAPI.types.Message`} 301 | """ 302 | headers = { 303 | "accept": "*/*", 304 | "content-type": "application/x-www-form-urlencoded; charset=UTF-8", 305 | "x-requested-with": "XMLHttpRequest" 306 | } 307 | objects = [{"type": "chat_node", "id": i, "tag": "00000000", 308 | "data": {"node": i, "last_message": -1, "content": ""}} for i in chats_data] 309 | payload = { 310 | "objects": json.dumps(objects), 311 | "request": False, 312 | "csrf_token": self.csrf_token 313 | } 314 | response = self.method("post", "runner/", headers, payload, raise_not_200=True) 315 | json_response = response.json() 316 | 317 | result = {} 318 | for i in json_response["objects"]: 319 | if not i.get("data"): 320 | result[i.get("id")] = [] 321 | continue 322 | if isinstance(i.get("id"), int): 323 | interlocutor_id = int(i["data"]["node"]["name"].split("-")[2]) 324 | interlocutor_name = chats_data[i.get("id")] 325 | else: 326 | interlocutor_id = None 327 | interlocutor_name = None 328 | messages = self.__parse_messages(i["data"]["messages"], i.get("id"), interlocutor_id, interlocutor_name) 329 | result[i.get("id")] = messages 330 | return result 331 | 332 | def upload_image(self, image: str | IO[bytes]) -> int: 333 | """ 334 | Выгружает изображение на сервер FunPay для дальнейшей отправки в качестве сообщения. 335 | Для отправки изображения в чат рекомендуется использовать метод :meth:`FunPayAPI.account.Account.send_image`. 336 | 337 | :param image: путь до изображения или представление изображения в виде байтов. 338 | :type image: :obj:`str` or :obj:`bytes` 339 | 340 | :return: ID изображения на серверах FunPay. 341 | :rtype: :obj:`int` 342 | """ 343 | if not self.is_initiated: 344 | raise exceptions.AccountNotInitiatedError() 345 | 346 | if isinstance(image, str): 347 | with open(image, "rb") as f: 348 | img = f.read() 349 | else: 350 | img = image 351 | 352 | fields = { 353 | 'file': ("funpay_cardinal_image.png", img, "image/png"), 354 | 'file_id': "0" 355 | } 356 | boundary = '----WebKitFormBoundary' + ''.join(random.sample(string.ascii_letters + string.digits, 16)) 357 | m = MultipartEncoder(fields=fields, boundary=boundary) 358 | 359 | headers = { 360 | "accept": "*/*", 361 | "x-requested-with": "XMLHttpRequest", 362 | "content-type": m.content_type, 363 | } 364 | 365 | response = self.method("post", "file/addChatImage", headers, m) 366 | 367 | if response.status_code == 400: 368 | try: 369 | json_response = response.json() 370 | message = json_response.get("msg") 371 | raise exceptions.ImageUploadError(response, message) 372 | except requests.exceptions.JSONDecodeError: 373 | raise exceptions.ImageUploadError(response, None) 374 | elif response.status_code != 200: 375 | raise exceptions.RequestFailedError(response) 376 | 377 | if not (document_id := response.json().get("fileId")): 378 | raise exceptions.ImageUploadError(response, None) 379 | return int(document_id) 380 | 381 | def send_message(self, chat_id: int | str, text: Optional[str] = None, chat_name: Optional[str] = None, 382 | image_id: Optional[int] = None, add_to_ignore_list: bool = True, 383 | update_last_saved_message: bool = False) -> types.Message: 384 | """ 385 | Отправляет сообщение в чат. 386 | 387 | :param chat_id: ID чата. 388 | :type chat_id: :obj:`int` or :obj:`str` 389 | 390 | :param text: текст сообщения. 391 | :type text: :obj:`str` or :obj:`None`, опционально 392 | 393 | :param chat_name: название чата (для возвращаемого объекта сообщения) (не нужно для отправки сообщения в публичный чат). 394 | :type chat_name: :obj:`str` or :obj:`None`, опционально 395 | 396 | :param image_id: ID изображения. Доступно только для личных чатов. 397 | :type image_id: :obj:`int` or :obj:`None`, опционально 398 | 399 | :param add_to_ignore_list: добавлять ли ID отправленного сообщения в игнорируемый список Runner'а? 400 | :type add_to_ignore_list: :obj:`bool`, опционально 401 | 402 | :param update_last_saved_message: обновлять ли последнее сохраненное сообщение на отправленное в Runner'е? 403 | :type update_last_saved_message: :obj:`bool`, опционально. 404 | 405 | :return: экземпляр отправленного сообщения. 406 | :rtype: :class:`FunPayAPI.types.Message` 407 | """ 408 | if not self.is_initiated: 409 | raise exceptions.AccountNotInitiatedError() 410 | 411 | headers = { 412 | "accept": "*/*", 413 | "content-type": "application/x-www-form-urlencoded; charset=UTF-8", 414 | "x-requested-with": "XMLHttpRequest" 415 | } 416 | request = { 417 | "action": "chat_message", 418 | "data": {"node": chat_id, "last_message": -1, "content": text} 419 | } 420 | 421 | if image_id is not None: 422 | request["data"]["image_id"] = image_id 423 | request["data"]["content"] = "" 424 | else: 425 | request["data"]["content"] = f"{self.__bot_character}{text}" if text else "" 426 | 427 | objects = [ 428 | { 429 | "type": "chat_node", 430 | "id": chat_id, 431 | "tag": "00000000", 432 | "data": {"node": chat_id, "last_message": -1, "content": ""} 433 | } 434 | ] 435 | payload = { 436 | "objects": json.dumps(objects), 437 | "request": json.dumps(request), 438 | "csrf_token": self.csrf_token 439 | } 440 | 441 | response = self.method("post", "runner/", headers, payload, raise_not_200=True) 442 | json_response = response.json() 443 | if not (resp := json_response.get("response")): 444 | raise exceptions.MessageNotDeliveredError(response, None, chat_id) 445 | 446 | if (error_text := resp.get("error")) is not None: 447 | raise exceptions.MessageNotDeliveredError(response, error_text, chat_id) 448 | 449 | mes = json_response["objects"][0]["data"]["messages"][-1] 450 | parser = BeautifulSoup(mes["html"], "html.parser") 451 | try: 452 | if image_link := parser.find("a", {"class": "chat-img-link"}): 453 | image_link = image_link.get("href") 454 | message_text = None 455 | else: 456 | message_text = parser.find("div", {"class": "message-text"}).text.replace(self.__bot_character, "", 1) 457 | except Exception as e: 458 | logger.debug("SEND_MESSAGE RESPONSE") 459 | logger.debug(response.content.decode()) 460 | raise e 461 | 462 | message_obj = types.Message(int(mes["id"]), message_text, chat_id, chat_name, self.username, self.id, 463 | mes["html"], image_link) 464 | if self.runner and isinstance(chat_id, int): 465 | if add_to_ignore_list: 466 | self.runner.mark_as_by_bot(chat_id, message_obj.id) 467 | if update_last_saved_message: 468 | self.runner.update_last_message(chat_id, message_text) 469 | return message_obj 470 | 471 | def send_image(self, chat_id: int, image: int | str | IO[bytes], chat_name: Optional[str] = None, 472 | add_to_ignore_list: bool = True, update_last_saved_message: bool = False) -> types.Message: 473 | """ 474 | Отправляет изображение в чат. Доступно только для личных чатов. 475 | 476 | :param chat_id: ID чата. 477 | :type chat_id: :obj:`int` 478 | 479 | :param image: ID изображения / путь до изображения / изображение в виде байтов. 480 | Если передан путь до изображения или представление изображения в виде байтов, сначала оно будет выгружено 481 | с помощью метода :meth:`FunPayAPI.account.Account.upload_image`. 482 | :type image: :obj:`int` or :obj:`str` or :obj:`bytes` 483 | 484 | :param chat_name: Название чата (никнейм собеседника). Нужен для возвращаемого объекта. 485 | :type chat_name: :obj:`str` or :obj:`None`, опционально 486 | 487 | :param add_to_ignore_list: добавлять ли ID отправленного сообщения в игнорируемый список Runner'а? 488 | :type add_to_ignore_list: :obj:`bool`, опционально 489 | 490 | :param update_last_saved_message: обновлять ли последнее сохраненное сообщение на отправленное в Runner'е? 491 | :type update_last_saved_message: :obj:`bool`, опционально 492 | 493 | :return: объект отправленного сообщения. 494 | :rtype: :class:`FunPayAPI.types.Message` 495 | """ 496 | if not self.is_initiated: 497 | raise exceptions.AccountNotInitiatedError() 498 | 499 | if not isinstance(image, int): 500 | image = self.upload_image(image) 501 | result = self.send_message(chat_id, None, chat_name, image, add_to_ignore_list, update_last_saved_message) 502 | return result 503 | 504 | def send_review(self, order_id: str, text: str, rating: Literal[1, 2, 3, 4, 5] = 5) -> str: 505 | """ 506 | Отправляет / редактирует отзыв / ответ на отзыв. 507 | 508 | :param order_id: ID заказа. 509 | :type order_id: :obj:`str` 510 | 511 | :param text: текст отзыва. 512 | :type text: :obj:`str` 513 | 514 | :param rating: рейтинг (от 1 до 5). 515 | :type rating: :obj:`int`, опционально 516 | 517 | :return: ответ FunPay (HTML-код блока отзыва). 518 | :rtype: :obj:`str` 519 | """ 520 | if not self.is_initiated: 521 | raise exceptions.AccountNotInitiatedError() 522 | 523 | headers = { 524 | "accept": "*/*", 525 | "x-requested-with": "XMLHttpRequest" 526 | } 527 | payload = { 528 | "authorId": self.id, 529 | "text": text, 530 | "rating": rating, 531 | "csrf_token": self.csrf_token, 532 | "orderId": order_id 533 | } 534 | 535 | response = self.method("post", "orders/review", headers, payload) 536 | if response.status_code == 400: 537 | json_response = response.json() 538 | msg = json_response.get("msg") 539 | raise exceptions.FeedbackEditingError(response, msg, order_id) 540 | elif response.status_code != 200: 541 | raise exceptions.RequestFailedError(response) 542 | 543 | return response.json().get("content") 544 | 545 | def delete_review(self, order_id: str) -> str: 546 | """ 547 | Удаляет отзыв / ответ на отзыв. 548 | 549 | :param order_id: ID заказа. 550 | :type order_id: :obj:`str` 551 | 552 | :return: ответ FunPay (HTML-код блока отзыва). 553 | :rtype: :obj:`str` 554 | """ 555 | if not self.is_initiated: 556 | raise exceptions.AccountNotInitiatedError() 557 | 558 | headers = { 559 | "accept": "*/*", 560 | "x-requested-with": "XMLHttpRequest" 561 | } 562 | payload = { 563 | "authorId": self.id, 564 | "csrf_token": self.csrf_token, 565 | "orderId": order_id 566 | } 567 | 568 | response = self.method("post", "orders/reviewDelete", headers, payload) 569 | 570 | if response.status_code == 400: 571 | json_response = response.json() 572 | msg = json_response.get("msg") 573 | raise exceptions.FeedbackEditingError(response, msg, order_id) 574 | elif response.status_code != 200: 575 | raise exceptions.RequestFailedError(response) 576 | 577 | return response.json().get("content") 578 | 579 | def refund(self, order_id): 580 | """ 581 | Оформляет возврат средств за заказ. 582 | 583 | :param order_id: ID заказа. 584 | :type order_id: :obj:`str` 585 | """ 586 | if not self.is_initiated: 587 | raise exceptions.AccountNotInitiatedError() 588 | 589 | headers = { 590 | "accept": "*/*", 591 | "content-type": "application/x-www-form-urlencoded; charset=UTF-8", 592 | "x-requested-with": "XMLHttpRequest", 593 | } 594 | 595 | payload = { 596 | "id": order_id, 597 | "csrf_token": self.csrf_token 598 | } 599 | 600 | response = self.method("post", "orders/refund", headers, payload, raise_not_200=True) 601 | 602 | if response.json().get("error"): 603 | raise exceptions.RefundError(response, response.json().get("msg"), order_id) 604 | 605 | def withdraw(self, currency: enums.Currency, wallet: enums.Wallet, amount: int | float, address: str) -> float: 606 | """ 607 | Отправляет запрос на вывод средств. 608 | 609 | :param currency: валюта. 610 | :type currency: :class:`FunPayAPI.common.enums.Currency` 611 | 612 | :param wallet: тип кошелька. 613 | :type wallet: :class:`FunPayAPI.common.enums.Wallet` 614 | 615 | :param amount: кол-во средств. 616 | :type amount: :obj:`int` or :obj:`float` 617 | 618 | :param address: адрес кошелька. 619 | :type address: :obj:`str` 620 | 621 | :return: кол-во выведенных средств с учетом комиссии FunPay. 622 | :rtype: :obj:`float` 623 | """ 624 | if not self.is_initiated: 625 | raise exceptions.AccountNotInitiatedError() 626 | 627 | currencies = { 628 | enums.Currency.RUB: "rub", 629 | enums.Currency.USD: "usd", 630 | enums.Currency.EUR: "eur" 631 | } 632 | 633 | wallets = { 634 | enums.Wallet.QIWI: "qiwi", 635 | enums.Wallet.YOUMONEY: "fps", 636 | enums.Wallet.BINANCE: "binance", 637 | enums.Wallet.TRC: "usdt_trc", 638 | enums.Wallet.CARD_RUB: "card_rub", 639 | enums.Wallet.CARD_USD: "card_usd", 640 | enums.Wallet.CARD_EUR: "card_eur", 641 | enums.Wallet.WEBMONEY: "wmz" 642 | } 643 | headers = { 644 | "accept": "*/*", 645 | "x-requested-with": "XMLHttpRequest" 646 | } 647 | payload = { 648 | "csrf_token": self.csrf_token, 649 | "currency_id": currencies[currency], 650 | "ext_currency_id": wallets[wallet], 651 | "wallet": address, 652 | "amount_int": str(amount) 653 | } 654 | response = self.method("post", "withdraw/withdraw", headers, payload, raise_not_200=True) 655 | json_response = response.json() 656 | if json_response.get("error"): 657 | error_message = json_response.get("msg") 658 | raise exceptions.WithdrawError(response, error_message) 659 | return float(json_response.get("amount_ext")) 660 | 661 | def get_raise_modal(self, category_id: int) -> dict: 662 | """ 663 | Отправляет запрос на получение modal-формы для поднятия лотов категории (игры). 664 | !ВНИМАНИЕ! Если на аккаунте только 1 подкатегория, относящаяся переданной категории (игре), 665 | то FunPay поднимет лоты данной подкатегории без отправления modal-формы с выбором других подкатегорий. 666 | 667 | :param category_id: ID категории (игры). 668 | :type category_id: :obj:`int` 669 | 670 | :return: ответ FunPay. 671 | :rtype: :obj:`dict` 672 | """ 673 | if not self.is_initiated: 674 | raise exceptions.AccountNotInitiatedError() 675 | category = self.get_category(category_id) 676 | subcategory = category.get_subcategories()[0] 677 | headers = { 678 | "accept": "*/*", 679 | "content-type": "application/x-www-form-urlencoded; charset=UTF-8", 680 | "x-requested-with": "XMLHttpRequest" 681 | } 682 | payload = { 683 | "game_id": category_id, 684 | "node_id": subcategory.id 685 | } 686 | response = self.method("post", "https://funpay.com/lots/raise", headers, payload, raise_not_200=True) 687 | json_response = response.json() 688 | return json_response 689 | 690 | def raise_lots(self, category_id: int, subcategories: Optional[list[int | types.SubCategory]] = None, 691 | exclude: list[int] | None = None) -> bool: 692 | """ 693 | Поднимает все лоты всех подкатегорий переданной категории (игры). 694 | 695 | :param category_id: ID категории (игры). 696 | :type category_id: :obj:`int` 697 | 698 | :param subcategories: список подкатегорий, которые необходимо поднять. Если не указаны, поднимутся все 699 | подкатегории переданной категории. 700 | :type subcategories: :obj:`list` of :obj:`int` or :class:`FunPayAPI.types.SubCategory` 701 | 702 | :param exclude: ID подкатегорий, которые не нужно поднимать. 703 | :type exclude: :obj:`list` of :obj:`int`, опционально. 704 | 705 | :return: `True` 706 | :rtype: :obj:`bool` 707 | """ 708 | if not self.is_initiated: 709 | raise exceptions.AccountNotInitiatedError() 710 | if not (category := self.get_category(category_id)): 711 | raise Exception("Not Found") # todo 712 | 713 | exclude = exclude or [] 714 | if subcategories: 715 | subcats = [] 716 | for i in subcategories: 717 | if isinstance(i, types.SubCategory): 718 | if i.type is types.SubCategoryTypes.COMMON and i.category.id == category.id and i.id not in exclude: 719 | subcats.append(i) 720 | else: 721 | if not (subcat := category.get_subcategory(types.SubCategoryTypes.COMMON, i)): 722 | continue 723 | subcats.append(subcat) 724 | else: 725 | subcats = [i for i in category.get_subcategories() if i.type is types.SubCategoryTypes.COMMON and i.id not in exclude] 726 | 727 | headers = { 728 | "accept": "*/*", 729 | "content-type": "application/x-www-form-urlencoded; charset=UTF-8", 730 | "x-requested-with": "XMLHttpRequest" 731 | } 732 | payload = { 733 | "game_id": category_id, 734 | "node_id": subcats[0].id, 735 | "node_ids[]": [i.id for i in subcats] 736 | } 737 | 738 | response = self.method("post", "lots/raise", headers, payload, raise_not_200=True) 739 | json_response = response.json() 740 | logger.debug(f"Ответ FunPay (поднятие категорий): {json_response}.") 741 | if not json_response.get("error"): 742 | return True 743 | elif json_response.get("error") and json_response.get("msg") and "Подождите" in json_response.get("msg"): 744 | wait_time = utils.parse_wait_time(json_response.get("msg")) 745 | raise exceptions.RaiseError(response, category, json_response.get("MSG"), wait_time) 746 | else: 747 | raise exceptions.RaiseError(response, category, None, None) 748 | 749 | def get_user(self, user_id: int) -> types.UserProfile: 750 | """ 751 | Парсит страницу пользователя. 752 | 753 | :param user_id: ID пользователя. 754 | :type user_id: :obj:`int` 755 | 756 | :return: объект профиля пользователя. 757 | :rtype: :class:`FunPayAPI.types.UserProfile` 758 | """ 759 | if not self.is_initiated: 760 | raise exceptions.AccountNotInitiatedError() 761 | 762 | response = self.method("get", f"users/{user_id}/", {"accept": "*/*"}, {}, raise_not_200=True) 763 | html_response = response.content.decode() 764 | parser = BeautifulSoup(html_response, "html.parser") 765 | 766 | username = parser.find("div", {"class": "user-link-name"}) 767 | if not username: 768 | raise exceptions.UnauthorizedError(response) 769 | 770 | username = parser.find("span", {"class": "mr4"}).text 771 | user_status = parser.find("span", {"class": "media-user-status"}) 772 | user_status = user_status.text if user_status else "" 773 | avatar_link = parser.find("div", {"class": "avatar-photo"}).get("style").split("(")[1].split(")")[0] 774 | avatar_link = avatar_link if avatar_link.startswith("https") else f"https://funpay.com{avatar_link}" 775 | banned = bool(parser.find("span", {"class": "label label-danger"})) 776 | user_obj = types.UserProfile(user_id, username, avatar_link, "Онлайн" in user_status, banned, html_response) 777 | 778 | subcategories_divs = parser.find_all("div", {"class": "offer-list-title-container"}) 779 | 780 | if not subcategories_divs: 781 | return user_obj 782 | 783 | for i in subcategories_divs: 784 | subcategory_link = i.find("h3").find("a").get("href") 785 | subcategory_id = int(subcategory_link.split("/")[-2]) 786 | subcategory_type = types.SubCategoryTypes.CURRENCY if "chips" in subcategory_link else \ 787 | types.SubCategoryTypes.COMMON 788 | subcategory_obj = self.get_subcategory(subcategory_type, subcategory_id) 789 | if not subcategory_obj: 790 | continue 791 | 792 | offers = i.parent.find_all("a", {"class": "tc-item"}) 793 | for j in offers: 794 | offer_id = j["href"].split("id=")[1] 795 | description = j.find("div", {"class": "tc-desc-text"}) 796 | description = description.text if description else None 797 | server = j.find("div", {"class": "tc-server hidden-xxs"}) 798 | if not server: 799 | server = j.find("div", {"class": "tc-server hidden-xs"}) 800 | server = server.text if server else None 801 | 802 | if subcategory_obj.type is types.SubCategoryTypes.COMMON: 803 | price = float(j.find("div", {"class": "tc-price"})["data-s"]) 804 | else: 805 | price = float(j.find("div", {"class": "tc-price"}).find("div").text.split(" ")[0]) 806 | 807 | lot_obj = types.LotShortcut(offer_id, server, description, price, subcategory_obj, str(j)) 808 | user_obj.add_lot(lot_obj) 809 | return user_obj 810 | 811 | def get_chat(self, chat_id: int) -> types.Chat: 812 | """ 813 | Получает информацию о личном чате. 814 | 815 | :param chat_id: ID чата. 816 | :type chat_id: :obj:`int` 817 | 818 | :return: объект чата. 819 | :rtype: :class:`FunPayAPI.types.Chat` 820 | """ 821 | if not self.is_initiated: 822 | raise exceptions.AccountNotInitiatedError() 823 | 824 | response = self.method("get", f"chat/?node={chat_id}", {"accept": "*/*"}, {}, raise_not_200=True) 825 | html_response = response.content.decode() 826 | parser = BeautifulSoup(html_response, "html.parser") 827 | if (name := parser.find("div", {"class": "chat-header"}).find("div", {"class": "media-user-name"}).find("a").text) == "Чат": 828 | raise Exception("chat not found") # todo 829 | 830 | if not (chat_panel := parser.find("div", {"class": "param-item chat-panel"})): 831 | text, link = None, None 832 | else: 833 | a = chat_panel.find("a") 834 | text, link = a.text, a["href"] 835 | 836 | history = self.get_chat_history(chat_id, interlocutor_username=name) 837 | return types.Chat(chat_id, name, link, text, html_response, history) 838 | 839 | def get_order(self, order_id: str) -> types.Order: 840 | """ 841 | Получает полную информацию о заказе. 842 | 843 | :param order_id: ID заказа. 844 | :type order_id: :obj:`str` 845 | 846 | :return: объекст заказа. 847 | :rtype: :class:`FunPayAPI.types.Order` 848 | """ 849 | if not self.is_initiated: 850 | raise exceptions.AccountNotInitiatedError() 851 | headers = { 852 | "accept": "*/*" 853 | } 854 | response = self.method("get", f"orders/{order_id}/", headers, {}, raise_not_200=True) 855 | html_response = response.content.decode() 856 | parser = BeautifulSoup(html_response, "html.parser") 857 | username = parser.find("div", {"class": "user-link-name"}) 858 | if not username: 859 | raise exceptions.UnauthorizedError(response) 860 | 861 | if (span := parser.find("span", {"class": "text-warning"})) and span.text == "Возврат": 862 | status = types.OrderStatuses.REFUNDED 863 | elif (span := parser.find("span", {"class": "text-success"})) and span.text == "Закрыт": 864 | status = types.OrderStatuses.CLOSED 865 | else: 866 | status = types.OrderStatuses.PAID 867 | 868 | short_description = None 869 | full_description = None 870 | sum_ = None 871 | subcategory = None 872 | for div in parser.find_all("div", {"class": "param-item"}): 873 | if not (h := div.find("h5")): 874 | continue 875 | if h.text == "Краткое описание": 876 | short_description = div.find("div").text 877 | elif h.text == "Подробное описание": 878 | full_description = div.find("div").text 879 | elif h.text == "Сумма": 880 | sum_ = float(div.find("span").text) 881 | elif h.text == "Категория": 882 | subcategory_link = div.find("a").get("href") 883 | subcategory_split = subcategory_link.split("/") 884 | subcategory_id = int(subcategory_split[-2]) 885 | subcategory_type = types.SubCategoryTypes.COMMON if "lots" in subcategory_link else \ 886 | types.SubCategoryTypes.CURRENCY 887 | subcategory = self.get_subcategory(subcategory_type, subcategory_id) 888 | 889 | chat = parser.find("div", {"class": "chat-header"}) 890 | chat_link = chat.find("div", {"class": "media-user-name"}).find("a") 891 | interlocutor_name = chat_link.text 892 | interlocutor_id = int(chat_link.get("href").split("/")[-2]) 893 | nav_bar = parser.find("ul", {"class": "nav navbar-nav navbar-right logged"}) 894 | active_item = nav_bar.find("li", {"class": "active"}) 895 | if "Продажи" in active_item.find("a").text.strip(): 896 | buyer_id, buyer_username = interlocutor_id, interlocutor_name 897 | seller_id, seller_username = self.id, self.username 898 | else: 899 | buyer_id, buyer_username = self.id, self.username 900 | seller_id, seller_username = interlocutor_id, interlocutor_name 901 | 902 | review_obj = parser.find("div", {"class": "order-review"}) 903 | if not (stars_obj := review_obj.find("div", {"class": "rating"})): 904 | stars, text, = None, None 905 | else: 906 | stars = int(stars_obj.find("div").get("class")[0].split("rating")[1]) 907 | text = review_obj.find("div", {"class": "review-item-text"}).text.strip() 908 | 909 | if not (reply_obj := review_obj.find("div", {"class": "review-item-answer review-compiled-reply"})): 910 | reply = None 911 | else: 912 | reply = reply_obj.find("div").text.strip() 913 | 914 | if all([not text, not reply]): 915 | review = None 916 | else: 917 | review = types.Review(stars, text, reply, False, str(reply_obj), order_id, buyer_username, buyer_id) 918 | 919 | order = types.Order(order_id, status, subcategory, short_description, full_description, sum_, 920 | buyer_id, buyer_username, seller_id, seller_username, html_response, review) 921 | return order 922 | 923 | def get_sells(self, start_from: str | None = None, include_paid: bool = True, include_closed: bool = True, 924 | include_refunded: bool = True, exclude_ids: list[str] | None = None, 925 | id: Optional[int] = None, buyer: Optional[str] = None, 926 | state: Optional[Literal["closed", "paid", "refunded"]] = None, game: Optional[int] = None, 927 | section: Optional[str] = None, server: Optional[int] = None, 928 | side: Optional[int] = None, **more_filters) -> tuple[str | None, list[types.OrderShortcut]]: 929 | """ 930 | Получает и парсит список заказов со страницы https://funpay.com/orders/trade 931 | 932 | :param start_from: ID заказа, с которого начать список (ID заказа должен быть без '#'!). 933 | :type start_from: :obj:`str` 934 | 935 | :param include_paid: включить ли в список заказы, ожидающие выполнения? 936 | :type include_paid: :obj:`bool`, опционально 937 | 938 | :param include_closed: включить ли в список закрытые заказы? 939 | :type include_closed: :obj:`bool`, опционально 940 | 941 | :param include_refunded: включить ли в список заказы, за которые запрошен возврат средств? 942 | :type include_refunded: :obj:`bool`, опционально 943 | 944 | :param exclude_ids: исключить заказы с ID из списка (ID заказа должен быть без '#'!). 945 | :type exclude_ids: :obj:`list` of :obj:`str`, опционально 946 | 947 | :param id: ID заказа. 948 | :type id: :obj:`int`, опционально 949 | 950 | :param buyer: никнейм покупателя. 951 | :type buyer: :obj:`str`, опционально 952 | 953 | :param state: статус заказа. 954 | :type: :obj:`str` `paid`, `closed` or `refunded`, опционально 955 | 956 | :param game: ID игры. 957 | :type game: :obj:`int`, опционально 958 | 959 | :param section: ID категории в формате `<тип лота>-`.\n 960 | Типы лотов:\n 961 | * `lot` - стандартный лот (например: `lot-256`)\n 962 | * `chip` - игровая валюта (например: `chip-4471`)\n 963 | :type section: :obj:`str`, опционально 964 | 965 | :param server: ID сервера. 966 | :type server: :obj:`int`, опционально 967 | 968 | :param side: ID стороны (платформы). 969 | :type side: :obj:`int`, опционально. 970 | 971 | :param more_filters: доп. фильтры. 972 | 973 | :return: (ID след. заказа (для start_from), список заказов) 974 | :rtype: :obj:`tuple` (:obj:`str` or :obj:`None`, :obj:`list` of :class:`FunPayAPI.types.OrderShortcut`) 975 | """ 976 | if not self.is_initiated: 977 | raise exceptions.AccountNotInitiatedError() 978 | 979 | exclude_ids = exclude_ids or [] 980 | filters = {"id": id, "buyer": buyer, "state": state, "game": game, "section": section, "server": server, 981 | "side": side} 982 | filters = {name: filters[name] for name in filters if filters[name]} 983 | filters.update(more_filters) 984 | 985 | link = "https://funpay.com/orders/trade?" 986 | for name in filters: 987 | link += f"{name}={filters[name]}&" 988 | link = link[:-1] 989 | 990 | if start_from: 991 | filters["continue"] = start_from 992 | 993 | response = self.method("post" if start_from else "get", link, {}, filters, raise_not_200=True) 994 | html_response = response.content.decode() 995 | 996 | parser = BeautifulSoup(html_response, "html.parser") 997 | check_user = parser.find("div", {"class": "content-account content-account-login"}) 998 | if check_user: 999 | raise exceptions.UnauthorizedError(response) 1000 | 1001 | next_order_id = parser.find("input", {"type": "hidden", "name": "continue"}) 1002 | next_order_id = next_order_id.get("value") if next_order_id else None 1003 | 1004 | order_divs = parser.find_all("a", {"class": "tc-item"}) 1005 | if not order_divs: 1006 | return None, [] 1007 | 1008 | sells = [] 1009 | for div in order_divs: 1010 | classname = div.get("class") 1011 | if "warning" in classname: 1012 | if not include_refunded: 1013 | continue 1014 | order_status = types.OrderStatuses.REFUNDED 1015 | elif "info" in classname: 1016 | if not include_paid: 1017 | continue 1018 | order_status = types.OrderStatuses.PAID 1019 | else: 1020 | if not include_closed: 1021 | continue 1022 | order_status = types.OrderStatuses.CLOSED 1023 | 1024 | order_id = div.find("div", {"class": "tc-order"}).text[1:] 1025 | if order_id in exclude_ids: 1026 | continue 1027 | 1028 | description = div.find("div", {"class": "order-desc"}).find("div").text 1029 | price = float(div.find("div", {"class": "tc-price"}).text.split(" ")[0]) 1030 | 1031 | buyer_div = div.find("div", {"class": "media-user-name"}).find("span") 1032 | buyer_username = buyer_div.text 1033 | buyer_id = int(buyer_div.get("data-href")[:-1].split("https://funpay.com/users/")[1]) 1034 | subcategory_name = div.find("div", {"class": "text-muted"}).text 1035 | 1036 | now = datetime.now() 1037 | order_date_text = div.find("div", {"class": "tc-date-time"}).text 1038 | if "сегодня" in order_date_text: # сегодня, ЧЧ:ММ 1039 | h, m = order_date_text.split(", ")[1].split(":") 1040 | order_date = datetime(now.year, now.month, now.day, int(h), int(m)) 1041 | elif "вчера" in order_date_text: # вчера, ЧЧ:ММ 1042 | h, m = order_date_text.split(", ")[1].split(":") 1043 | temp = now - timedelta(days=1) 1044 | order_date = datetime(temp.year, temp.month, temp.day, int(h), int(m)) 1045 | elif order_date_text.count(" ") == 2: # ДД месяца, ЧЧ:ММ 1046 | split = order_date_text.split(", ") 1047 | day, month = split[0].split() 1048 | day, month = int(day), utils.MONTHS[month] 1049 | h, m = split[1].split(":") 1050 | order_date = datetime(now.year, month, day, int(h), int(m)) 1051 | else: # ДД месяца ГГГГ, ЧЧ:ММ 1052 | split = order_date_text.split(", ") 1053 | day, month, year = split[0].split() 1054 | day, month, year = int(day), utils.MONTHS[month], int(year) 1055 | h, m = split[1].split(":") 1056 | order_date = datetime(year, month, day, int(h), int(m)) 1057 | 1058 | order_obj = types.OrderShortcut(order_id, description, price, buyer_username, buyer_id, order_status, 1059 | order_date, subcategory_name, str(div)) 1060 | sells.append(order_obj) 1061 | 1062 | return next_order_id, sells 1063 | 1064 | def add_chats(self, chats: list[types.ChatShortcut]): 1065 | """ 1066 | Сохраняет чаты. 1067 | 1068 | :param chats: объекты чатов. 1069 | :type chats: :obj:`list` of :class:`FunPayAPI.types.ChatShortcut` 1070 | """ 1071 | for i in chats: 1072 | self.__saved_chats[i.id] = i 1073 | 1074 | def request_chats(self) -> list[types.ChatShortcut]: 1075 | """ 1076 | Запрашивает чаты и парсит их. 1077 | 1078 | :return: объекты чатов (не больше 50). 1079 | :rtype: :obj:`list` of :class:`FunPayAPI.types.ChatShortcut` 1080 | """ 1081 | chats = { 1082 | "type": "chat_bookmarks", 1083 | "id": self.id, 1084 | "tag": utils.random_tag(), 1085 | "data": False 1086 | } 1087 | payload = { 1088 | "objects": json.dumps([chats]), 1089 | "request": False, 1090 | "csrf_token": self.csrf_token 1091 | } 1092 | headers = { 1093 | "accept": "*/*", 1094 | "content-type": "application/x-www-form-urlencoded; charset=UTF-8", 1095 | "x-requested-with": "XMLHttpRequest" 1096 | } 1097 | response = self.method("post", "https://funpay.com/runner/", headers, payload, raise_not_200=True) 1098 | json_response = response.json() 1099 | 1100 | msgs = "" 1101 | for obj in json_response["objects"]: 1102 | if obj.get("type") != "chat_bookmarks": 1103 | continue 1104 | msgs = obj["data"]["html"] 1105 | if not msgs: 1106 | return [] 1107 | 1108 | parser = BeautifulSoup(msgs, "html.parser") 1109 | chats = parser.find_all("a", {"class": "contact-item"}) 1110 | chats_objs = [] 1111 | 1112 | for msg in chats: 1113 | chat_id = int(msg["data-id"]) 1114 | last_msg_text = msg.find("div", {"class": "contact-item-message"}).text 1115 | unread = True if "unread" in msg.get("class") else False 1116 | chat_with = msg.find("div", {"class": "media-user-name"}).text 1117 | chat_obj = types.ChatShortcut(chat_id, chat_with, last_msg_text, unread, str(msg)) 1118 | chats_objs.append(chat_obj) 1119 | return chats_objs 1120 | 1121 | def get_chats(self, update: bool = False) -> dict[int, types.ChatShortcut]: 1122 | """ 1123 | Возвращает словарь с сохраненными чатами ({id: types.ChatShortcut}) 1124 | 1125 | :param update: обновлять ли предварительно список чатов с помощью доп. запроса? 1126 | :type update: :obj:`bool`, опционально 1127 | 1128 | :return: словарь с сохраненными чатами. 1129 | :rtype: :obj:`dict` {:obj:`int`: :class:`FunPayAPi.types.ChatShortcut`} 1130 | """ 1131 | if not self.is_initiated: 1132 | raise exceptions.AccountNotInitiatedError() 1133 | if update: 1134 | chats = self.request_chats() 1135 | self.add_chats(chats) 1136 | return self.__saved_chats 1137 | 1138 | def get_chat_by_name(self, name: str, make_request: bool = False) -> types.ChatShortcut | None: 1139 | """ 1140 | Возвращает чат по его названию (если он сохранен). 1141 | 1142 | :param name: название чата. 1143 | :type name: :obj:`str` 1144 | 1145 | :param make_request: обновить ли сохраненные чаты, если чат не был найден? 1146 | :type make_request: :obj:`bool`, опционально 1147 | 1148 | :return: объект чата или :obj:`None`, если чат не был найден. 1149 | :rtype: :class:`FunPayAPI.types.ChatShortcut` or :obj:`None` 1150 | """ 1151 | if not self.is_initiated: 1152 | raise exceptions.AccountNotInitiatedError() 1153 | 1154 | for i in self.__saved_chats: 1155 | if self.__saved_chats[i].name == name: 1156 | return self.__saved_chats[i] 1157 | 1158 | if make_request: 1159 | self.add_chats(self.request_chats()) 1160 | return self.get_chat_by_name(name) 1161 | else: 1162 | return None 1163 | 1164 | def get_chat_by_id(self, chat_id: int, make_request: bool = False) -> types.ChatShortcut | None: 1165 | """ 1166 | Возвращает личный чат по его ID (если он сохранен). 1167 | 1168 | :param chat_id: ID чата. 1169 | :type chat_id: :obj:`int` 1170 | 1171 | :param make_request: обновить ли сохраненные чаты, если чат не был найден? 1172 | :type make_request: :obj:`bool`, опционально 1173 | 1174 | :return: объект чата или :obj:`None`, если чат не был найден. 1175 | :rtype: :class:`FunPayAPI.types.ChatShortcut` or :obj:`None` 1176 | """ 1177 | if not self.is_initiated: 1178 | raise exceptions.AccountNotInitiatedError() 1179 | 1180 | if not make_request or chat_id in self.__saved_chats: 1181 | return self.__saved_chats.get(chat_id) 1182 | 1183 | self.add_chats(self.request_chats()) 1184 | return self.get_chat_by_id(chat_id) 1185 | 1186 | def get_lot_fields(self, lot_id: int) -> types.LotFields: 1187 | """ 1188 | Получает все поля лота. 1189 | 1190 | :param lot_id: ID лота. 1191 | :type lot_id: :obj:`int` 1192 | 1193 | :return: объект с полями лота. 1194 | :rtype: :class:`FunPayAPI.types.LotFields` 1195 | """ 1196 | if not self.is_initiated: 1197 | raise exceptions.AccountNotInitiatedError() 1198 | headers = { 1199 | "accept": "*/*", 1200 | "content-type": "application/json", 1201 | "x-requested-with": "XMLHttpRequest", 1202 | } 1203 | response = self.method("get", f"lots/offerEdit?offer={lot_id}", headers, {}, raise_not_200=True) 1204 | 1205 | json_response = response.json() 1206 | bs = BeautifulSoup(json_response["html"], "html.parser") 1207 | 1208 | result = {"active": "", "deactivate_after_sale": ""} 1209 | result.update({field["name"]: field.get("value") or "" for field in bs.find_all("input") 1210 | if field["name"] not in ["active", "deactivate_after_sale"]}) 1211 | result.update({field["name"]: field.text or "" for field in bs.find_all("textarea")}) 1212 | result.update({field["name"]: field.find("option", selected=True)["value"] for field in bs.find_all("select")}) 1213 | result.update({field["name"]: "on" for field in bs.find_all("input", {"type": "checkbox"}, checked=True)}) 1214 | return types.LotFields(lot_id, result) 1215 | 1216 | def save_lot(self, lot_fields: types.LotFields): 1217 | """ 1218 | Сохраняет лот на FunPay. 1219 | 1220 | :param lot_fields: объект с полями лота. 1221 | :type lot_fields: :class:`FunPayAPI.types.LotFields` 1222 | """ 1223 | if not self.is_initiated: 1224 | raise exceptions.AccountNotInitiatedError() 1225 | headers = { 1226 | "accept": "*/*", 1227 | "content-type": "application/x-www-form-urlencoded; charset=UTF-8", 1228 | "x-requested-with": "XMLHttpRequest", 1229 | } 1230 | fields = lot_fields.renew_fields().fields 1231 | fields["location"] = "trade" 1232 | 1233 | response = self.method("post", "lots/offerSave", headers, fields, raise_not_200=True) 1234 | json_response = response.json() 1235 | if json_response.get("error"): 1236 | raise exceptions.LotSavingError(response, json_response.get("error"), lot_fields.lot_id) 1237 | 1238 | def get_category(self, category_id: int) -> types.Category | None: 1239 | """ 1240 | Возвращает объект категории (игры). 1241 | 1242 | :param category_id: ID категории (игры). 1243 | :type category_id: :obj:`int` 1244 | 1245 | :return: объект категории (игры) или :obj:`None`, если категория не была найдена. 1246 | :rtype: :class:`FunPayAPI.types.Category` or :obj:`None` 1247 | """ 1248 | return self.__sorted_categories.get(category_id) 1249 | 1250 | @property 1251 | def categories(self) -> list[types.Category]: 1252 | """ 1253 | Возвращает все категории (игры) FunPay (парсятся при первом выполнении метода :meth:`FunPayAPI.account.Account.get`). 1254 | 1255 | :return: все категории (игры) FunPay. 1256 | :rtype: :obj:`list` of :class:`FunPayAPI.types.Category` 1257 | """ 1258 | return self.__categories 1259 | 1260 | def get_sorted_categories(self) -> dict[int, types.Category]: 1261 | """ 1262 | Возвращает все категории (игры) FunPay в виде словаря {ID: категория} 1263 | (парсятся при первом выполнении метода :meth:`FunPayAPI.account.Account.get`). 1264 | 1265 | :return: все категории (игры) FunPay в виде словаря {ID: категория} 1266 | :rtype: :obj:`dict` {:obj:`int`: :class:`FunPayAPI.types.Category`} 1267 | """ 1268 | return self.__sorted_categories 1269 | 1270 | def get_subcategory(self, subcategory_type: types.SubCategoryTypes, 1271 | subcategory_id: int) -> types.SubCategory | None: 1272 | """ 1273 | Возвращает объект подкатегории. 1274 | 1275 | :param subcategory_type: тип подкатегории. 1276 | :type subcategory_type: :class:`FunPayAPI.common.enums.SubCategoryTypes` 1277 | 1278 | :param subcategory_id: ID подкатегории. 1279 | :type subcategory_id: :obj:`int` 1280 | 1281 | :return: объект подкатегории или :obj:`None`, если подкатегория не была найдена. 1282 | :rtype: :class:`FunPayAPI.types.SubCategory` or :obj:`None` 1283 | """ 1284 | return self.__sorted_subcategories[subcategory_type].get(subcategory_id) 1285 | 1286 | @property 1287 | def subcategories(self) -> list[types.SubCategory]: 1288 | """ 1289 | Возвращает все подкатегории FunPay (парсятся при первом выполнении метода Account.get). 1290 | 1291 | :return: все подкатегории FunPay. 1292 | :rtype: :obj:`list` of :class:`FunPayAPI.types.SubCategory` 1293 | """ 1294 | return self.__subcategories 1295 | 1296 | def get_sorted_subcategories(self) -> dict[types.SubCategoryTypes, dict[int, types.SubCategory]]: 1297 | """ 1298 | Возвращает все подкатегории FunPay в виде словаря {тип подкатегории: {ID: подкатегория}} 1299 | (парсятся при первом выполнении метода Account.get). 1300 | 1301 | :return: все подкатегории FunPay в виде словаря {тип подкатегории: {ID: подкатегория}} 1302 | :rtype: :obj:`dict` {:class:`FunPayAPI.common.enums.SubCategoryTypes`: :obj:`dict` {:obj:`int` :class:`FunPayAPI.types.SubCategory`}} 1303 | """ 1304 | return self.__sorted_subcategories 1305 | 1306 | @property 1307 | def is_initiated(self) -> bool: 1308 | """ 1309 | Инициализирован ли класс :class:`FunPayAPI.account.Account` с помощью метода :meth:`FunPayAPI.account.Account.get`? 1310 | 1311 | :return: :obj:`True`, если да, :obj:`False`, если нет. 1312 | :rtype: :obj:`bool` 1313 | """ 1314 | return self.__initiated 1315 | 1316 | def __setup_categories(self, html: str): 1317 | """ 1318 | Парсит категории и подкатегории с основной страницы и добавляет их в свойства класса. 1319 | 1320 | :param html: HTML страница. 1321 | """ 1322 | parser = BeautifulSoup(html, "html.parser") 1323 | games_table = parser.find_all("div", {"class": "promo-game-list"}) 1324 | if not games_table: 1325 | return 1326 | 1327 | games_table = games_table[1] if len(games_table) > 1 else games_table[0] 1328 | games_divs = games_table.find_all("div", {"class": "promo-game-item"}) 1329 | if not games_divs: 1330 | return 1331 | 1332 | for i in games_divs: 1333 | game_id = int(i.find("div", {"class": "game-title"}).get("data-id")) 1334 | game_title = i.find("a").text 1335 | game_obj = types.Category(game_id, game_title) 1336 | 1337 | subcategories_divs = i.find_all("li") 1338 | for j in subcategories_divs: 1339 | subcategory_name = j.find("a").text 1340 | link = j.find("a").get("href") 1341 | subcategory_type = types.SubCategoryTypes.CURRENCY if "chips" in link else types.SubCategoryTypes.COMMON 1342 | subcategory_id = int(link.split("/")[-2]) 1343 | 1344 | subcategory_obj = types.SubCategory(subcategory_id, subcategory_name, subcategory_type, game_obj) 1345 | game_obj.add_subcategory(subcategory_obj) 1346 | self.__subcategories.append(subcategory_obj) 1347 | self.__sorted_subcategories[subcategory_type][subcategory_id] = subcategory_obj 1348 | 1349 | self.__categories.append(game_obj) 1350 | self.__sorted_categories[game_id] = game_obj 1351 | 1352 | def __parse_messages(self, json_messages: dict, chat_id: int | str, 1353 | interlocutor_id: Optional[int] = None, interlocutor_username: Optional[str] = None, 1354 | from_id: int = 0) -> list[types.Message]: 1355 | messages = [] 1356 | ids = {self.id: self.username, 0: "FunPay"} 1357 | badges = {} 1358 | if interlocutor_id is not None: 1359 | ids[interlocutor_id] = interlocutor_username 1360 | 1361 | for i in json_messages: 1362 | if i["id"] < from_id: 1363 | continue 1364 | author_id = i["author"] 1365 | parser = BeautifulSoup(i["html"], "html.parser") 1366 | 1367 | # Если ник или бейдж написавшего неизвестен, но есть блок с данными об авторе сообщения 1368 | if None in [ids.get(author_id), badges.get(author_id)] and (author_div := parser.find("div", {"class": "media-user-name"})): 1369 | if badges.get(author_id) is None: 1370 | badge = author_div.find("span") 1371 | badges[author_id] = badge.text if badge else 0 1372 | if ids.get(author_id) is None: 1373 | author = author_div.find("a").text.strip() 1374 | ids[author_id] = author 1375 | if self.chat_id_private(chat_id) and author_id == interlocutor_id and not interlocutor_username: 1376 | interlocutor_username = author 1377 | ids[interlocutor_id] = interlocutor_username 1378 | 1379 | if self.chat_id_private and (image_link := parser.find("a", {"class": "chat-img-link"})): 1380 | image_link = image_link.get("href") 1381 | message_text = None 1382 | else: 1383 | image_link = None 1384 | if author_id == 0: 1385 | message_text = parser.find("div", {"class": "alert alert-with-icon alert-info"}).text.strip() 1386 | else: 1387 | message_text = parser.find("div", {"class": "message-text"}).text 1388 | 1389 | by_bot = False 1390 | if not image_link and message_text.startswith(self.__bot_character): 1391 | message_text = message_text.replace(self.__bot_character, "", 1) 1392 | by_bot = True 1393 | 1394 | message_obj = types.Message(i["id"], message_text, chat_id, interlocutor_username, 1395 | None, author_id, i["html"], image_link, determine_msg_type=False) 1396 | message_obj.by_bot = by_bot 1397 | message_obj.type = types.MessageTypes.NON_SYSTEM if author_id != 0 else message_obj.get_message_type() 1398 | messages.append(message_obj) 1399 | 1400 | for i in messages: 1401 | i.author = ids.get(i.author_id) 1402 | i.chat_name = interlocutor_username 1403 | i.badge = badges.get(i.author_id) if badges.get(i.author_id) != 0 else None 1404 | return messages 1405 | 1406 | @staticmethod 1407 | def chat_id_private(chat_id: int | str): 1408 | return isinstance(chat_id, int) or PRIVATE_CHAT_ID_RE.fullmatch(chat_id) 1409 | 1410 | @property 1411 | def bot_character(self) -> str: 1412 | return self.__bot_character 1413 | -------------------------------------------------------------------------------- /FunPayAPI/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LIMBODS/FunPayAPI/adb6848bb015a1aabfa0e87b79f5a54cebbcc663/FunPayAPI/common/__init__.py -------------------------------------------------------------------------------- /FunPayAPI/common/enums.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from enum import Enum 3 | 4 | 5 | class EventTypes(Enum): 6 | """ 7 | В данном классе перечислены все типы событий FunPayAPI. 8 | """ 9 | INITIAL_CHAT = 0 10 | """Обнаружен чат (при первом запросе Runner'а).""" 11 | 12 | CHATS_LIST_CHANGED = 1 13 | """Список чатов и/или последнее сообщение одного/нескольких чатов изменилось.""" 14 | 15 | LAST_CHAT_MESSAGE_CHANGED = 2 16 | """В чате изменилось последнее сообщение.""" 17 | 18 | NEW_MESSAGE = 3 19 | """Обнаружено новое сообщение в истории чата.""" 20 | 21 | INITIAL_ORDER = 4 22 | """Обнаружен заказ (при первом запросе Runner'а).""" 23 | 24 | ORDERS_LIST_CHANGED = 5 25 | """Список заказов и/или статус одного/нескольких заказов изменился.""" 26 | 27 | NEW_ORDER = 6 28 | """Новый заказ.""" 29 | 30 | ORDER_STATUS_CHANGED = 7 31 | """Статус заказа изменился.""" 32 | 33 | 34 | class MessageTypes(Enum): 35 | """ 36 | В данном классе перечислены все типы сообщений. 37 | """ 38 | NON_SYSTEM = 0 39 | """Несистемное сообщение.""" 40 | 41 | ORDER_PURCHASED = 1 42 | """Покупатель X оплатил заказ #Y. Лот. X, не забудьте потом нажать кнопку «Подтвердить выполнение заказа».""" 43 | 44 | ORDER_CONFIRMED = 2 45 | """Покупатель X подтвердил успешное выполнение заказа #Y и отправил деньги продавцу Z.""" 46 | 47 | NEW_FEEDBACK = 3 48 | """Покупатель X написал отзыв к заказу #Y.""" 49 | 50 | FEEDBACK_CHANGED = 4 51 | """Покупатель X изменил отзыв к заказу #Y.""" 52 | 53 | FEEDBACK_DELETED = 5 54 | """Покупатель X удалил отзыв к заказу #Y.""" 55 | 56 | NEW_FEEDBACK_ANSWER = 6 57 | """Продавец Z ответил на отзыв к заказу #Y.""" 58 | 59 | FEEDBACK_ANSWER_CHANGED = 7 60 | """Продавец Z изменил ответ на отзыв к заказу #Y.""" 61 | 62 | FEEDBACK_ANSWER_DELETED = 8 63 | """Продавец Z удалил ответ на отзыв к заказу #Y.""" 64 | 65 | ORDER_REOPENED = 9 66 | """Заказ #Y открыт повторно.""" 67 | 68 | REFUND = 10 69 | """Продавец Z вернул деньги покупателю X по заказу #Y.""" 70 | 71 | PARTIAL_REFUND = 11 72 | """Часть средств по заказу #Y возвращена покупателю.""" 73 | 74 | ORDER_CONFIRMED_BY_ADMIN = 12 75 | """Администратор A подтвердил успешное выполнение заказа #Y и отправил деньги продавцу Z.""" 76 | 77 | DISCORD = 13 78 | """Вы можете перейти в Discord. Внимание: общение за пределами сервера FunPay считается нарушением правил.""" 79 | 80 | 81 | class OrderStatuses(Enum): 82 | """ 83 | В данном классе перечислены все состояния заказов. 84 | """ 85 | PAID = 0 86 | """Заказ оплачен и ожидает выполнения.""" 87 | CLOSED = 1 88 | """Заказ закрыт.""" 89 | REFUNDED = 2 90 | """Средства по заказу возвращены.""" 91 | 92 | 93 | class SubCategoryTypes(Enum): 94 | """ 95 | В данном классе перечислены все типы подкатегорий. 96 | """ 97 | COMMON = 0 98 | """Подкатегория со стандартными лотами.""" 99 | CURRENCY = 1 100 | """Подкатегория с лотами игровой валюты (их нельзя поднимать).""" 101 | 102 | 103 | class Currency(Enum): 104 | """ 105 | В данном классе перечислены все типы валют баланса FunPay. 106 | """ 107 | USD = 0 108 | """Доллар""" 109 | RUB = 1 110 | """Рубль""" 111 | EUR = 2 112 | """Евро""" 113 | 114 | 115 | class Wallet(Enum): 116 | """ 117 | В данном классе перечислены все кошельки для вывода средств с баланса FunPay. 118 | """ 119 | QIWI = 0 120 | """Qiwi кошелек.""" 121 | BINANCE = 1 122 | """Binance Pay.""" 123 | TRC = 2 124 | """USDT TRC20.""" 125 | CARD_RUB = 3 126 | """Рублевая банковская карта.""" 127 | CARD_USD = 4 128 | """Долларовая банковская карта.""" 129 | CARD_EUR = 5 130 | """Евро банковская карта.""" 131 | WEBMONEY = 6 132 | """WebMoney WMZ.""" 133 | YOUMONEY = 7 134 | """ЮMoney.""" 135 | -------------------------------------------------------------------------------- /FunPayAPI/common/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | В данном модуле описаны все кастомные исключения, используемые в пакете FunPayAPI. 3 | """ 4 | import requests 5 | from .. import types 6 | 7 | 8 | class AccountNotInitiatedError(Exception): 9 | """ 10 | Исключение, которое возбуждается, если предпринята попытка вызвать метод класса :class:`FunPayAPI.account.Account` 11 | без предварительного получения данных аккаунта с помощью метода :meth:`FunPayAPI.account.Account.get`. 12 | """ 13 | def __init__(self): 14 | pass 15 | 16 | def __str__(self): 17 | return "Необходимо получить данные об аккаунте с помощью метода Account.get()" 18 | 19 | 20 | class RequestFailedError(Exception): 21 | """ 22 | Исключение, которое возбуждается, если статус код ответа != 200. 23 | """ 24 | def __init__(self, response: requests.Response): 25 | """ 26 | :param response: объект ответа. 27 | """ 28 | self.response = response 29 | self.status_code = response.status_code 30 | self.url = response.request.url 31 | self.request_headers = response.request.headers 32 | if "cookie" in self.request_headers: 33 | self.request_headers["cookie"] = "HIDDEN" 34 | self.request_body = response.request.body 35 | self.log_response = False 36 | 37 | def short_str(self): 38 | return f"Ошибка запроса к {self.url}. (Статус-код: {self.status_code})" 39 | 40 | def __str__(self): 41 | msg = f"Ошибка запроса к {self.url} .\n" \ 42 | f"Метод: {self.response.request.method} .\n" \ 43 | f"Статус-код ответа: {self.status_code} .\n" \ 44 | f"Заголовки запроса: {self.request_headers} .\n" \ 45 | f"Тело запроса: {self.request_body} ." 46 | if self.log_response: 47 | msg += f"\n{self.response.content.decode()}" 48 | return msg 49 | 50 | 51 | class UnauthorizedError(RequestFailedError): 52 | """ 53 | Исключение, которое возбуждается, если не удалось найти идентифицирующий аккаунт элемент и / или произошло другое 54 | событие, указывающее на отсутствие авторизации. 55 | """ 56 | def __init__(self, response): 57 | super(UnauthorizedError, self).__init__(response) 58 | 59 | def short_str(self): 60 | return "Не авторизирован (возможно, введен неверный golden_key?)." 61 | 62 | 63 | class WithdrawError(RequestFailedError): 64 | """ 65 | Исключение, которое возбуждается, если произошла ошибка при попытке вывести средства с аккаунта. 66 | """ 67 | def __init__(self, response, error_message: str | None): 68 | super(WithdrawError, self).__init__(response) 69 | self.error_message = error_message 70 | if not self.error_message: 71 | self.log_response = True 72 | 73 | def short_str(self): 74 | return f"Произошла ошибка при выводе средств с аккаунта{f': {self.error_message}' if self.error_message else '.'}" 75 | 76 | 77 | class RaiseError(RequestFailedError): 78 | """ 79 | Исключение, которое возбуждается, если произошла ошибка при попытке поднять лоты. 80 | """ 81 | def __init__(self, response, category: types.Category, error_message: str | None, wait_time: int | None): 82 | super(RaiseError, self).__init__(response) 83 | self.category = category 84 | self.error_message = error_message 85 | self.wait_time = wait_time 86 | 87 | def short_str(self): 88 | return f"Не удалось поднять лоты категории \"{self.category.name}\"" \ 89 | f"{f': {self.error_message}' if self.error_message else '.'}" 90 | 91 | 92 | class ImageUploadError(RequestFailedError): 93 | """ 94 | Исключение, которое возбуждается, если произошла ошибка при выгрузке изображения. 95 | """ 96 | def __init__(self, response: requests.Response, error_message: str | None): 97 | super(ImageUploadError, self).__init__(response) 98 | self.error_message = error_message 99 | if not self.error_message: 100 | self.log_response = True 101 | 102 | def short_str(self): 103 | return f"Произошла ошибка при выгрузке изображения{f': {self.error_message}' if self.error_message else '.'}" 104 | 105 | 106 | class MessageNotDeliveredError(RequestFailedError): 107 | """ 108 | Исключение, которое возбуждается, если при отправке сообщения произошла ошибка. 109 | """ 110 | def __init__(self, response: requests.Response, error_message: str | None, chat_id: int): 111 | super(MessageNotDeliveredError, self).__init__(response) 112 | self.error_message = error_message 113 | self.chat_id = chat_id 114 | if not self.error_message: 115 | self.log_response = True 116 | 117 | def short_str(self): 118 | return f"Не удалось отправить сообщение в чат {self.chat_id}" \ 119 | f"{f': {self.error_message}' if self.error_message else '.'}" 120 | 121 | 122 | class FeedbackEditingError(RequestFailedError): 123 | """ 124 | Исключение, которое возбуждается, если при добавлении / редактировании / удалении отзыва / ответа на отзыв 125 | произошла ошибка. 126 | """ 127 | def __init__(self, response: requests.Response, error_message: str | None, order_id: str): 128 | super(FeedbackEditingError, self).__init__(response) 129 | self.error_message = error_message 130 | self.order_id = order_id 131 | if not self.error_message: 132 | self.log_response = True 133 | 134 | def short_str(self): 135 | return f"Не удалось изменить состояние отзыва / ответа на отзыв на заказ {self.order_id}" \ 136 | f"{f': {self.error_message}' if self.error_message else '.'}" 137 | 138 | 139 | class LotSavingError(RequestFailedError): 140 | """ 141 | Исключение, которое возбуждается, если при сохранении лота произошла ошибка. 142 | """ 143 | def __init__(self, response: requests.Response, error_message: str | None, lot_id: int): 144 | super(LotSavingError, self).__init__(response) 145 | self.error_message = error_message 146 | self.lot_id = lot_id 147 | if not self.error_message: 148 | self.log_response = True 149 | 150 | def short_str(self): 151 | return f"Не удалось сохранить лот {self.lot_id}" \ 152 | f"{f': {self.error_message}' if self.error_message else '.'}" 153 | 154 | 155 | class RefundError(RequestFailedError): 156 | """ 157 | Исключение, которое возбуждается, если при возврате средств за заказ произошла ошибка. 158 | """ 159 | def __init__(self, response: requests.Response, error_message: str | None, order_id: str): 160 | super(RefundError, self).__init__(response) 161 | self.error_message = error_message 162 | self.order_id = order_id 163 | if not self.error_message: 164 | self.log_response = True 165 | 166 | def short_str(self): 167 | return f"Не удалось вернуть средства по заказу {self.order_id}" \ 168 | f"{f': {self.error_message}' if self.error_message else '.'}" 169 | -------------------------------------------------------------------------------- /FunPayAPI/common/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | В данном модуле написаны вспомогательные функции. 3 | """ 4 | 5 | import string 6 | import random 7 | import re 8 | 9 | 10 | MONTHS = { 11 | "января": 1, 12 | "февраля": 2, 13 | "марта": 3, 14 | "апреля": 4, 15 | "мая": 5, 16 | "июня": 6, 17 | "июля": 7, 18 | "августа": 8, 19 | "сентября": 9, 20 | "октября": 10, 21 | "ноября": 11, 22 | "декабря": 12 23 | } 24 | 25 | 26 | def random_tag() -> str: 27 | """ 28 | Генерирует случайный тег для запроса (для runner'а). 29 | 30 | :return: сгенерированный тег. 31 | """ 32 | return "".join(random.choice(string.digits + string.ascii_lowercase) for _ in range(10)) 33 | 34 | 35 | def parse_wait_time(response: str) -> int: 36 | """ 37 | Парсит ответ FunPay на запрос о поднятии лотов. 38 | 39 | :param response: текст ответа. 40 | 41 | :return: Примерное время ожидание до следующего поднятия лотов (в секундах). 42 | """ 43 | if response == "Подождите секунду.": 44 | return 2 45 | elif response == "Подождите минуту.": 46 | return 60 47 | elif response == "Подождите час.": 48 | return 3600 49 | elif "сек" in response: 50 | response = response.split() 51 | return int(response[1]) 52 | elif "мин" in response: 53 | response = response.split() 54 | # ["Подождите", "n", "минут."] 55 | return (int(response[1])-1) * 60 56 | elif "час" in response: 57 | response = response.split() 58 | return (int(response[1])) * 3600 59 | else: 60 | return 10 61 | 62 | 63 | class RegularExpressions(object): 64 | """ 65 | В данном классе хранятся скомпилированные регулярные выражения, описывающие системные сообщения FunPay и прочие 66 | элементы текстов. 67 | Класс является singleton'ом. 68 | """ 69 | def __new__(cls, *args, **kwargs): 70 | if not hasattr(cls, "instance"): 71 | setattr(cls, "instance", super(RegularExpressions, cls).__new__(cls)) 72 | return getattr(cls, "instance") 73 | 74 | def __init__(self): 75 | self.ORDER_PURCHASED = re.compile(r"Покупатель [a-zA-Z0-9]+ оплатил заказ #[A-Z0-9]{8}\.") 76 | """ 77 | Скомпилированное регулярное выражение, описывающее сообщение об оплате заказа. 78 | Лучше всего использовать вместе с MessageTypesRes.ORDER_PURCHASED2 79 | """ 80 | 81 | self.ORDER_PURCHASED2 = re.compile(r"[a-zA-Z0-9]+, не забудьте потом нажать кнопку " 82 | r"«Подтвердить выполнение заказа»\.") 83 | """ 84 | Скомпилированное регулярное выражение, описывающее сообщение об оплате заказа (2). 85 | Лучше всего использовать вместе с MessageTypesRes.ORDER_PURCHASED 86 | """ 87 | 88 | self.ORDER_CONFIRMED = re.compile(r"Покупатель [a-zA-Z0-9]+ подтвердил успешное выполнение " 89 | r"заказа #[A-Z0-9]{8} и отправил деньги продавцу [a-zA-Z0-9]+\.") 90 | """ 91 | Скомпилированное регулярное выражение, описывающее сообщение о подтверждении выполнения заказа. 92 | """ 93 | 94 | self.NEW_FEEDBACK = re.compile(r"Покупатель [a-zA-Z0-9]+ написал отзыв к заказу #[A-Z0-9]{8}\.") 95 | """ 96 | Скомпилированное регулярное выражение, описывающее сообщение о новом отзыве. 97 | """ 98 | 99 | self.FEEDBACK_CHANGED = re.compile(r"Покупатель [a-zA-Z0-9]+ изменил отзыв к заказу #[A-Z0-9]{8}\.") 100 | """ 101 | Скомпилированное регулярное выражение, описывающее сообщение об изменении отзыва. 102 | """ 103 | 104 | self.FEEDBACK_DELETED = re.compile(r"Покупатель [a-zA-Z0-9]+ удалил отзыв к заказу #[A-Z0-9]{8}\.") 105 | """ 106 | Скомпилированное регулярное выражение, описывающее сообщение об удалении отзыва. 107 | """ 108 | 109 | self.NEW_FEEDBACK_ANSWER = re.compile(r"Продавец [a-zA-Z0-9]+ ответил на отзыв к заказу #[A-Z0-9]{8}\.") 110 | """ 111 | Скомпилированное регулярное выражение, описывающее сообщение о новом ответе на отзыв. 112 | """ 113 | 114 | self.FEEDBACK_ANSWER_CHANGED = re.compile(r"Продавец [a-zA-Z0-9]+ изменил ответ на отзыв к " 115 | r"заказу #[A-Z0-9]{8}\.") 116 | """ 117 | Скомпилированное регулярное выражение, описывающее сообщение об изменении ответа на отзыв. 118 | """ 119 | 120 | self.FEEDBACK_ANSWER_DELETED = re.compile(r"Продавец [a-zA-Z0-9]+ удалил ответ на отзыв к заказу " 121 | r"#[A-Z0-9]{8}\.") 122 | """ 123 | Скомпилированное регулярное выражение, описывающее сообщение об удалении ответа на отзыв. 124 | """ 125 | 126 | self.ORDER_REOPENED = re.compile(r"Заказ #[A-Z0-9]{8} открыт повторно\.") 127 | """ 128 | Скомпилированное регулярное выражение, описывающее сообщение о повтором открытии заказа. 129 | """ 130 | 131 | self.REFUND = re.compile(r"Продавец [a-zA-Z0-9]+ вернул деньги покупателю [a-zA-Z0-9]+ " 132 | r"по заказу #[A-Z0-9]{8}\.") 133 | """ 134 | Скомпилированное регулярное выражение, описывающее сообщение о возврате денежных средств. 135 | """ 136 | 137 | self.PARTIAL_REFUND = re.compile(r"Часть средств по заказу #[A-Z0-9]{8} возвращена покупателю\.") 138 | """ 139 | Скомпилированное регулярное выражение, описывающее сообщение частичном о возврате денежных средств. 140 | """ 141 | 142 | self.ORDER_CONFIRMED_BY_ADMIN = re.compile(r"Администратор [a-zA-Z0-9]+ подтвердил успешное выполнение " 143 | r"заказа #[A-Z0-9]{8} и отправил деньги продавцу [a-zA-Z0-9]+\.") 144 | """ 145 | Скомпилированное регулярное выражение, описывающее сообщение о подтверждении выполнения заказа администратором. 146 | """ 147 | 148 | self.ORDER_ID = re.compile(r"#[A-Z0-9]{8}") 149 | """ 150 | Скомпилированное регулярное выражение, описывающее ID заказа. 151 | """ 152 | 153 | self.ORDER_DATE = re.compile(r"\d{1,2} [а-я]+, \d{1,2}:\d{1,2}") 154 | """ 155 | Скомпилированное регулярное выражение, описывающее дату заказа в формате <ДД месяца, ЧЧ:ММ>. 156 | """ 157 | 158 | self.FULL_ORDER_DATE = re.compile(r"\d{1,2} [а-я]+ \d{4}, \d{1,2}:\d{1,2}") 159 | """ 160 | Скомпилированное регулярное выражение, описывающее дату заказа в формате <ДД месяца ГГГГ, ЧЧ:ММ>. 161 | """ 162 | 163 | self.DISCORD = "Вы можете перейти в Discord. " \ 164 | "Внимание: общение за пределами сервера FunPay считается нарушением правил." 165 | """ 166 | Точный текст сообщения о предложении перехода в Discord. 167 | """ 168 | 169 | self.PRODUCTS_AMOUNT = re.compile(r"\d+ шт\.") 170 | """ 171 | Скомпилированное регулярное выражение, описывающее запись кол-ва товаров в заказе. 172 | """ 173 | -------------------------------------------------------------------------------- /FunPayAPI/types.py: -------------------------------------------------------------------------------- 1 | """ 2 | В данном модуле описаны все типы пакета FunPayAPI 3 | """ 4 | from __future__ import annotations 5 | from typing import Literal, overload, Optional 6 | from .common.utils import RegularExpressions 7 | from .common.enums import MessageTypes, OrderStatuses, SubCategoryTypes 8 | import datetime 9 | 10 | 11 | class ChatShortcut: 12 | """ 13 | Данный класс представляет виджет чата со страницы https://funpay.com/chat/ 14 | 15 | :param id_: ID чата. 16 | :type id_: :obj:`int` 17 | 18 | :param name: название чата (никнейм собеседника). 19 | :type name: :obj:`str` 20 | 21 | :param last_message_text: текст последнего сообщения в чате (макс. 250 символов). 22 | :type last_message_text: :obj:`str` 23 | 24 | :param unread: флаг "непрочитанности" (`True`, если чат не прочитан (оранжевый). `False`, если чат прочитан). 25 | :type unread: :obj:`bool` 26 | 27 | :param html: HTML код виджета чата. 28 | :type html: :obj:`str` 29 | 30 | :param determine_msg_type: определять ли тип последнего сообщения? 31 | :type determine_msg_type: :obj:`bool`, опционально 32 | """ 33 | def __init__(self, id_: int, name: str, last_message_text: str, 34 | unread: bool, html: str, determine_msg_type: bool = True): 35 | self.id: int = id_ 36 | """ID чата.""" 37 | self.name: str | None = name if name else None 38 | """Название чата (никнейм собеседника).""" 39 | self.last_message_text: str = last_message_text 40 | """Текст последнего сообщения в чате (макс. 250 символов).""" 41 | self.unread: bool = unread 42 | """Флаг \"непрочитанности\" (если True - в чате есть непрочитанные сообщения).""" 43 | self.last_message_type: MessageTypes | None = None if not determine_msg_type else self.get_last_message_type() 44 | """Тип последнего сообщения.""" 45 | self.html: str = html 46 | """HTML код виджета чата.""" 47 | 48 | def get_last_message_type(self) -> MessageTypes: 49 | """ 50 | Определяет тип последнего сообщения в чате на основе регулярных выражений из MessageTypesRes. 51 | 52 | !Внимание! Результат определения типа сообщения данным методом не является правильным в 100% случаев, т.к. он 53 | основан на сравнении с регулярными выражениями. 54 | Возможны "ложные срабатывание", если пользователь напишет "поддельное" сообщение, которое совпадет с одним из 55 | регулярных выражений. 56 | 57 | :return: тип последнего сообщения. 58 | :rtype: :class:`FunPayAPI.common.enums.MessageTypes` 59 | """ 60 | res = RegularExpressions() 61 | if self.last_message_text == res.DISCORD: 62 | return MessageTypes.DISCORD 63 | 64 | if res.ORDER_PURCHASED.findall(self.last_message_text) and res.ORDER_PURCHASED2.findall(self.last_message_text): 65 | return MessageTypes.ORDER_PURCHASED 66 | 67 | if res.ORDER_ID.search(self.last_message_text) is None: 68 | return MessageTypes.NON_SYSTEM 69 | 70 | # Регулярные выражения выставлены в порядке от самых часто-используемых к самым редко-используемым 71 | sys_msg_types = { 72 | MessageTypes.ORDER_CONFIRMED: res.ORDER_CONFIRMED, 73 | MessageTypes.NEW_FEEDBACK: res.NEW_FEEDBACK, 74 | MessageTypes.NEW_FEEDBACK_ANSWER: res.NEW_FEEDBACK_ANSWER, 75 | MessageTypes.FEEDBACK_CHANGED: res.FEEDBACK_CHANGED, 76 | MessageTypes.FEEDBACK_DELETED: res.FEEDBACK_DELETED, 77 | MessageTypes.REFUND: res.REFUND, 78 | MessageTypes.FEEDBACK_ANSWER_CHANGED: res.FEEDBACK_ANSWER_CHANGED, 79 | MessageTypes.FEEDBACK_ANSWER_DELETED: res.FEEDBACK_ANSWER_DELETED, 80 | MessageTypes.ORDER_CONFIRMED_BY_ADMIN: res.ORDER_CONFIRMED_BY_ADMIN, 81 | MessageTypes.PARTIAL_REFUND: res.PARTIAL_REFUND, 82 | MessageTypes.ORDER_REOPENED: res.ORDER_REOPENED 83 | } 84 | 85 | for i in sys_msg_types: 86 | if sys_msg_types[i].search(self.last_message_text): 87 | return i 88 | else: 89 | return MessageTypes.NON_SYSTEM 90 | 91 | def __str__(self): 92 | return self.last_message_text 93 | 94 | 95 | class Chat: 96 | """ 97 | Данный класс представляет личный чат. 98 | 99 | :param id_: ID чата. 100 | :type id_: :obj:`int` 101 | 102 | :param name: название чата (никнейм собеседника). 103 | :type name: :obj:`str` 104 | 105 | :param looking_link: ссылка на лот, который смотрит собеседник. 106 | :type looking_link: :obj:`str` or :obj:`None` 107 | 108 | :param looking_text: название лота, который смотрит собеседник. 109 | :type looking_text: :obj:`str` or :obj:`None` 110 | 111 | :param html: HTML код чата. 112 | :type html: :obj:`str` 113 | 114 | :param messages: последние 100 сообщений чата. 115 | :type messages: :obj:`list` of :class:`FunPayAPI.types.Message` or :obj:`None` 116 | """ 117 | def __init__(self, id_: int, name: str, looking_link: str | None, looking_text: str | None, 118 | html: str, messages: Optional[list[Message]] = None): 119 | self.id: int = id_ 120 | """ID чата.""" 121 | self.name: str = name 122 | """Название чата (никнейм собеседника).""" 123 | self.looking_link: str | None = looking_link 124 | """Ссылка на лот, который в данный момент смотрит собеседник.""" 125 | self.looking_text: str | None = looking_text 126 | """Название лота, который в данный момент смотрит собеседник.""" 127 | self.html: str = html 128 | """HTML код чата.""" 129 | self.messages: list[Message] = messages or [] 130 | """Последние 100 сообщений чата.""" 131 | 132 | 133 | class Message: 134 | """ 135 | Данный класс представляет отдельное сообщение. 136 | 137 | :param id_: ID сообщения. 138 | :type id_: :obj:`int` 139 | 140 | :param text: текст сообщения (если есть). 141 | :type text: :obj:`str` or :obj:`None` 142 | 143 | :param chat_id: ID чата, в котором находится данное сообщение. 144 | :type chat_id: :obj:`int` or :obj:`str` 145 | 146 | :param chat_name: название чата, в котором находится данное сообщение. 147 | :type chat_name: :obj:`str` or :obj:`None` 148 | 149 | :param author: никнейм автора сообщения. 150 | :type author: :obj:`str`, or :obj:`None` 151 | 152 | :param author_id: ID автора сообщения. 153 | :type author_id: :obj:`int` 154 | 155 | :param html: HTML код сообщения. 156 | :type html: :obj:`str` 157 | 158 | :param image_link: ссылка на изображение из сообщения (если есть). 159 | :type image_link: :obj:`str` or :obj:`None`, опционально 160 | 161 | :param determine_msg_type: определять ли тип сообщения. 162 | :type determine_msg_type: :obj:`bool`, опционально 163 | """ 164 | def __init__(self, id_: int, text: str | None, chat_id: int | str, chat_name: str | None, 165 | author: str | None, author_id: int, html: str, 166 | image_link: str | None = None, determine_msg_type: bool = True, badge_text: Optional[str] = None): 167 | self.id: int = id_ 168 | """ID сообщения.""" 169 | self.text: str | None = text 170 | """Текст сообщения.""" 171 | self.chat_id: int | str = chat_id 172 | """ID чата.""" 173 | self.chat_name: str | None = chat_name 174 | """Название чата.""" 175 | self.type: MessageTypes | None = None if not determine_msg_type else self.get_message_type() 176 | """Тип сообщения.""" 177 | self.author: str | None = author 178 | """Автор сообщения.""" 179 | self.author_id: int = author_id 180 | """ID автора сообщения.""" 181 | self.html: str = html 182 | """HTML-код сообщения.""" 183 | self.image_link: str | None = image_link 184 | """Ссылка на изображение в сообщении (если оно есть).""" 185 | self.by_bot: bool = False 186 | """Отправлено ли сообщение с помощью :meth:`FunPayAPI.Account.send_message`?""" 187 | self.badge: str | None = badge_text 188 | """Текст бэйджика тех. поддержки.""" 189 | 190 | def get_message_type(self) -> MessageTypes: 191 | """ 192 | Определяет тип сообщения на основе регулярных выражений из MessageTypesRes. 193 | 194 | Внимание! Данный способ определения типа сообщения не является 100% правильным, т.к. он основан на сравнении с 195 | регулярными выражениями. Возможно ложное "срабатывание", если пользователь напишет "поддельное" сообщение, 196 | которое совпадет с одним из регулярных выражений. 197 | Рекомендуется делать проверку на author_id == 0. 198 | 199 | :return: тип последнего сообщения в чате. 200 | :rtype: :class:`FunPayAPI.common.enums.MessageTypes` 201 | """ 202 | if not self.text: 203 | return MessageTypes.NON_SYSTEM 204 | 205 | res = RegularExpressions() 206 | if self.text == res.DISCORD: 207 | return MessageTypes.DISCORD 208 | 209 | if res.ORDER_PURCHASED.findall(self.text) and res.ORDER_PURCHASED2.findall(self.text): 210 | return MessageTypes.ORDER_PURCHASED 211 | 212 | if res.ORDER_ID.search(self.text) is None: 213 | return MessageTypes.NON_SYSTEM 214 | 215 | # Регулярные выражения выставлены в порядке от самых часто-используемых к самым редко-используемым 216 | sys_msg_types = { 217 | MessageTypes.ORDER_CONFIRMED: res.ORDER_CONFIRMED, 218 | MessageTypes.NEW_FEEDBACK: res.NEW_FEEDBACK, 219 | MessageTypes.NEW_FEEDBACK_ANSWER: res.NEW_FEEDBACK_ANSWER, 220 | MessageTypes.FEEDBACK_CHANGED: res.FEEDBACK_CHANGED, 221 | MessageTypes.FEEDBACK_DELETED: res.FEEDBACK_DELETED, 222 | MessageTypes.REFUND: res.REFUND, 223 | MessageTypes.FEEDBACK_ANSWER_CHANGED: res.FEEDBACK_ANSWER_CHANGED, 224 | MessageTypes.FEEDBACK_ANSWER_DELETED: res.FEEDBACK_ANSWER_DELETED, 225 | MessageTypes.ORDER_CONFIRMED_BY_ADMIN: res.ORDER_CONFIRMED_BY_ADMIN, 226 | MessageTypes.PARTIAL_REFUND: res.PARTIAL_REFUND, 227 | MessageTypes.ORDER_REOPENED: res.ORDER_REOPENED 228 | } 229 | 230 | for i in sys_msg_types: 231 | if sys_msg_types[i].search(self.text): 232 | return i 233 | else: 234 | return MessageTypes.NON_SYSTEM 235 | 236 | def __str__(self): 237 | return self.text if self.text is not None else self.image_link if self.image_link is not None else "" 238 | 239 | 240 | class OrderShortcut: 241 | """ 242 | Данный класс представляет виджет заказа со страницы https://funpay.com/orders/trade 243 | 244 | :param id_: ID заказа. 245 | :type id_: :obj:`str` 246 | 247 | :param description: описание заказа. 248 | :type description: :obj:`str` 249 | 250 | :param price: цена заказа. 251 | :type price: :obj:`float` 252 | 253 | :param buyer_username: никнейм покупателя. 254 | :type buyer_username: :obj:`str` 255 | 256 | :param buyer_id: ID покупателя. 257 | :type buyer_id: :obj:`int` 258 | 259 | :param status: статус заказа. 260 | :type status: :class:`FunPayAPI.common.enums.OrderStatuses` 261 | 262 | :param date: дата создания заказа. 263 | :type date: :class:`datetime.datetime` 264 | 265 | :param subcategory_name: название подкатегории, к которой относится заказ. 266 | :type subcategory_name: :obj:`str` 267 | 268 | :param html: HTML код виджета заказа. 269 | :type html: :obj:`str` 270 | 271 | :param dont_search_amount: не искать кол-во товара. 272 | :type dont_search_amount: :obj:`bool`, опционально 273 | """ 274 | def __init__(self, id_: str, description: str, price: float, 275 | buyer_username: str, buyer_id: int, status: OrderStatuses, 276 | date: datetime.datetime, subcategory_name: str, html: str, dont_search_amount: bool = False): 277 | self.id: str = id_ if not id_.startswith("#") else id_[1:] 278 | """ID заказа.""" 279 | self.description: str = description 280 | """Описание заказа.""" 281 | self.price: float = price 282 | """Цена заказа.""" 283 | self.amount: int | None = self.parse_amount() if not dont_search_amount else None 284 | """Кол-во товаров.""" 285 | self.buyer_username: str = buyer_username 286 | """Никнейм покупателя.""" 287 | self.buyer_id: int = buyer_id 288 | """ID покупателя.""" 289 | self.status: OrderStatuses = status 290 | """Статус заказа.""" 291 | self.date: datetime.datetime = date 292 | """Дата создания заказа.""" 293 | self.subcategory_name: str = subcategory_name 294 | """Название подкатегории, к которой относится заказ.""" 295 | self.html: str = html 296 | """HTML код виджета заказа.""" 297 | 298 | def parse_amount(self) -> int: 299 | """ 300 | Парсит кол-во купленного товара (ищет подстроку по регулярному выражению). 301 | 302 | :return: кол-во купленного товара. 303 | :rtype: :obj:`int` 304 | """ 305 | res = RegularExpressions() 306 | result = res.PRODUCTS_AMOUNT.findall(self.description) 307 | if result: 308 | return int(result[0].split(" ")[0]) 309 | return 1 310 | 311 | def __str__(self): 312 | return self.description 313 | 314 | 315 | class Order: 316 | """ 317 | Данный класс представляет заказ со страницы https://funpay.com/orders// 318 | 319 | :param id_: ID заказа. 320 | :type id_: :obj:`str` 321 | 322 | :param status: статус заказа. 323 | :type status: :class:`FunPayAPI.common.enums.OrderStatuses` 324 | 325 | :param subcategory: подкатегория, к которой относится заказ. 326 | :type subcategory: :class:`FunPayAPI.types.SubCategory` 327 | 328 | :param short_description: краткое описание (название) заказа. 329 | :type short_description: :obj:`str` or :obj:`None` 330 | 331 | :param full_description: полное описание заказа. 332 | :type full_description: :obj:`str` or :obj:`None` 333 | 334 | :param sum_: сумма заказа. 335 | :type sum_: :obj:`float` 336 | 337 | :param buyer_id: ID покупателя. 338 | :type buyer_id: :obj:`int` 339 | 340 | :param buyer_username: никнейм покупателя. 341 | :type buyer_username: :obj:`str` 342 | 343 | :param seller_id: ID продавца. 344 | :type seller_id: :obj:`int` 345 | 346 | :param seller_username: никнейм продавца. 347 | :type seller_username: :obj:`str` 348 | 349 | :param html: HTML код заказа. 350 | :type html: :obj:`str` 351 | 352 | :param review: объект отзыва на заказ. 353 | :type review: :class:`FunPayAPI.types.Review` or :obj:`None` 354 | """ 355 | def __init__(self, id_: str, status: OrderStatuses, subcategory: SubCategory, short_description: str | None, 356 | full_description: str | None, sum_: float, 357 | buyer_id: int, buyer_username: str, 358 | seller_id: int, seller_username: str, 359 | html: str, review: Review | None): 360 | self.id: str = id_ if not id_.startswith("#") else id_[1:] 361 | """ID заказа.""" 362 | self.status: OrderStatuses = status 363 | """Статус заказа.""" 364 | self.subcategory: SubCategory = subcategory 365 | """Подкатегория, к которой относится заказ.""" 366 | self.short_description: str | None = short_description 367 | """Краткое описание (название) заказа. То же самое, что и Order.title.""" 368 | self.title: str | None = short_description 369 | """Краткое описание (название) заказа. То же самое, что и Order.short_description.""" 370 | self.full_description: str | None = full_description 371 | """Полное описание заказа.""" 372 | self.sum: float = sum_ 373 | """Сумма заказа.""" 374 | self.buyer_id: int = buyer_id 375 | """ID покупателя.""" 376 | self.buyer_username: str = buyer_username 377 | """Никнейм покупателя.""" 378 | self.seller_id: int = seller_id 379 | """ID продавца.""" 380 | self.seller_username: str = seller_username 381 | """Никнейм продавца.""" 382 | self.html: str = html 383 | """HTML код заказа.""" 384 | self.review: Review | None = review 385 | """Объект отзыва заказа.""" 386 | 387 | 388 | class Category: 389 | """ 390 | Класс, описывающий категорию (игру). 391 | 392 | :param id_: ID категории (game_id / data-id). 393 | :type id_: :obj:`int` 394 | 395 | :param name: название категории (игры). 396 | :type name: :obj:`str` 397 | 398 | :param subcategories: подкатегории. 399 | :type subcategories: :obj:`list` of :class:`FunPayAPI.types.SubCategory` or :obj:`None`, опционально 400 | """ 401 | def __init__(self, id_: int, name: str, subcategories: list[SubCategory] | None = None): 402 | self.id: int = id_ 403 | """ID категории (game_id / data-id).""" 404 | self.name: str = name 405 | """Название категории (игры).""" 406 | self.__subcategories: list[SubCategory] = subcategories or [] 407 | """Список подкатегорий.""" 408 | self.__sorted_subcategories: dict[SubCategoryTypes, dict[int, SubCategory]] = { 409 | SubCategoryTypes.COMMON: {}, 410 | SubCategoryTypes.CURRENCY: {} 411 | } 412 | for i in self.__subcategories: 413 | self.__sorted_subcategories[i.type][i.id] = i 414 | 415 | def add_subcategory(self, subcategory: SubCategory): 416 | """ 417 | Добавляет подкатегорию в список подкатегорий. 418 | 419 | :param subcategory: объект подкатегории. 420 | :type subcategory: :class:`FunPayAPI.types.SubCategory` 421 | """ 422 | if subcategory not in self.__subcategories: 423 | self.__subcategories.append(subcategory) 424 | self.__sorted_subcategories[subcategory.type][subcategory.id] = subcategory 425 | 426 | def get_subcategory(self, subcategory_type: SubCategoryTypes, subcategory_id: int) -> SubCategory | None: 427 | """ 428 | Возвращает объект подкатегории. 429 | 430 | :param subcategory_type: тип подкатегории. 431 | :type subcategory_type: :class:`FunPayAPI.common.enums.SubCategoryTypes` 432 | 433 | :param subcategory_id: ID подкатегории. 434 | :type subcategory_id: :obj:`int` 435 | 436 | :return: объект подкатегории или None, если подкатегория не найдена. 437 | :rtype: :class:`FunPayAPI.types.SubCategory` or :obj:`None` 438 | """ 439 | return self.__sorted_subcategories[subcategory_type].get(subcategory_id) 440 | 441 | def get_subcategories(self) -> list[SubCategory]: 442 | """ 443 | Возвращает все подкатегории данной категории (игры). 444 | 445 | :return: все подкатегории данной категории (игры). 446 | :rtype: :obj:`list` of :class:`FunPayAPI.types.SubCategory` 447 | """ 448 | return self.__subcategories 449 | 450 | def get_sorted_subcategories(self) -> dict[SubCategoryTypes, dict[int, SubCategory]]: 451 | """ 452 | Возвращает все подкатегории данной категории (игры) в виде словаря {type: {ID: подкатегория}}. 453 | 454 | :return: все подкатегории данной категории (игры) в виде словаря {type: ID: подкатегория}}. 455 | :rtype: :obj:`dict` {:class:`FunPayAPI.common.enums.SubCategoryTypes`: :obj:`dict` {:obj:`int`, :class:`FunPayAPI.types.SubCategory`}} 456 | """ 457 | return self.__sorted_subcategories 458 | 459 | 460 | class SubCategory: 461 | """ 462 | Класс, описывающий подкатегорию. 463 | 464 | :param id_: ID подкатегории. 465 | :type id_: :obj:`int` 466 | 467 | :param name: название подкатегории. 468 | :type name: :obj:`str` 469 | 470 | :param type_: тип лотов подкатегории. 471 | :type type_: :class:`FunPayAPI.common.enums.SubCategoryTypes` 472 | 473 | :param category: родительская категория (игра). 474 | :type category: :class:`FunPayAPI.types.Category` 475 | """ 476 | def __init__(self, id_: int, name: str, type_: SubCategoryTypes, category: Category): 477 | self.id: int = id_ 478 | """ID подкатегории.""" 479 | self.name: str = name 480 | """Название подкатегории.""" 481 | self.type: SubCategoryTypes = type_ 482 | """Тип подкатегории.""" 483 | self.category: Category = category 484 | """Родительская категория (игра).""" 485 | self.fullname: str = f"{self.name} {self.category.name}" 486 | """Полное название подкатегории.""" 487 | self.public_link: str = f"https://funpay.com/chips/{id_}/" if type_ is SubCategoryTypes.CURRENCY else \ 488 | f"https://funpay.com/lots/{id_}/" 489 | """Публичная ссылка на список лотов подкатегории.""" 490 | self.private_link: str = f"{self.public_link}trade" 491 | """Приватная ссылка на список лотов подкатегории (для редактирования лотов).""" 492 | 493 | 494 | class LotFields: 495 | """ 496 | Класс, описывающий поля лота со страницы редактирования лота. 497 | 498 | :param lot_id: ID лота. 499 | :type lot_id: :obj:`int` 500 | 501 | :param fields: словарь с полями. 502 | :type fields: :obj:`dict` 503 | """ 504 | def __init__(self, lot_id: int, fields: dict): 505 | self.lot_id: int = lot_id 506 | """ID лота.""" 507 | self.__fields: dict = fields 508 | """Поля лота.""" 509 | 510 | self.title_ru: str = self.__fields.get("fields[summary][ru]") 511 | """Русское краткое описание (название) лота.""" 512 | self.title_en: str = self.__fields.get("fields[summary][en]") 513 | """Английское краткое описание (название) лота.""" 514 | self.description_ru: str = self.__fields.get("fields[desc][ru]") 515 | """Русское полное описание лота.""" 516 | self.description_en: str = self.__fields.get("fields[desc][en]") 517 | """Английское полное описание лота.""" 518 | self.amount: int | None = int(i) if (i := self.__fields.get("amount")) else None 519 | """Кол-во товара.""" 520 | self.price: float = float(i) if (i := self.__fields.get("price")) else None 521 | """Цена за 1шт.""" 522 | self.active: bool = "active" in self.__fields 523 | """Активен ли лот.""" 524 | self.deactivate_after_sale: bool = "deactivate_after_sale[]" in self.__fields 525 | """Деактивировать ли лот после продажи.""" 526 | 527 | @property 528 | def fields(self) -> dict[str, str]: 529 | """ 530 | Возвращает все поля лота в виде словаря. 531 | 532 | :return: все поля лота в виде словаря. 533 | :rtype: :obj:`dict` {:obj:`str`: :obj:`str`} 534 | """ 535 | return self.__fields 536 | 537 | def edit_fields(self, fields: dict[str, str]): 538 | """ 539 | Редактирует переданные поля лота. 540 | 541 | :param fields: поля лота, которые нужно заменить, и их значения. 542 | :type fields: obj:`dict` {:obj:`str`: :obj:`str`} 543 | """ 544 | self.__fields.update(fields) 545 | 546 | def set_fields(self, fields: dict): 547 | """ 548 | Сбрасывает текущие поля лота и устанавливает переданные. 549 | !НЕ РЕДАКТИРУЕТ СВОЙСТВА ЭКЗЕМЛПЯРА! 550 | 551 | :param fields: поля лота. 552 | :type fields: :obj:`dict` {:obj:`str`: :obj:`str`} 553 | """ 554 | self.__fields = fields 555 | 556 | def renew_fields(self) -> LotFields: 557 | """ 558 | Обновляет :py:obj:`~__fields` (возвращается в методе :meth:`FunPayAPI.types.LotFields.get_fields`), 559 | основываясь на свойствах экземпляра. 560 | Необходимо вызвать перед сохранением лота на FunPay после изменения любого свойства экземпляра. 561 | 562 | :return: экземпляр класса :class:`FunPayAPI.types.LotFields` с новыми полями лота. 563 | :rtype: :class:`FunPayAPI.types.LotFields` 564 | """ 565 | self.__fields["fields[summary][ru]"] = self.title_ru 566 | self.__fields["fields[summary][en]"] = self.title_en 567 | self.__fields["fields[desc][ru]"] = self.description_ru 568 | self.__fields["fields[desc][en]"] = self.description_en 569 | self.__fields["price"] = str(self.price) if self.price is not None else "" 570 | self.__fields["deactivate_after_sale"] = "on" if self.deactivate_after_sale else "" 571 | self.__fields["active"] = "on" if self.active else "" 572 | self.__fields["amount"] = self.amount if self.amount is not None else "" 573 | return self 574 | 575 | 576 | class LotShortcut: 577 | """ 578 | Данный класс представляет виджет лота. 579 | 580 | :param id_: ID лота. 581 | :type id_: :obj:`int` or :obj:`str` 582 | 583 | :param server: название сервера (если указан в лоте). 584 | :type server: :obj:`str` or :obj:`None` 585 | 586 | :param description: краткое описание (название) лота. 587 | :type description: :obj:`str` or :obj:`None` 588 | 589 | :param price: цена лота. 590 | :type price: :obj:`float` 591 | 592 | :param subcategory: подкатегория лота. 593 | :type subcategory: :class:`FunPayAPI.types.SubCategory` 594 | 595 | :param html: HTML код виджета лота. 596 | :type html: :obj:`str` 597 | """ 598 | def __init__(self, id_: int | str, server: str | None, 599 | description: str | None, price: float, subcategory: SubCategory, html: str): 600 | self.id: int | str = id_ 601 | if isinstance(self.id, str) and self.id.isnumeric(): 602 | self.id = int(self.id) 603 | """ID лота.""" 604 | self.server: str | None = server 605 | """Название сервера (если указан).""" 606 | self.description: str | None = description 607 | """Краткое описание (название) лота.""" 608 | self.title: str | None = description 609 | """Краткое описание (название) лота.""" 610 | self.price: float = price 611 | """Цена лота.""" 612 | self.subcategory: SubCategory = subcategory 613 | """Подкатегория лота.""" 614 | self.html: str = html 615 | """HTML-код виджета лота.""" 616 | self.public_link: str = f"https://funpay.com/chips/offer?id={self.id}" \ 617 | if self.subcategory.type is SubCategoryTypes.CURRENCY else f"https://funpay.com/lots/offer?id={self.id}" 618 | """Публичная ссылка на лот.""" 619 | 620 | 621 | class UserProfile: 622 | """ 623 | Данный класс представляет пользователя FunPay. 624 | 625 | :param id_: ID пользователя. 626 | :type id_: :obj:`int` 627 | 628 | :param username: никнейм пользователя. 629 | :type username: :obj:`str` 630 | 631 | :param profile_photo: ссылка на фото профиля. 632 | :type profile_photo: :obj:`str` 633 | 634 | :param online: онлайн ли пользователь? 635 | :type online: :obj:`bool` 636 | 637 | :param banned: заблокирован ли пользователь? 638 | :type banned: :obj:`bool` 639 | 640 | :param html: HTML код страницы пользователя. 641 | :type html: :obj:`str` 642 | """ 643 | def __init__(self, id_: int, username: str, profile_photo: str, online: bool, banned: bool, html: str): 644 | self.id: int = id_ 645 | """ID пользователя.""" 646 | self.username: str = username 647 | """Никнейм пользователя.""" 648 | self.profile_photo: str = profile_photo 649 | """Ссылка на фото профиля.""" 650 | self.online: bool = online 651 | """Онлайн ли пользователь.""" 652 | self.banned: bool = banned 653 | """Заблокирован ли пользователь.""" 654 | self.html: str = html 655 | """HTML код страницы пользователя.""" 656 | self.__lots: list[LotShortcut] = [] 657 | """Все лоты пользователя.""" 658 | self.__lots_ids: dict[int | str, LotShortcut] = {} 659 | """Все лоты пользователя в виде словаря {ID: лот}}""" 660 | self.__sorted_by_subcategory_lots: dict[SubCategory, dict[int | str, LotShortcut]] = {} 661 | """Все лоты пользователя в виде словаря {подкатегория: {ID: лот}}""" 662 | self.__sorted_by_subcategory_type_lots: dict[SubCategoryTypes, dict[int | str, LotShortcut]] = { 663 | SubCategoryTypes.COMMON: {}, 664 | SubCategoryTypes.CURRENCY: {} 665 | } 666 | 667 | def get_lot(self, lot_id: int | str) -> LotShortcut | None: 668 | """ 669 | Возвращает объект лота со страницы пользователя. 670 | 671 | :param lot_id: ID лота. 672 | :type lot_id: :obj:`int` or :obj:`str` 673 | 674 | :return: объект лота со страницы пользователя или `None`, если объект не найден. 675 | :rtype: :class:`FunPayAPI.types.LotShortcut` or :obj:`None` 676 | """ 677 | if isinstance(lot_id, str) and lot_id.isnumeric(): 678 | return self.__lots_ids.get(int(lot_id)) 679 | return self.__lots_ids.get(lot_id) 680 | 681 | def get_lots(self) -> list[LotShortcut]: 682 | """ 683 | Возвращает список всех лотов пользователя. 684 | 685 | :return: список всех лотов пользователя. 686 | :rtype: :obj:`list` of :class:`FunPayAPI.types.LotShortcut` 687 | """ 688 | return self.__lots 689 | 690 | @overload 691 | def get_sorted_lots(self, mode: Literal[1]) -> dict[int | str, LotShortcut]: 692 | ... 693 | 694 | @overload 695 | def get_sorted_lots(self, mode: Literal[2]) -> dict[SubCategory, dict[int | str, LotShortcut]]: 696 | ... 697 | 698 | @overload 699 | def get_sorted_lots(self, mode: Literal[3]) -> dict[SubCategoryTypes, dict[int | str, LotShortcut]]: 700 | ... 701 | 702 | def get_sorted_lots(self, mode: Literal[1, 2, 3]) -> dict[int | str, LotShortcut] |\ 703 | dict[SubCategory, dict[int | str, LotShortcut]] |\ 704 | dict[SubCategoryTypes, dict[int | str, LotShortcut]]: 705 | """ 706 | Возвращает список всех лотов пользователя в виде словаря. 707 | 708 | :param mode: вариант словаря.\n 709 | 1 - {ID: лот}\n 710 | 2 - {подкатегория: {ID: лот}}\n 711 | 3 - {тип лота: {ID: лот}} 712 | 713 | :return: список всех лотов пользователя в виде словаря. 714 | :rtype: :obj:`dict` {:obj:`int` or :obj:`str`: :class:`FunPayAPI.types.LotShortcut`} (`mode==1`) \n 715 | :obj:`dict` {:class:`FunPayAPI.types.SubCategory`: :obj:`dict` {:obj:`int` or :obj:`str`: :class:`FunPayAPI.types.LotShortcut`}} (`mode==2`) \n 716 | :obj:`dict` {:class:`FunPayAPI.common.enums.SubCategoryTypes`: :obj:`dict` {:obj:`int` or :obj:`str`: :class:`FunPayAPI.types.LotShortcut`}} (`mode==3`) 717 | """ 718 | if mode == 1: 719 | return self.__lots_ids 720 | elif mode == 2: 721 | return self.__sorted_by_subcategory_lots 722 | else: 723 | return self.__sorted_by_subcategory_type_lots 724 | 725 | def add_lot(self, lot: LotShortcut): 726 | """ 727 | Добавляет лот в список лотов. 728 | 729 | :param lot: объект лота. 730 | """ 731 | if lot in self.__lots: 732 | return 733 | 734 | self.__lots.append(lot) 735 | self.__lots_ids[lot.id] = lot 736 | if lot.subcategory not in self.__sorted_by_subcategory_lots: 737 | self.__sorted_by_subcategory_lots[lot.subcategory] = {} 738 | self.__sorted_by_subcategory_lots[lot.subcategory][lot.id] = lot 739 | self.__sorted_by_subcategory_type_lots[lot.subcategory.type][lot.id] = lot 740 | 741 | def get_common_lots(self) -> list[LotShortcut]: 742 | """ 743 | Возвращает список стандартных лотов со страницы пользователя. 744 | 745 | :return: Список стандартных лотов со страницы пользователя. 746 | :rtype: :obj:`list` of :class:`FunPayAPI.types.LotShortcut` 747 | """ 748 | return list(self.__sorted_by_subcategory_type_lots[SubCategoryTypes.COMMON].values()) 749 | 750 | def get_currency_lots(self) -> list[LotShortcut]: 751 | """ 752 | Возвращает список лотов-валют со страницы пользователя. 753 | 754 | :return: список лотов-валют со страницы пользователя. 755 | :rtype: :obj:`list` of :class:`FunPayAPI.types.LotShortcut` 756 | """ 757 | return list(self.__sorted_by_subcategory_type_lots[SubCategoryTypes.CURRENCY].values()) 758 | 759 | def __str__(self): 760 | return self.username 761 | 762 | 763 | class Review: 764 | """ 765 | Данный класс представляет отзыв на заказ. 766 | 767 | :param stars: кол-во звезд в отзыве. 768 | :type stars: :obj:`int` or :obj:`None` 769 | 770 | :param text: текст отзыва. 771 | :type text: :obj:`str` or :obj:`None` 772 | 773 | :param reply: текст ответа на отзыв. 774 | :type reply: :obj:`str` or :obj:`None` 775 | 776 | :param anonymous: анонимный ли отзыв? 777 | :type anonymous: :obj:`bool` 778 | 779 | :param html: HTML код отзыва. 780 | :type html: :obj:`str` 781 | 782 | :param order_id: ID заказа, к которому относится отзыв. 783 | :type order_id: :obj:`str` or :obj:`None`, опционально 784 | 785 | :param author: автор отзыва. 786 | :type author: :obj:`str` or :obj:`None`, опционально 787 | 788 | :param author_id: ID автора отзыва. 789 | :type author_id: :obj:`int` or :obj:`None`, опционально 790 | """ 791 | def __init__(self, stars: int | None, text: str | None, reply: str | None, anonymous: bool, html: str, 792 | order_id: str | None = None, author: str | None = None, author_id: int | None = None): 793 | self.stars: int | None = stars 794 | """Кол-во звезде в отзыве.""" 795 | self.text: str | None = text 796 | """Текст отзыва.""" 797 | self.reply: str | None = reply 798 | """Текст ответа на отзыв.""" 799 | self.anonymous: bool = anonymous 800 | """Анонимный ли отзыв?""" 801 | self.html: str = html 802 | """HTML код отзыва.""" 803 | self.order_id: str | None = order_id[1:] if order_id and order_id.startswith("#") else order_id 804 | """ID заказа, к которому относится отзыв.""" 805 | self.author: str | None = author 806 | """Автор отзыва.""" 807 | self.author_id: int | None = author_id 808 | """ID автора отзыва.""" 809 | 810 | 811 | class Balance: 812 | """ 813 | Данный класс представляет информацию о балансе аккаунта. 814 | 815 | :param total_rub: общий рублёвый баланс. 816 | :type total_rub: :obj:`float` 817 | 818 | :param available_rub: доступный к выводу рублёвый баланс. 819 | :type available_rub: :obj:`float` 820 | 821 | :param total_usd: общий долларовый баланс. 822 | :type total_usd: :obj:`float` 823 | 824 | :param available_usd: доступный к выводу долларовый баланс. 825 | :type available_usd: :obj:`float` 826 | 827 | :param total_eur: общий евро баланс. 828 | :param available_eur: :obj:`float` 829 | """ 830 | def __init__(self, total_rub: float, available_rub: float, total_usd: float, available_usd: float, 831 | total_eur: float, available_eur: float): 832 | self.total_rub: float = total_rub 833 | """Общий рублёвый баланс.""" 834 | self.available_rub: float = available_rub 835 | """Доступный к выводу рублёвый баланс.""" 836 | self.total_usd: float = total_usd 837 | """Общий долларовый баланс.""" 838 | self.available_usd: float = available_usd 839 | """Доступный к выводу долларовый баланс.""" 840 | self.total_eur: float = total_eur 841 | """Общий евро баланс.""" 842 | self.available_eur: float = available_eur 843 | """Доступный к выводу евро баланс.""" 844 | -------------------------------------------------------------------------------- /FunPayAPI/updater/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LIMBODS/FunPayAPI/adb6848bb015a1aabfa0e87b79f5a54cebbcc663/FunPayAPI/updater/__init__.py -------------------------------------------------------------------------------- /FunPayAPI/updater/events.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import time 3 | from ..common import utils 4 | from ..common.enums import * 5 | from .. import types 6 | 7 | 8 | class BaseEvent: 9 | """ 10 | Базовый класс события. 11 | 12 | :param runner_tag: тег Runner'а. 13 | :type runner_tag: :obj:`str` 14 | 15 | :param event_type: тип события. 16 | :type event_type: :class:`FunPayAPI.common.enums.EventTypes` 17 | 18 | :param event_time: время события (лучше не указывать, будет генерироваться автоматически). 19 | :type event_time: :obj:`int` or :obj:`float` or :obj:`None`, опционально. 20 | """ 21 | def __init__(self, runner_tag: str, event_type: EventTypes, event_time: int | float | None = None): 22 | self.runner_tag = runner_tag 23 | self.type = event_type 24 | self.time = event_time if event_type is not None else time.time() 25 | 26 | 27 | class InitialChatEvent(BaseEvent): 28 | """ 29 | Класс события: обнаружен чат при первом запросе Runner'а. 30 | 31 | :param runner_tag: тег Runner'а. 32 | :type runner_tag: :obj:`str` 33 | 34 | :param chat_obj: объект обнаруженного чата. 35 | :type chat_obj: :class:`FunPayAPI.types.ChatShortcut` 36 | """ 37 | def __init__(self, runner_tag: str, chat_obj: types.ChatShortcut): 38 | super(InitialChatEvent, self).__init__(runner_tag, EventTypes.INITIAL_CHAT) 39 | self.chat: types.ChatShortcut = chat_obj 40 | """Объект обнаруженного чата.""" 41 | 42 | 43 | class ChatsListChangedEvent(BaseEvent): 44 | """ 45 | Класс события: список чатов и / или содержимое одного / нескольких чатов изменилось. 46 | 47 | :param runner_tag: тег Runner'а. 48 | :type runner_tag: :obj:`str` 49 | """ 50 | def __init__(self, runner_tag: str): 51 | super(ChatsListChangedEvent, self).__init__(runner_tag, EventTypes.CHATS_LIST_CHANGED) 52 | # todo: добавить список всех чатов. 53 | 54 | 55 | class LastChatMessageChangedEvent(BaseEvent): 56 | """ 57 | Класс события: последнее сообщение в чате изменилось. 58 | 59 | :param runner_tag: тег Runner'а. 60 | :type runner_tag: :obj:`str` 61 | 62 | :param chat_obj: объект чата, в котором изменилось полседнее сообщение. 63 | :type chat_obj: :class:`FunPayAPI.types.ChatShortcut` 64 | """ 65 | def __init__(self, runner_tag: str, chat_obj: types.ChatShortcut): 66 | super(LastChatMessageChangedEvent, self).__init__(runner_tag, EventTypes.LAST_CHAT_MESSAGE_CHANGED) 67 | self.chat: types.ChatShortcut = chat_obj 68 | """Объект чата, в котором изменилось полседнее сообщение.""" 69 | 70 | 71 | class NewMessageEvent(BaseEvent): 72 | """ 73 | Класс события: в истории чата обнаружено новое сообщение. 74 | 75 | :param runner_tag: тег Runner'а. 76 | :type runner_tag: :obj:`str` 77 | 78 | :param message_obj: объект нового сообщения. 79 | :type message_obj: :class:`FunPayAPI.types.Message` 80 | 81 | :param stack: объект стэка событий новых собщений. 82 | :type stack: :class:`FunPayAPI.updater.events.MessageEventsStack` or :obj:`None`, опционально 83 | """ 84 | def __init__(self, runner_tag: str, message_obj: types.Message, stack: MessageEventsStack | None = None): 85 | super(NewMessageEvent, self).__init__(runner_tag, EventTypes.NEW_MESSAGE) 86 | self.message: types.Message = message_obj 87 | """Объект нового сообщения.""" 88 | self.stack: MessageEventsStack = stack 89 | """Объект стэка событий новых сообщений.""" 90 | 91 | 92 | class MessageEventsStack: 93 | """ 94 | Данный класс представляет стэк событий новых сообщений. 95 | Нужен для того, чтобы сразу предоставить доступ ко всем событиям новых сообщений от одного пользователя и одного запроса Runner'а. 96 | """ 97 | def __init__(self): 98 | self.__id = utils.random_tag() 99 | self.__stack = [] 100 | 101 | def add_events(self, messages: list[NewMessageEvent]): 102 | """ 103 | Добавляет события новых сообщений в стэк. 104 | 105 | :param messages: список событий новых сообщений. 106 | :type messages: :obj:`list` of :class:`FunPayAPI.updater.events.NewMessageEvent` 107 | """ 108 | self.__stack.extend(messages) 109 | 110 | def get_stack(self) -> list[NewMessageEvent]: 111 | """ 112 | Возвращает стэк событий новых сообщений. 113 | 114 | :return: стэк событий новых сообщений. 115 | :rtype: :obj:`list` of :class:`FunPayAPI.updater.events.NewMessageEvent` 116 | """ 117 | return self.__stack 118 | 119 | def id(self) -> str: 120 | """ 121 | Возвращает ID стэка (ID стега генерируется случайным образом при создании объекта). 122 | 123 | :return: ID стэка. 124 | :rtype: :obj:`str` 125 | """ 126 | return self.__id 127 | 128 | 129 | class InitialOrderEvent(BaseEvent): 130 | """ 131 | Класс события: обнаружен заказ при первом запросе Runner'а. 132 | 133 | :param runner_tag: тег Runner'а. 134 | :type runner_tag: :obj:`str` 135 | 136 | :param order_obj: объект обнаруженного заказа. 137 | :type order_obj: :class:`FunPayAPI.types.OrderShortcut` 138 | """ 139 | def __init__(self, runner_tag: str, order_obj: types.OrderShortcut): 140 | super(InitialOrderEvent, self).__init__(runner_tag, EventTypes.INITIAL_ORDER) 141 | self.order: types.OrderShortcut = order_obj 142 | """Объект обнаруженного заказа.""" 143 | 144 | 145 | class OrdersListChangedEvent(BaseEvent): 146 | """ 147 | Класс события: список заказов и/или статус одного/нескольких заказов изменился. 148 | 149 | :param runner_tag: тег Runner'а. 150 | :type runner_tag: :obj:`str` 151 | 152 | :param purchases: кол-во незавершенных покупок. 153 | :type purchases: :obj:`int` 154 | 155 | :param sales: кол-во незавершенных продаж. 156 | :type sales: :obj:`int` 157 | """ 158 | def __init__(self, runner_tag: str, purchases: int, sales: int): 159 | super(OrdersListChangedEvent, self).__init__(runner_tag, EventTypes.ORDERS_LIST_CHANGED) 160 | self.purchases: int = purchases 161 | """Кол-во незавершенных покупок.""" 162 | self.sales: int = sales 163 | """Кол-во незавершенных продаж.""" 164 | 165 | 166 | class NewOrderEvent(BaseEvent): 167 | """ 168 | Класс события: в списке заказов обнаружен новый заказ. 169 | 170 | :param runner_tag: тег Runner'а. 171 | :type runner_tag: :obj:`str` 172 | 173 | :param order_obj: объект нового заказа. 174 | :type order_obj: :class:`FunPayAPI.types.OrderShortcut` 175 | """ 176 | def __init__(self, runner_tag: str, order_obj: types.OrderShortcut): 177 | super(NewOrderEvent, self).__init__(runner_tag, EventTypes.NEW_ORDER) 178 | self.order: types.OrderShortcut = order_obj 179 | """Объект нового заказа.""" 180 | 181 | 182 | class OrderStatusChangedEvent(BaseEvent): 183 | """ 184 | Класс события: статус заказа изменился. 185 | 186 | :param runner_tag: тег Runner'а. 187 | :type runner_tag: :obj:`str` 188 | 189 | :param order_obj: объект измененного заказа. 190 | :type order_obj: :class:`FunPayAPI.types.OrderShortcut` 191 | """ 192 | def __init__(self, runner_tag: str, order_obj: types.OrderShortcut): 193 | super(OrderStatusChangedEvent, self).__init__(runner_tag, EventTypes.ORDER_STATUS_CHANGED) 194 | self.order: types.OrderShortcut = order_obj 195 | """Объект измененного заказа.""" 196 | -------------------------------------------------------------------------------- /FunPayAPI/updater/runner.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from typing import TYPE_CHECKING, Generator 5 | if TYPE_CHECKING: 6 | from ..account import Account 7 | 8 | import json 9 | import logging 10 | from bs4 import BeautifulSoup 11 | 12 | from ..common import exceptions 13 | from .events import * 14 | 15 | 16 | logger = logging.getLogger("FunPayAPI.runner") 17 | 18 | 19 | class Runner: 20 | """ 21 | Класс для получения новых событий FunPay. 22 | 23 | :param account: экземпляр аккаунта (должен быть инициализирован с помощью метода :meth:`FunPayAPI.account.Account.get`). 24 | :type account: :class:`FunPayAPI.account.Account` 25 | 26 | :param disable_message_requests: отключить ли запросы для получения истории чатов?\n 27 | Если `True`, :meth:`FunPayAPI.updater.runner.Runner.listen` не будет возвращать события 28 | :class:`FunPayAPI.updater.events.NewMessageEvent`.\n 29 | Из событий, связанных с чатами, будут возвращаться только:\n 30 | * :class:`FunPayAPI.updater.events.InitialChatEvent`\n 31 | * :class:`FunPayAPI.updater.events.ChatsListChangedEvent`\n 32 | * :class:`FunPayAPI.updater.events.LastChatMessageChangedEvent`\n 33 | :type disable_message_requests: :obj:`bool`, опционально 34 | 35 | :param disabled_order_requests: отключить ли запросы для получения списка заказов?\n 36 | Если `True`, :meth:`FunPayAPI.updater.runner.Runner.listen` не будет возвращать события 37 | :class:`FunPayAPI.updater.events.InitialOrderEvent`, :class:`FunPayAPI.updater.events.NewOrderEvent`, 38 | :class:`FunPayAPI.updater.events.OrderStatusChangedEvent`.\n 39 | Из событий, связанных с заказами, будет возвращаться только 40 | :class:`FunPayAPI.updater.events.OrdersListChangedEvent`. 41 | :type disabled_order_requests: :obj:`bool`, опционально 42 | """ 43 | def __init__(self, account: Account, disable_message_requests: bool = False, 44 | disabled_order_requests: bool = False): 45 | # todo добавить события и исключение событий о новых покупках (не продажах!) 46 | if not account.is_initiated: 47 | raise exceptions.AccountNotInitiatedError() 48 | if account.runner: 49 | raise Exception("К аккаунту уже привязан Runner!") # todo 50 | 51 | self.make_msg_requests: bool = False if disable_message_requests else True 52 | """Делать ли доп. запросы для получения всех новых сообщений изменившихся чатов?""" 53 | self.make_order_requests: bool = False if disabled_order_requests else True 54 | """Делать ли доп запросы для получения все новых / изменившихся заказов?""" 55 | 56 | self.__first_request = True 57 | self.__last_msg_event_tag = utils.random_tag() 58 | self.__last_order_event_tag = utils.random_tag() 59 | 60 | self.saved_orders: dict[str, types.OrderShortcut] = {} 61 | """Сохраненные состояния заказов ({ID заказа: экземпляр types.OrderShortcut}).""" 62 | 63 | self.last_messages: dict[int, list[str, str | None]] = {} 64 | """ID последний сообщений ({ID чата: (текст сообщения (до 250 символов), время сообщения)}).""" 65 | 66 | self.init_messages: dict[int, str] = {} 67 | """Текста инит. чатов (для generate_new_message_events).""" 68 | 69 | self.by_bot_ids: dict[int, list[int]] = {} 70 | """ID сообщений, отправленных с помощью self.account.send_message ({ID чата: [ID сообщения, ...]}).""" 71 | 72 | self.last_messages_ids: dict[int, int] = {} 73 | """ID последних сообщений в чатах ({ID чата: ID последнего сообщения}).""" 74 | 75 | self.account: Account = account 76 | """Экземпляр аккаунта, к которому привязан Runner.""" 77 | self.account.runner = self 78 | 79 | self.__msg_time_re = re.compile(r"\d{2}:\d{2}") 80 | 81 | def get_updates(self) -> dict: 82 | """ 83 | Запрашивает список событий FunPay. 84 | 85 | :return: ответ FunPay. 86 | :rtype: :obj:`dict` 87 | """ 88 | orders = { 89 | "type": "orders_counters", 90 | "id": self.account.id, 91 | "tag": self.__last_order_event_tag, 92 | "data": False 93 | } 94 | chats = { 95 | "type": "chat_bookmarks", 96 | "id": self.account.id, 97 | "tag": self.__last_msg_event_tag, 98 | "data": False 99 | } 100 | payload = { 101 | "objects": json.dumps([orders, chats]), 102 | "request": False, 103 | "csrf_token": self.account.csrf_token 104 | } 105 | headers = { 106 | "accept": "*/*", 107 | "content-type": "application/x-www-form-urlencoded; charset=UTF-8", 108 | "x-requested-with": "XMLHttpRequest" 109 | } 110 | 111 | response = self.account.method("post", "runner/", headers, payload, raise_not_200=True) 112 | json_response = response.json() 113 | logger.debug(f"Получены данные о событиях: {json_response}") 114 | return json_response 115 | 116 | def parse_updates(self, updates: dict) -> list[InitialChatEvent | ChatsListChangedEvent | 117 | LastChatMessageChangedEvent | NewMessageEvent | InitialOrderEvent | 118 | OrdersListChangedEvent | NewOrderEvent | OrderStatusChangedEvent]: 119 | """ 120 | Парсит ответ FunPay и создает события. 121 | 122 | :param updates: результат выполнения :meth:`FunPayAPI.updater.runner.Runner.get_updates` 123 | :type updates: :obj:`dict` 124 | 125 | :return: список событий. 126 | :rtype: :obj:`list` of :class:`FunPayAPI.updater.events.InitialChatEvent`, 127 | :class:`FunPayAPI.updater.events.ChatsListChangedEvent`, 128 | :class:`FunPayAPI.updater.events.LastChatMessageChangedEvent`, 129 | :class:`FunPayAPI.updater.events.NewMessageEvent`, :class:`FunPayAPI.updater.events.InitialOrderEvent`, 130 | :class:`FunPayAPI.updater.events.OrdersListChangedEvent`, 131 | :class:`FunPayAPI.updater.events.NewOrderEvent`, 132 | :class:`FunPayAPI.updater.events.OrderStatusChangedEvent` 133 | """ 134 | events = [] 135 | for obj in updates["objects"]: 136 | if obj.get("type") == "chat_bookmarks": 137 | events.extend(self.parse_chat_updates(obj)) 138 | elif obj.get("type") == "orders_counters": 139 | events.extend(self.parse_order_updates(obj)) 140 | 141 | if self.__first_request: 142 | self.__first_request = False 143 | return events 144 | 145 | def parse_chat_updates(self, obj) -> list[InitialChatEvent | ChatsListChangedEvent | LastChatMessageChangedEvent | 146 | NewMessageEvent]: 147 | """ 148 | Парсит события, связанные с чатами. 149 | 150 | :param obj: словарь из результата выполнения :meth:`FunPayAPI.updater.runner.Runner.get_updates`, где 151 | "type" == "chat_bookmarks". 152 | :type obj: :obj:`dict` 153 | 154 | :return: список событий, связанных с чатами. 155 | :rtype: :obj:list of :class:`FunPayAPI.updater.events.InitialChatEvent`, 156 | :class:`FunPayAPI.updater.events.ChatsListChangedEvent`, 157 | :class:`FunPayAPI.updater.events.LastChatMessageChangedEvent`, 158 | :class:`FunPayAPI.updater.events.NewMessageEvent` 159 | """ 160 | events, lcmc_events = [], [] 161 | self.__last_msg_event_tag = obj.get("tag") 162 | parser = BeautifulSoup(obj["data"]["html"], "html.parser") 163 | chats = parser.find_all("a", {"class": "contact-item"}) 164 | 165 | # Получаем все изменившиеся чаты 166 | for chat in chats: 167 | chat_id = int(chat["data-id"]) 168 | # Если чат удален админами - скип. 169 | if not (last_msg_text := chat.find("div", {"class": "contact-item-message"})): 170 | continue 171 | 172 | last_msg_text = last_msg_text.text 173 | if last_msg_text.startswith(self.account.bot_character): 174 | last_msg_text = last_msg_text.replace(self.account.bot_character, "", 1) 175 | last_msg_time = chat.find("div", {"class": "contact-item-time"}).text 176 | 177 | # Если текст последнего сообщения совпадает с сохраненным 178 | if chat_id in self.last_messages and self.last_messages[chat_id][0] == last_msg_text: 179 | # Если есть сохраненное время сообщения для данного чата 180 | if self.last_messages[chat_id][1]: 181 | # Если время ласт сообщения не имеет формат ЧЧ:ММ или совпадает с сохраненным - скип чата 182 | if not self.__msg_time_re.fullmatch(last_msg_time) or self.last_messages[chat_id][1] == last_msg_time: 183 | continue 184 | # Если нет сохраненного времени сообщения для данного чата - скип чата 185 | else: 186 | continue 187 | 188 | unread = True if "unread" in chat.get("class") else False 189 | chat_with = chat.find("div", {"class": "media-user-name"}).text 190 | chat_obj = types.ChatShortcut(chat_id, chat_with, last_msg_text, unread, str(chat)) 191 | self.account.add_chats([chat_obj]) 192 | self.last_messages[chat_id] = [last_msg_text, last_msg_time] 193 | 194 | if self.__first_request: 195 | events.append(InitialChatEvent(self.__last_msg_event_tag, chat_obj)) 196 | self.init_messages[chat_id] = last_msg_text 197 | continue 198 | 199 | lcmc_events.append(LastChatMessageChangedEvent(self.__last_msg_event_tag, chat_obj)) 200 | 201 | if lcmc_events: 202 | events.append(ChatsListChangedEvent(self.__last_msg_event_tag)) 203 | 204 | if not self.make_msg_requests: 205 | events.extend(lcmc_events) 206 | return events 207 | 208 | while lcmc_events: 209 | chats_pack = lcmc_events[:10] 210 | del lcmc_events[:10] 211 | chats_data = {i.chat.id: i.chat.name for i in chats_pack} 212 | new_msg_events = self.generate_new_message_events(chats_data) 213 | for i in chats_pack: 214 | events.append(i) 215 | if new_msg_events.get(i.chat.id): 216 | events.extend(new_msg_events[i.chat.id]) 217 | return events 218 | 219 | def generate_new_message_events(self, chats_data: dict[int, str]) -> dict[int, list[NewMessageEvent]]: 220 | """ 221 | Получает историю переданных чатов и генерирует события новых сообщений. 222 | 223 | :param chats_data: ID чатов и никнеймы собеседников (None, если никнейм неизвестен) 224 | Например: {48392847: "SLLMK", 58392098: "Amongus", 38948728: None} 225 | :type chats_data: :obj:`dict` {:obj:`int`: :obj:`str` or :obj:`None`} 226 | 227 | :return: словарь с событиями новых сообщений в формате {ID чата: [список событий]} 228 | :rtype: :obj:`dict` {:obj:`int`: :obj:`list` of :class:`FunPayAPI.updater.events.NewMessageEvent`} 229 | """ 230 | attempts = 3 231 | while attempts: 232 | attempts -= 1 233 | try: 234 | chats = self.account.get_chats_histories(chats_data) 235 | break 236 | except exceptions.RequestFailedError as e: 237 | logger.error(e) 238 | except: 239 | logger.error(f"Не удалось получить истории чатов {list(chats_data.keys())}.") 240 | logger.debug("TRACEBACK", exc_info=True) 241 | time.sleep(1) 242 | else: 243 | logger.error(f"Не удалось получить истории чатов {list(chats_data.keys())}: превышено кол-во попыток.") 244 | return {} 245 | 246 | result = {} 247 | 248 | for cid in chats: 249 | messages = chats[cid] 250 | result[cid] = [] 251 | self.by_bot_ids[cid] = self.by_bot_ids.get(cid) or [] 252 | 253 | # Удаляем все сообщения, у которых ID меньше сохраненного последнего сообщения 254 | if self.last_messages_ids.get(cid): 255 | messages = [i for i in messages if i.id > self.last_messages_ids[cid]] 256 | if not messages: 257 | continue 258 | 259 | # Отмечаем все сообщения, отправленные с помощью Account.send_message() 260 | if self.by_bot_ids.get(cid): 261 | for i in messages: 262 | if not i.by_bot and i.id in self.by_bot_ids[cid]: 263 | i.by_bot = True 264 | 265 | stack = MessageEventsStack() 266 | 267 | # Если нет сохраненного ID последнего сообщения 268 | if not self.last_messages_ids.get(cid): 269 | # Если данный чат был доступен при первом запросе и есть сохраненное последнее сообщение, 270 | # то ищем новые сообщения относительно последнего сохраненного текста 271 | if init_msg_text := self.init_messages.get(cid): 272 | del self.init_messages[cid] 273 | temp = [] 274 | for i in reversed(messages): 275 | if i.text[:250] == init_msg_text: 276 | break 277 | temp.append(i) 278 | messages = list(reversed(temp)) 279 | 280 | # Если данного чата не было при первом запросе, в результат добавляем только ласт сообщение истории. 281 | else: 282 | messages = messages[-1:] 283 | 284 | self.last_messages_ids[cid] = messages[-1].id # Перезаписываем ID последнего сообщение 285 | self.by_bot_ids[cid] = [i for i in self.by_bot_ids[cid] if i > self.last_messages_ids[cid]] # чистим память 286 | 287 | for msg in messages: 288 | event = NewMessageEvent(self.__last_msg_event_tag, msg, stack) 289 | stack.add_events([event]) 290 | result[cid].append(event) 291 | return result 292 | 293 | def parse_order_updates(self, obj) -> list[InitialOrderEvent | OrdersListChangedEvent | NewOrderEvent | 294 | OrderStatusChangedEvent]: 295 | """ 296 | Парсит события, связанные с продажами. 297 | 298 | :param obj: словарь из результата выполнения :meth:`FunPayAPI.updater.runner.Runner.get_updates`, где 299 | "type" == "orders_counters". 300 | :type obj: :obj:`dict` 301 | 302 | :return: список событий, связанных с продажами. 303 | :rtype: :obj:`list` of :class:`FunPayAPI.updater.events.InitialOrderEvent`, 304 | :class:`FunPayAPI.updater.events.OrdersListChangedEvent`, 305 | :class:`FunPayAPI.updater.events.NewOrderEvent`, 306 | :class:`FunPayAPI.updater.events.OrderStatusChangedEvent` 307 | """ 308 | events = [] 309 | self.__last_order_event_tag = obj.get("tag") 310 | if not self.__first_request: 311 | events.append(OrdersListChangedEvent(self.__last_order_event_tag, 312 | obj["data"]["buyer"], obj["data"]["seller"])) 313 | if not self.make_order_requests: 314 | return events 315 | 316 | attempts = 3 317 | while attempts: 318 | attempts -= 1 319 | try: 320 | orders_list = self.account.get_sells() 321 | break 322 | except exceptions.RequestFailedError as e: 323 | logger.error(e) 324 | except: 325 | logger.error("Не удалось обновить список заказов.") 326 | logger.debug("TRACEBACK", exc_info=True) 327 | time.sleep(1) 328 | else: 329 | logger.error("Не удалось обновить список продаж: превышено кол-во попыток.") 330 | return events 331 | 332 | for order in orders_list[1]: 333 | if order.id not in self.saved_orders: 334 | if self.__first_request: 335 | events.append(InitialOrderEvent(self.__last_order_event_tag, order)) 336 | else: 337 | events.append(NewOrderEvent(self.__last_order_event_tag, order)) 338 | if order.status == types.OrderStatuses.CLOSED: 339 | events.append(OrderStatusChangedEvent(self.__last_order_event_tag, order)) 340 | self.update_order(order) 341 | 342 | elif order.status != self.saved_orders[order.id].status: 343 | events.append(OrderStatusChangedEvent(self.__last_order_event_tag, order)) 344 | self.update_order(order) 345 | return events 346 | 347 | def update_last_message(self, chat_id: int, message_text: str | None, message_time: str | None = None): 348 | """ 349 | Обновляет сохраненный текст последнего сообщения чата. 350 | 351 | :param chat_id: ID чата. 352 | :type chat_id: :obj:`int` 353 | 354 | :param message_text: текст сообщения (если `None`, заменяется за "Изображение"). 355 | :type message_text: :obj:`str` or :obj:`None` 356 | 357 | :param message_time: время отправки сообщения в формате ЧЧ:ММ. Используется исключительно Runner'ом. 358 | :type message_time: :obj:`str` or :obj:`None`, опционально 359 | """ 360 | if message_text is None: 361 | message_text = "Изображение" 362 | self.last_messages[chat_id] = [message_text[:250], message_time] 363 | 364 | def update_order(self, order: types.OrderShortcut): 365 | """ 366 | Обновляет сохраненное состояние переданного заказа. 367 | 368 | :param order: экземпляр заказа, который нужно обновить. 369 | :type order: :class:`FunPayAPI.types.OrderShortcut` 370 | """ 371 | self.saved_orders[order.id] = order 372 | 373 | def mark_as_by_bot(self, chat_id: int, message_id: int): 374 | """ 375 | Помечает сообщение с переданным ID, как отправленный с помощью :meth:`FunPayAPI.account.Account.send_message`. 376 | 377 | :param chat_id: ID чата. 378 | :type chat_id: :obj:`int` 379 | 380 | :param message_id: ID сообщения. 381 | :type message_id: :obj:`int` 382 | """ 383 | if self.by_bot_ids.get(chat_id) is None: 384 | self.by_bot_ids[chat_id] = [message_id] 385 | else: 386 | self.by_bot_ids[chat_id].append(message_id) 387 | 388 | def listen(self, requests_delay: int | float = 6.0, 389 | ignore_exceptions: bool = True) -> Generator[InitialChatEvent | ChatsListChangedEvent | 390 | LastChatMessageChangedEvent | NewMessageEvent | 391 | InitialOrderEvent | OrdersListChangedEvent | NewOrderEvent | 392 | OrderStatusChangedEvent]: 393 | """ 394 | Бесконечно отправляет запросы для получения новых событий. 395 | 396 | :param requests_delay: задержка между запросами (в секундах). 397 | :type requests_delay: :obj:`int` or :obj:`float`, опционально 398 | 399 | :param ignore_exceptions: игнорировать ошибки? 400 | :type ignore_exceptions: :obj:`bool`, опционально 401 | 402 | :return: генератор событий FunPay. 403 | :rtype: :obj:`Generator` of :class:`FunPayAPI.updater.events.InitialChatEvent`, 404 | :class:`FunPayAPI.updater.events.ChatsListChangedEvent`, 405 | :class:`FunPayAPI.updater.events.LastChatMessageChangedEvent`, 406 | :class:`FunPayAPI.updater.events.NewMessageEvent`, :class:`FunPayAPI.updater.events.InitialOrderEvent`, 407 | :class:`FunPayAPI.updater.events.OrdersListChangedEvent`, 408 | :class:`FunPayAPI.updater.events.NewOrderEvent`, 409 | :class:`FunPayAPI.updater.events.OrderStatusChangedEvent` 410 | """ 411 | while True: 412 | try: 413 | updates = self.get_updates() 414 | events = self.parse_updates(updates) 415 | for event in events: 416 | yield event 417 | except Exception as e: 418 | if not ignore_exceptions: 419 | raise e 420 | else: 421 | logger.error("Произошла ошибка при получении событий. " 422 | "(ничего страшного, если это сообщение появляется нечасто).") 423 | logger.debug("TRACEBACK", exc_info=True) 424 | time.sleep(requests_delay) 425 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | FunPayAPI is a Python package for simplified work with FunPay API. 635 | Copyright (C) 2023, Woopertail 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | FunPayAPI Copyright (C) 2023, Woopertail 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

FunPay API

3 |

Библиотека для легкого написания ботов FunPay.

4 | 5 |

Важные ссылки

6 |

7 | Telegram чат
8 | Документация
9 | PyPi
10 |

11 | 12 |

Быстрый старт

13 |

Пример простого бота, который будет отвечать на сообщение с текстом «привет».

14 | 15 | ```python 16 | from FunPayAPI import Account, Runner, types, enums 17 | 18 | 19 | TOKEN = "" 20 | 21 | # Создаем класс аккаунта и сразу же получаем данные аккаунта. 22 | acc = Account(TOKEN).get() 23 | 24 | # Создаем класс "прослушивателя" событий. 25 | runner = Runner(acc) 26 | 27 | 28 | # "Слушаем" события 29 | for event in runner.listen(requests_delay=4): 30 | # Если событие - новое сообщение 31 | if event.type is enums.EventTypes.NEW_MESSAGE: 32 | # Если текст сообщения == "привет" и оно отправлено не нами 33 | if event.message.text.lower() == "привет" and event.message.author_id != acc.id: 34 | acc.send_message(event.message.chat_id, "Ну привет...") # отправляем ответное сообщение 35 | ``` 36 | 37 |

Пример простого бота, который выдает товар при новом заказе, если в названии заказа есть слово «аккаунт».

38 | 39 | ```python 40 | from FunPayAPI import Account, Runner, types, enums 41 | 42 | 43 | TOKEN = "" 44 | 45 | # Создаем класс аккаунта и сразу же получаем данные аккаунта. 46 | acc = Account(TOKEN).get() 47 | 48 | # Создаем класс "прослушивателя" событий. 49 | runner = Runner(acc) 50 | 51 | 52 | # "Слушаем" события 53 | for event in runner.listen(requests_delay=4): 54 | # Если событие - новый заказ 55 | if event.type is enums.EventTypes.NEW_ORDER: 56 | # Если "аккаунт" есть в названии заказа 57 | if "аккаунт" in event.order.description: 58 | chat = acc.get_chat_by_name(event.order.buyer_username, True) # получаем ID чата по никнейму 59 | acc.send_message(chat.id, f"Привет, {event.order.buyer_username}!\n" 60 | f"Вот твой аккаунт:\n" 61 | f"Почта: mail@somemail.ru\n" 62 | f"Пароль: somepassword!123") # отправляем ответное сообщение 63 | ``` 64 | 65 | -------------------------------------------------------------------------------- /doc_req.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | furo 4 | git+https://github.com/woopertail/FunPayAPI.git -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/source/HOW_chats.rst: -------------------------------------------------------------------------------- 1 | ========================== 2 | Как работают события чата? 3 | ========================== 4 | 5 | .. meta:: 6 | :description: Как работают события чата? 7 | :keywords: funpay, funpayapi, fpapi, docs, documentation, chats, chat, event, events, чат, чаты, события 8 | 9 | 10 | Для правильного написания программ с использованием FunPayAPI необходимо понимать как именно обнаруживаются изменения в чатах. 11 | Из-за того что FunPay не имеет официального API, этот процесс в некоторых моментах работает контринтуитивно. 12 | 13 | 14 | Первый запрос 15 | ------------- 16 | Метод слушателя событий :meth:`FunPayAPI.updater.runner.Runner.listen` при первом запуске сканирует всех существующие 17 | чаты на аккаунте (не более 50) со страницы https://funpay.com/chat/ и генерирует события 18 | :class:`FunPayAPI.updater.events.InitialChatEvent` для каждого чата. 19 | 20 | Сохраняются текст и дата отправки последнего сообщения каждого чата в словарь :attr:`FunPayAPI.updater.runner.Runner.last_messages` 21 | в следующем формате: 22 | 23 | .. code-block:: python 24 | 25 | { 26 | ID чата (int): [текст последнего сообщения (str) (не более 250 символов), время отправки последнего сообщения (str)], 27 | ID чата (int): [текст последнего сообщения (str) (не более 250 символов), время отправки последнего сообщения (str)], 28 | ID чата (int): [текст последнего сообщения (str) (не более 250 символов), время отправки последнего сообщения (str)], 29 | ... 30 | } 31 | 32 | А так же в отдельный словарь :attr:`FunPayAPI.updater.runner.Runner.init_messages` сохраняются текста последних сообщений 33 | каждого чата: 34 | 35 | .. code-block:: python 36 | 37 | { 38 | ID чата (int): текст последнего сообщения (str) (не более 250 символов) 39 | ID чата (int): текст последнего сообщения (str) (не более 250 символов) 40 | ID чата (int): текст последнего сообщения (str) (не более 250 символов) 41 | ... 42 | } 43 | 44 | Позже вы поймете для чего используется отдельный словарь с текстами последних сообщений. 45 | 46 | # todo добавить фото 47 | 48 | 49 | Обнаружение изменений 50 | --------------------- 51 | -------------------------------------------------------------------------------- /docs/source/_static/FunPayAPI.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LIMBODS/FunPayAPI/adb6848bb015a1aabfa0e87b79f5a54cebbcc663/docs/source/_static/FunPayAPI.png -------------------------------------------------------------------------------- /docs/source/_static/FunPayAPI_darkmode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LIMBODS/FunPayAPI/adb6848bb015a1aabfa0e87b79f5a54cebbcc663/docs/source/_static/FunPayAPI_darkmode.png -------------------------------------------------------------------------------- /docs/source/account.rst: -------------------------------------------------------------------------------- 1 | =========================== 2 | Управление FunPay Аккаунтом 3 | =========================== 4 | 5 | .. meta:: 6 | :description: Управление FunPay аккаунтом. 7 | :keywords: funpay, funpayapi, fpapi, docs, documentation, guide, account, аккаунт 8 | 9 | .. automodule:: FunPayAPI.account 10 | :members: 11 | 12 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | import sys, os 9 | 10 | 11 | sys.path.insert(0, os.path.abspath('../..')) 12 | 13 | 14 | project = 'FunPayAPI' 15 | copyright = '2023, Woopertail' 16 | author = 'Woopertail' 17 | release = '1.1.0' 18 | 19 | # -- General configuration --------------------------------------------------- 20 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 21 | 22 | extensions = ['sphinx.ext.duration', 23 | 'sphinx.ext.doctest', 24 | 'sphinx.ext.autodoc', 25 | 'sphinx.ext.autosummary', 26 | 'sphinx.ext.napoleon', 27 | 'sphinx.ext.intersphinx', ] 28 | 29 | autodoc_member_order = 'bysource' 30 | intersphinx_mapping = {'python': ('https://docs.python.org/3.11', None)} 31 | 32 | templates_path = ['_templates'] 33 | exclude_patterns = [] 34 | 35 | language = 'ru' 36 | 37 | # -- Options for HTML output ------------------------------------------------- 38 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 39 | 40 | html_theme = 'furo' 41 | html_static_path = ['_static'] 42 | html_theme_options = { 43 | "light_logo": "FunPayAPI.png", 44 | "dark_logo": "FunPayAPI_darkmode.png", 45 | } 46 | -------------------------------------------------------------------------------- /docs/source/enums.rst: -------------------------------------------------------------------------------- 1 | ================ 2 | FunPayAPI Enum's 3 | ================ 4 | 5 | .. meta:: 6 | :description: Перечисления (Enum's) FunPayAPI 7 | :keywords: funpay, funpayapi, fpapi, docs, documentation, guide, enum, enums, перечисления 8 | 9 | .. automodule:: FunPayAPI.common.enums 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/source/events.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | События FunPayAPI 3 | ================= 4 | 5 | .. meta:: 6 | :description: События FunPayAPI 7 | :keywords: funpay, funpayapi, fpapi, docs, documentation, guide, events, события 8 | 9 | .. automodule:: FunPayAPI.updater.events 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. FunPayAPI documentation master file, created by 2 | sphinx-quickstart on Thu May 11 16:39:34 2023. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | 7 | Добро пожаловать на страницу документации FunPayAPI 8 | =================================================== 9 | 10 | .. meta:: 11 | :description: Официальная документация пакета FunPayAPI 12 | :keywords: funpay, funpayapi, fpapi, docs, documentation, guide 13 | 14 | 15 | ========= 16 | FunPayAPI 17 | ========= 18 | FunPayAPI - это неофициальная реализация прослойки между API `FunPay `_ и клиентом. 19 | 20 | ====== 21 | Ссылки 22 | ====== 23 | FunPayAPI & FPC Telegram чат: `@funpay_cardinal `_ 24 | 25 | PyPi: `PyPi `_ 26 | 27 | GitHub: `GitHub `_ 28 | 29 | 30 | ========== 31 | Содержание 32 | ========== 33 | 34 | .. toctree:: 35 | :maxdepth: 3 36 | 37 | install 38 | quickstart 39 | HOW_chats 40 | account 41 | runner 42 | types 43 | events 44 | enums 45 | -------------------------------------------------------------------------------- /docs/source/install.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Установка 3 | ========= 4 | 5 | .. meta:: 6 | :description: Установка FunPayAPI 7 | :keywords: funpay, funpayapi, fpapi, docs, documentation, guide, установка, install 8 | 9 | 10 | С использованием PIP 11 | -------------------- 12 | .. code-block:: bash 13 | 14 | $ pip install FunPayAPI 15 | 16 | С использованием pipenv 17 | ----------------------- 18 | .. code-block:: bash 19 | 20 | $ pipenv install FunPayAPI 21 | 22 | Клонирование Git репозитория 23 | ---------------------------- 24 | .. code-block:: bash 25 | 26 | $ git clone https://github.com/woopertail/FunPayAPI.git 27 | $ cd FunPayAPI 28 | $ python setup.py install 29 | 30 | Напрямую, используя PIP 31 | ----------------------- 32 | .. code-block:: bash 33 | 34 | $ pip install git+https://github.com/woopertail/FunPayAPI.git 35 | 36 | 37 | .. Hint:: 38 | 39 | Рекомендуется использовать первый вариант установки. 40 | 41 | .. Caution:: 42 | 43 | Хотя FunPayAPI готов к использованию, он все еще находится в стадии разработки и постоянно обновляется. Не забывайте регулярно обновлять пакет FunPayAPI с помощью команды: 44 | 45 | .. code-block:: bash 46 | 47 | $ pip install FunPayAPI --upgrade 48 | 49 | 50 | -------------------------------------------------------------------------------- /docs/source/quickstart.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | Быстрый страт 3 | ============= 4 | 5 | .. meta:: 6 | :description: Быстрый старт 7 | :keywords: funpay, funpayapi, fpapi, docs, documentation, guide, quickstart, быстрый старт 8 | 9 | 10 | Пример простого бота, который будет отвечать на сообщение с текстом "привет". 11 | """"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" 12 | 13 | .. code:: python 14 | 15 | from FunPayAPI import Account, Runner, types, enums 16 | 17 | 18 | TOKEN = "" 19 | 20 | # Создаем класс аккаунта и сразу же получаем данные аккаунта. 21 | acc = Account(TOKEN).get() 22 | 23 | # Создаем класс "прослушивателя" событий. 24 | runner = Runner(acc) 25 | 26 | 27 | # "Слушаем" события 28 | for event in runner.listen(requests_delay=4): 29 | # Если событие - новое сообщение 30 | if event.type is enums.EventTypes.NEW_MESSAGE: 31 | # Если текст сообщения == "привет" и оно отправлено не нами 32 | if event.message.text.lower() == "привет" and event.message.author_id != acc.id: 33 | acc.send_message(event.message.chat_id, "Ну привет...") # отправляем ответное сообщение 34 | 35 | 36 | Пример простого бота, который выдает товар при новом заказе, если в названии заказа есть слово "аккаунт". 37 | """"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" 38 | 39 | .. code:: python 40 | 41 | from FunPayAPI import Account, Runner, types, enums 42 | 43 | 44 | TOKEN = "" 45 | 46 | # Создаем класс аккаунта и сразу же получаем данные аккаунта. 47 | acc = Account(TOKEN).get() 48 | 49 | # Создаем класс "прослушивателя" событий. 50 | runner = Runner(acc) 51 | 52 | 53 | # "Слушаем" события 54 | for event in runner.listen(requests_delay=4): 55 | # Если событие - новый заказ 56 | if event.type is enums.EventTypes.NEW_ORDER: 57 | # Если "аккаунт" есть в названии заказа 58 | if "аккаунт" in event.order.description: 59 | chat = acc.get_chat_by_name(event.order.buyer_username, True) # получаем ID чата по никнейму 60 | acc.send_message(chat.id, f"Привет, {event.order.buyer_username}!\n" 61 | f"Вот твой аккаунт:\n" 62 | f"Почта: mail@somemail.ru\n" 63 | f"Пароль: somepassword!123") # отправляем ответное сообщение -------------------------------------------------------------------------------- /docs/source/runner.rst: -------------------------------------------------------------------------------- 1 | ======================== 2 | Получение событий FunPay 3 | ======================== 4 | 5 | .. meta:: 6 | :description: Получение событий FunPay 7 | :keywords: funpay, funpayapi, fpapi, docs, documentation, guide, events, событий, runner 8 | 9 | .. automodule:: FunPayAPI.updater.runner 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/source/types.rst: -------------------------------------------------------------------------------- 1 | ============== 2 | Типы FunPayAPI 3 | ============== 4 | 5 | .. meta:: 6 | :description: Типы FunPayAPI 7 | :keywords: funpay, funpayapi, fpapi, docs, documentation, guide, types, типы 8 | 9 | .. automodule:: FunPayAPI.types 10 | :members: 11 | 12 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | beautifulsoup4>=4.11.1 2 | requests==2.28.1 3 | requests_toolbelt==0.10.1 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | 4 | with open("README.md", "r", encoding="utf-8") as f: 5 | long_desc = f.read() 6 | 7 | 8 | setup(name='FunPayAPI', 9 | version="1.1.0", 10 | description='Прослойка между FunPayAPI и клиентом.', 11 | long_description=long_desc, 12 | long_description_content_type="text/markdown", 13 | author='Woopertail', 14 | author_email='woopertail@gmail.com', 15 | url='https://github.com/woopertail/FunPayAPI', 16 | packages=find_packages("."), 17 | license='GPL3', 18 | keywords='funpay bot api tools', 19 | install_requires=['requests==2.28.1', 'beautifulsoup4', 'requests_toolbelt==0.10.1'], 20 | classifiers=[ 21 | 'Development Status :: 5 - Production/Stable', 22 | 'Programming Language :: Python :: 3', 23 | 'Environment :: Console', 24 | 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 25 | ] 26 | ) 27 | --------------------------------------------------------------------------------