maxapi-python 0.1.1__tar.gz → 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- maxapi_python-0.1.2/.github/FUNDING.yml +15 -0
- {maxapi_python-0.1.1 → maxapi_python-0.1.2}/.gitignore +1 -0
- {maxapi_python-0.1.1 → maxapi_python-0.1.2}/PKG-INFO +20 -4
- {maxapi_python-0.1.1 → maxapi_python-0.1.2}/README.md +14 -2
- {maxapi_python-0.1.1 → maxapi_python-0.1.2}/examples/example.py +22 -9
- {maxapi_python-0.1.1 → maxapi_python-0.1.2}/pyproject.toml +11 -4
- maxapi_python-0.1.2/scripts/build.py +47 -0
- maxapi_python-0.1.2/src/pymax/core.py +156 -0
- maxapi_python-0.1.2/src/pymax/files.py +85 -0
- maxapi_python-0.1.2/src/pymax/filters.py +38 -0
- maxapi_python-0.1.2/src/pymax/interfaces.py +67 -0
- maxapi_python-0.1.2/src/pymax/mixins/__init__.py +18 -0
- maxapi_python-0.1.2/src/pymax/mixins/auth.py +81 -0
- maxapi_python-0.1.2/src/pymax/mixins/channel.py +25 -0
- maxapi_python-0.1.2/src/pymax/mixins/group.py +220 -0
- maxapi_python-0.1.2/src/pymax/mixins/handler.py +60 -0
- maxapi_python-0.1.2/src/pymax/mixins/message.py +293 -0
- maxapi_python-0.1.2/src/pymax/mixins/self.py +38 -0
- maxapi_python-0.1.2/src/pymax/mixins/user.py +82 -0
- maxapi_python-0.1.2/src/pymax/mixins/websocket.py +242 -0
- maxapi_python-0.1.2/src/pymax/payloads.py +175 -0
- maxapi_python-0.1.2/src/pymax/static.py +210 -0
- {maxapi_python-0.1.1 → maxapi_python-0.1.2}/src/pymax/types.py +122 -27
- maxapi_python-0.1.2/src/pymax/utils.py +38 -0
- maxapi_python-0.1.1/readme.md +0 -129
- maxapi_python-0.1.1/src/pymax/core.py +0 -688
- maxapi_python-0.1.1/src/pymax/payloads.py +0 -86
- maxapi_python-0.1.1/src/pymax/static.py +0 -86
- {maxapi_python-0.1.1 → maxapi_python-0.1.2}/.github/workflows/publish.yml +0 -0
- {maxapi_python-0.1.1 → maxapi_python-0.1.2}/LICENSE +0 -0
- {maxapi_python-0.1.1 → maxapi_python-0.1.2}/assets/icon.svg +0 -0
- {maxapi_python-0.1.1 → maxapi_python-0.1.2}/assets/logo.svg +0 -0
- {maxapi_python-0.1.1 → maxapi_python-0.1.2}/docs/api.md +0 -0
- {maxapi_python-0.1.1 → maxapi_python-0.1.2}/docs/assets/icon.svg +0 -0
- {maxapi_python-0.1.1 → maxapi_python-0.1.2}/docs/examples.md +0 -0
- {maxapi_python-0.1.1 → maxapi_python-0.1.2}/docs/index.md +0 -0
- {maxapi_python-0.1.1 → maxapi_python-0.1.2}/mkdocs.yml +0 -0
- {maxapi_python-0.1.1 → maxapi_python-0.1.2}/ruff.toml +0 -0
- {maxapi_python-0.1.1 → maxapi_python-0.1.2}/src/pymax/__init__.py +0 -0
- {maxapi_python-0.1.1 → maxapi_python-0.1.2}/src/pymax/crud.py +0 -0
- {maxapi_python-0.1.1 → maxapi_python-0.1.2}/src/pymax/exceptions.py +0 -0
- {maxapi_python-0.1.1 → maxapi_python-0.1.2}/src/pymax/models.py +0 -0
@@ -0,0 +1,15 @@
|
|
1
|
+
# These are supported funding model platforms
|
2
|
+
|
3
|
+
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
4
|
+
patreon: # Replace with a single Patreon username
|
5
|
+
open_collective: # Replace with a single Open Collective username
|
6
|
+
ko_fi: # Replace with a single Ko-fi username
|
7
|
+
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
8
|
+
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
9
|
+
liberapay: # Replace with a single Liberapay username
|
10
|
+
issuehunt: # Replace with a single IssueHunt username
|
11
|
+
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
12
|
+
polar: # Replace with a single Polar username
|
13
|
+
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
14
|
+
thanks_dev: # Replace with a single thanks.dev username
|
15
|
+
custom: ['https://www.donationalerts.com/r/pymax']
|
@@ -1,17 +1,21 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: maxapi-python
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.2
|
4
4
|
Summary: Python wrapper для API мессенджера Max
|
5
5
|
Project-URL: Homepage, https://github.com/noxzion/PyMax
|
6
6
|
Project-URL: Repository, https://github.com/noxzion/PyMax
|
7
7
|
Project-URL: Issues, https://github.com/noxzion/PyMax/issues
|
8
|
-
Author-email: noxzion <
|
8
|
+
Author-email: noxzion <mail@gmail.com>
|
9
9
|
License-Expression: MIT
|
10
10
|
License-File: LICENSE
|
11
11
|
Keywords: api,max,messenger,websocket,wrapper
|
12
12
|
Classifier: Operating System :: OS Independent
|
13
13
|
Classifier: Programming Language :: Python :: 3
|
14
14
|
Requires-Python: >=3.10
|
15
|
+
Requires-Dist: aiofiles>=24.1.0
|
16
|
+
Requires-Dist: aiohttp>=3.12.15
|
17
|
+
Requires-Dist: lz4>=4.4.4
|
18
|
+
Requires-Dist: msgpack>=1.1.1
|
15
19
|
Requires-Dist: sqlmodel>=0.0.24
|
16
20
|
Requires-Dist: websockets>=11.0
|
17
21
|
Description-Content-Type: text/markdown
|
@@ -31,6 +35,14 @@ Description-Content-Type: text/markdown
|
|
31
35
|
<img src="https://img.shields.io/badge/packaging-uv-D7FF64.svg" alt="Packaging">
|
32
36
|
</p>
|
33
37
|
|
38
|
+
---
|
39
|
+
> ⚠️ **Дисклеймер**
|
40
|
+
>
|
41
|
+
> * Это **неофициальная** библиотека для работы с внутренним API Max.
|
42
|
+
> * Использование может **нарушать условия предоставления услуг** сервиса.
|
43
|
+
> * **Вы используете её исключительно на свой страх и риск.**
|
44
|
+
> * **Разработчики и контрибьюторы не несут никакой ответственности** за любые последствия использования этого пакета, включая, но не ограничиваясь: блокировку аккаунтов, утерю данных, юридические риски и любые другие проблемы.
|
45
|
+
> * API может быть изменен в любой момент без предупреждения.
|
34
46
|
---
|
35
47
|
|
36
48
|
## Описание
|
@@ -74,7 +86,7 @@ phone = "+1234567890"
|
|
74
86
|
client = MaxClient(phone=phone, work_dir="cache")
|
75
87
|
|
76
88
|
# Обработчик входящих сообщений
|
77
|
-
@client.on_message
|
89
|
+
@client.on_message()
|
78
90
|
async def handle_message(message: Message) -> None:
|
79
91
|
print(f"{message.sender}: {message.text}")
|
80
92
|
|
@@ -133,12 +145,16 @@ if __name__ == "__main__":
|
|
133
145
|
|
134
146
|
## Документация
|
135
147
|
|
136
|
-
WIP
|
148
|
+
[WIP](https://noxzion.github.io/)
|
137
149
|
|
138
150
|
## Лицензия
|
139
151
|
|
140
152
|
Этот проект распространяется под лицензией MIT. См. файл [LICENSE](LICENSE) для получения информации.
|
141
153
|
|
154
|
+
## Новости
|
155
|
+
|
156
|
+
[Telegram](https://t.me/pymax_news)
|
157
|
+
|
142
158
|
## Авторы
|
143
159
|
|
144
160
|
- **[noxzion](https://github.com/noxzion)** — оригинальный автор проекта
|
@@ -13,6 +13,14 @@
|
|
13
13
|
<img src="https://img.shields.io/badge/packaging-uv-D7FF64.svg" alt="Packaging">
|
14
14
|
</p>
|
15
15
|
|
16
|
+
---
|
17
|
+
> ⚠️ **Дисклеймер**
|
18
|
+
>
|
19
|
+
> * Это **неофициальная** библиотека для работы с внутренним API Max.
|
20
|
+
> * Использование может **нарушать условия предоставления услуг** сервиса.
|
21
|
+
> * **Вы используете её исключительно на свой страх и риск.**
|
22
|
+
> * **Разработчики и контрибьюторы не несут никакой ответственности** за любые последствия использования этого пакета, включая, но не ограничиваясь: блокировку аккаунтов, утерю данных, юридические риски и любые другие проблемы.
|
23
|
+
> * API может быть изменен в любой момент без предупреждения.
|
16
24
|
---
|
17
25
|
|
18
26
|
## Описание
|
@@ -56,7 +64,7 @@ phone = "+1234567890"
|
|
56
64
|
client = MaxClient(phone=phone, work_dir="cache")
|
57
65
|
|
58
66
|
# Обработчик входящих сообщений
|
59
|
-
@client.on_message
|
67
|
+
@client.on_message()
|
60
68
|
async def handle_message(message: Message) -> None:
|
61
69
|
print(f"{message.sender}: {message.text}")
|
62
70
|
|
@@ -115,12 +123,16 @@ if __name__ == "__main__":
|
|
115
123
|
|
116
124
|
## Документация
|
117
125
|
|
118
|
-
WIP
|
126
|
+
[WIP](https://noxzion.github.io/)
|
119
127
|
|
120
128
|
## Лицензия
|
121
129
|
|
122
130
|
Этот проект распространяется под лицензией MIT. См. файл [LICENSE](LICENSE) для получения информации.
|
123
131
|
|
132
|
+
## Новости
|
133
|
+
|
134
|
+
[Telegram](https://t.me/pymax_news)
|
135
|
+
|
124
136
|
## Авторы
|
125
137
|
|
126
138
|
- **[noxzion](https://github.com/noxzion)** — оригинальный автор проекта
|
@@ -1,6 +1,8 @@
|
|
1
1
|
import asyncio
|
2
2
|
|
3
3
|
from pymax import MaxClient, Message
|
4
|
+
from pymax.files import Photo
|
5
|
+
from pymax.filters import Filter
|
4
6
|
|
5
7
|
phone = "+1234567890"
|
6
8
|
|
@@ -33,7 +35,7 @@ async def main() -> None:
|
|
33
35
|
await client.close()
|
34
36
|
|
35
37
|
|
36
|
-
@client.on_message
|
38
|
+
@client.on_message(filter=Filter(text=["Привет"]))
|
37
39
|
async def handle_message(message: Message) -> None:
|
38
40
|
print(str(message.sender) + ": " + message.text)
|
39
41
|
|
@@ -41,14 +43,25 @@ async def handle_message(message: Message) -> None:
|
|
41
43
|
@client.on_start
|
42
44
|
async def handle_start() -> None:
|
43
45
|
print("Client started successfully!")
|
44
|
-
history = await client.fetch_history(chat_id=0)
|
45
|
-
if history:
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
46
|
+
# history = await client.fetch_history(chat_id=0)
|
47
|
+
# if history:
|
48
|
+
# for message in history:
|
49
|
+
# user_id = message.sender
|
50
|
+
# user = await client.get_user(user_id)
|
51
|
+
|
52
|
+
# if user:
|
53
|
+
# print(f"{user.names[0].name}: {message.text}")
|
54
|
+
|
55
|
+
# print(client.me.names[0].first_name)
|
56
|
+
# user = await client.get_user(client.me.id)
|
57
|
+
|
58
|
+
# print(user.names[0].first_name)
|
59
|
+
photo1 = Photo(path="tests/test.jpeg")
|
60
|
+
photo2 = Photo(path="tests/test.jpg")
|
61
|
+
|
62
|
+
await client.send_message(
|
63
|
+
"Hello with photo!", chat_id=0, photos=[photo1, photo2], notify=True
|
64
|
+
)
|
52
65
|
|
53
66
|
|
54
67
|
if __name__ == "__main__":
|
@@ -1,11 +1,11 @@
|
|
1
1
|
[project]
|
2
2
|
name = "maxapi-python"
|
3
|
-
version = "0.1.
|
3
|
+
version = "0.1.2"
|
4
4
|
description = "Python wrapper для API мессенджера Max"
|
5
5
|
readme = "README.md"
|
6
6
|
requires-python = ">=3.10"
|
7
7
|
authors = [
|
8
|
-
{ name = "noxzion", email = "
|
8
|
+
{ name = "noxzion", email = "mail@gmail.com" }
|
9
9
|
]
|
10
10
|
license = "MIT"
|
11
11
|
keywords = ["max", "messenger", "api", "wrapper", "websocket"]
|
@@ -17,6 +17,10 @@ classifiers = [
|
|
17
17
|
dependencies = [
|
18
18
|
"sqlmodel>=0.0.24",
|
19
19
|
"websockets>=11.0",
|
20
|
+
"msgpack>=1.1.1",
|
21
|
+
"lz4>=4.4.4",
|
22
|
+
"aiohttp>=3.12.15",
|
23
|
+
"aiofiles>=24.1.0",
|
20
24
|
]
|
21
25
|
|
22
26
|
[project.urls]
|
@@ -28,8 +32,8 @@ Issues = "https://github.com/noxzion/PyMax/issues"
|
|
28
32
|
requires = ["hatchling"]
|
29
33
|
build-backend = "hatchling.build"
|
30
34
|
|
31
|
-
[tool.
|
32
|
-
|
35
|
+
[tool.setuptools.packages.find]
|
36
|
+
where = ["src"]
|
33
37
|
|
34
38
|
[tool.setuptools.package-dir]
|
35
39
|
"" = "src"
|
@@ -41,3 +45,6 @@ dev = [
|
|
41
45
|
"mkdocstrings[python]>=0.30.0",
|
42
46
|
"pydocstring>=0.2.1",
|
43
47
|
]
|
48
|
+
|
49
|
+
[tool.hatch.build.targets.wheel]
|
50
|
+
packages = ["src/pymax"]
|
@@ -0,0 +1,47 @@
|
|
1
|
+
import argparse
|
2
|
+
import shutil
|
3
|
+
import subprocess
|
4
|
+
import sys
|
5
|
+
from pathlib import Path
|
6
|
+
|
7
|
+
|
8
|
+
def build_package(clean: bool = False):
|
9
|
+
script_dir = Path(__file__).resolve().parent
|
10
|
+
|
11
|
+
project_root = script_dir.parent.resolve()
|
12
|
+
|
13
|
+
dist_path = project_root / "dist"
|
14
|
+
|
15
|
+
if clean and dist_path.exists() and dist_path.is_dir():
|
16
|
+
shutil.rmtree(dist_path)
|
17
|
+
|
18
|
+
try:
|
19
|
+
subprocess.run([sys.executable, "-m", "build"], check=True)
|
20
|
+
|
21
|
+
except subprocess.CalledProcessError as e:
|
22
|
+
print(f"Build failed: {e}")
|
23
|
+
|
24
|
+
sys.exit()
|
25
|
+
|
26
|
+
if dist_path.exists() and dist_path.is_dir():
|
27
|
+
print("Build successful. Files in dist:")
|
28
|
+
|
29
|
+
for f in dist_path.iterdir():
|
30
|
+
print(" ", f.name)
|
31
|
+
|
32
|
+
else:
|
33
|
+
print("Build failed.")
|
34
|
+
|
35
|
+
|
36
|
+
def main():
|
37
|
+
parser = argparse.ArgumentParser(description="Build the pymax Python package")
|
38
|
+
parser.add_argument(
|
39
|
+
"-c", "--clean", action="store_true", help="Clean previous builds before building."
|
40
|
+
)
|
41
|
+
|
42
|
+
args = parser.parse_args()
|
43
|
+
build_package(clean=args.clean)
|
44
|
+
|
45
|
+
|
46
|
+
if __name__ == "__main__":
|
47
|
+
main()
|
@@ -0,0 +1,156 @@
|
|
1
|
+
import asyncio
|
2
|
+
import json
|
3
|
+
import logging
|
4
|
+
import re
|
5
|
+
import time
|
6
|
+
from collections.abc import Awaitable, Callable
|
7
|
+
from pathlib import Path
|
8
|
+
from typing import TYPE_CHECKING, Any
|
9
|
+
|
10
|
+
import websockets
|
11
|
+
|
12
|
+
from .crud import Database
|
13
|
+
from .exceptions import InvalidPhoneError, WebSocketNotConnectedError
|
14
|
+
from .mixins import ApiMixin, WebSocketMixin
|
15
|
+
from .payloads import (
|
16
|
+
BaseWebSocketMessage,
|
17
|
+
SyncPayload,
|
18
|
+
)
|
19
|
+
from .static import ChatType, Constants, Opcode
|
20
|
+
from .types import Channel, Chat, Dialog, Me, Message, User, override
|
21
|
+
|
22
|
+
if TYPE_CHECKING:
|
23
|
+
from .filters import Filter
|
24
|
+
|
25
|
+
logger = logging.getLogger(__name__)
|
26
|
+
|
27
|
+
|
28
|
+
class MaxClient(ApiMixin, WebSocketMixin):
|
29
|
+
"""
|
30
|
+
Основной клиент для работы с WebSocket API сервиса Max.
|
31
|
+
|
32
|
+
|
33
|
+
Args:
|
34
|
+
phone (str): Номер телефона для авторизации.
|
35
|
+
uri (str, optional): URI WebSocket сервера. По умолчанию Constants.WEBSOCKET_URI.value.
|
36
|
+
work_dir (str, optional): Рабочая директория для хранения базы данных. По умолчанию ".".
|
37
|
+
logger (logging.Logger | None): Пользовательский логгер. Если не передан — используется
|
38
|
+
логгер модуля с именем f"{__name__}.MaxClient".
|
39
|
+
|
40
|
+
Raises:
|
41
|
+
InvalidPhoneError: Если формат номера телефона неверный.
|
42
|
+
"""
|
43
|
+
|
44
|
+
def __init__(
|
45
|
+
self,
|
46
|
+
phone: str,
|
47
|
+
uri: str = Constants.WEBSOCKET_URI.value,
|
48
|
+
headers: dict[str, Any] | None = Constants.DEFAULT_USER_AGENT.value,
|
49
|
+
token: str | None = None,
|
50
|
+
work_dir: str = ".",
|
51
|
+
logger: logging.Logger | None = None,
|
52
|
+
) -> None:
|
53
|
+
self.uri: str = uri
|
54
|
+
self.is_connected: bool = False
|
55
|
+
self.phone: str = phone
|
56
|
+
self.chats: list[Chat] = []
|
57
|
+
self.dialogs: list[Dialog] = []
|
58
|
+
self.channels: list[Channel] = []
|
59
|
+
self.me: Me | None = None
|
60
|
+
self._users: dict[int, User] = {}
|
61
|
+
if not self._check_phone():
|
62
|
+
raise InvalidPhoneError(self.phone)
|
63
|
+
self._work_dir: str = work_dir
|
64
|
+
self._database_path: Path = Path(work_dir) / "session.db"
|
65
|
+
self._database_path.parent.mkdir(parents=True, exist_ok=True)
|
66
|
+
self._database_path.touch(exist_ok=True)
|
67
|
+
self._database = Database(self._work_dir)
|
68
|
+
self._ws: websockets.ClientConnection | None = None
|
69
|
+
self._seq: int = 0
|
70
|
+
self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
71
|
+
self._recv_task: asyncio.Task[Any] | None = None
|
72
|
+
self._incoming: asyncio.Queue[dict[str, Any]] | None = None
|
73
|
+
self._device_id = self._database.get_device_id()
|
74
|
+
self._token = self._database.get_auth_token() or token
|
75
|
+
self.user_agent = headers
|
76
|
+
self._on_message_handlers: list[
|
77
|
+
tuple[Callable[[Message], Any], Filter | None]
|
78
|
+
] = []
|
79
|
+
self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
|
80
|
+
self._background_tasks: set[asyncio.Task[Any]] = set()
|
81
|
+
self.logger = logger or logging.getLogger(f"{__name__}.MaxClient")
|
82
|
+
self._setup_logger()
|
83
|
+
|
84
|
+
self.logger.debug(
|
85
|
+
"Initialized MaxClient uri=%s work_dir=%s", self.uri, self._work_dir
|
86
|
+
)
|
87
|
+
|
88
|
+
def _setup_logger(self) -> None:
|
89
|
+
self.logger.setLevel(logging.INFO)
|
90
|
+
|
91
|
+
if not logger.handlers:
|
92
|
+
handler = logging.StreamHandler()
|
93
|
+
formatter = logging.Formatter(
|
94
|
+
"%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
95
|
+
)
|
96
|
+
handler.setFormatter(formatter)
|
97
|
+
logger.addHandler(handler)
|
98
|
+
|
99
|
+
async def close(self) -> None:
|
100
|
+
try:
|
101
|
+
self.logger.info("Closing client")
|
102
|
+
if self._recv_task:
|
103
|
+
self._recv_task.cancel()
|
104
|
+
try:
|
105
|
+
await self._recv_task
|
106
|
+
except asyncio.CancelledError:
|
107
|
+
self.logger.debug("recv_task cancelled")
|
108
|
+
if self._ws:
|
109
|
+
await self._ws.close()
|
110
|
+
self.is_connected = False
|
111
|
+
self.logger.info("Client closed")
|
112
|
+
except Exception:
|
113
|
+
self.logger.exception("Error closing client")
|
114
|
+
|
115
|
+
async def start(self) -> None:
|
116
|
+
"""
|
117
|
+
Запускает клиент, подключается к WebSocket, авторизует
|
118
|
+
пользователя (если нужно) и запускает фоновый цикл.
|
119
|
+
"""
|
120
|
+
try:
|
121
|
+
self.logger.info("Client starting")
|
122
|
+
await self._connect(self.user_agent)
|
123
|
+
|
124
|
+
if self._token and self._database.get_auth_token() is None:
|
125
|
+
self._database.update_auth_token(self._device_id, self._token)
|
126
|
+
|
127
|
+
if self._token is None:
|
128
|
+
await self._login()
|
129
|
+
else:
|
130
|
+
await self._sync()
|
131
|
+
|
132
|
+
if self._on_start_handler:
|
133
|
+
self.logger.debug("Calling on_start handler")
|
134
|
+
result = self._on_start_handler()
|
135
|
+
if asyncio.iscoroutine(result):
|
136
|
+
await result
|
137
|
+
|
138
|
+
if self._ws:
|
139
|
+
ping_task = asyncio.create_task(self._send_interactive_ping())
|
140
|
+
self._background_tasks.add(ping_task)
|
141
|
+
ping_task.add_done_callback(
|
142
|
+
lambda t: self._background_tasks.discard(t)
|
143
|
+
or self._log_task_exception(t)
|
144
|
+
)
|
145
|
+
|
146
|
+
try:
|
147
|
+
await self._ws.wait_closed()
|
148
|
+
except asyncio.CancelledError:
|
149
|
+
self.logger.debug("wait_closed cancelled")
|
150
|
+
except Exception:
|
151
|
+
self.logger.exception("Client start failed")
|
152
|
+
|
153
|
+
|
154
|
+
class SocketMaxClient:
|
155
|
+
pass # нокс займись
|
156
|
+
# нет не займусь
|
@@ -0,0 +1,85 @@
|
|
1
|
+
import mimetypes
|
2
|
+
from abc import ABC, abstractmethod
|
3
|
+
from pathlib import Path
|
4
|
+
from typing import ClassVar, override
|
5
|
+
|
6
|
+
from aiofiles import open as aio_open
|
7
|
+
from aiohttp import ClientSession
|
8
|
+
|
9
|
+
|
10
|
+
class BaseFile(ABC):
|
11
|
+
def __init__(self, url: str | None = None, path: str | None = None) -> None:
|
12
|
+
self.url = url
|
13
|
+
self.path = path
|
14
|
+
|
15
|
+
if self.url is None and self.path is None:
|
16
|
+
raise ValueError("Either url or path must be provided.")
|
17
|
+
|
18
|
+
if self.url and self.path:
|
19
|
+
raise ValueError("Only one of url or path must be provided.")
|
20
|
+
|
21
|
+
@abstractmethod
|
22
|
+
async def read(self) -> bytes:
|
23
|
+
if self.url:
|
24
|
+
async with ClientSession() as session, session.get(self.url) as response:
|
25
|
+
response.raise_for_status()
|
26
|
+
return await response.read()
|
27
|
+
elif self.path:
|
28
|
+
async with aio_open(self.path, "rb") as f:
|
29
|
+
return await f.read()
|
30
|
+
else:
|
31
|
+
raise ValueError("Either url or path must be provided.")
|
32
|
+
|
33
|
+
|
34
|
+
class Photo(BaseFile):
|
35
|
+
ALLOWED_EXTENSIONS: ClassVar[set[str]] = {
|
36
|
+
".jpg",
|
37
|
+
".jpeg",
|
38
|
+
".png",
|
39
|
+
".gif",
|
40
|
+
".webp",
|
41
|
+
".bmp",
|
42
|
+
} # FIXME: костыль ✅
|
43
|
+
|
44
|
+
def __init__(self, url: str | None = None, path: str | None = None) -> None:
|
45
|
+
super().__init__(url, path)
|
46
|
+
|
47
|
+
def validate_photo(self) -> tuple[str, str] | None:
|
48
|
+
if self.path:
|
49
|
+
extension = Path(self.path).suffix.lower()
|
50
|
+
if extension not in self.ALLOWED_EXTENSIONS:
|
51
|
+
raise ValueError(
|
52
|
+
f"Invalid photo extension: {extension}. Allowed: {self.ALLOWED_EXTENSIONS}"
|
53
|
+
)
|
54
|
+
|
55
|
+
return (extension[1:], ("image/" + extension[1:]).lower())
|
56
|
+
elif self.url:
|
57
|
+
extension = Path(self.url).suffix.lower()
|
58
|
+
if extension in self.ALLOWED_EXTENSIONS:
|
59
|
+
raise ValueError(
|
60
|
+
f"Invalid photo extension in URL: {extension}. Allowed: {self.ALLOWED_EXTENSIONS}"
|
61
|
+
)
|
62
|
+
|
63
|
+
mime_type = mimetypes.guess_type(self.url)[0]
|
64
|
+
|
65
|
+
if not mime_type or not mime_type.startswith("image/"):
|
66
|
+
raise ValueError(f"URL does not appear to be an image: {self.url}")
|
67
|
+
|
68
|
+
return (extension[1:], mime_type)
|
69
|
+
return None
|
70
|
+
|
71
|
+
@override
|
72
|
+
async def read(self) -> bytes:
|
73
|
+
return await super().read()
|
74
|
+
|
75
|
+
|
76
|
+
class Video(BaseFile):
|
77
|
+
@override
|
78
|
+
async def read(self) -> bytes:
|
79
|
+
return await super().read()
|
80
|
+
|
81
|
+
|
82
|
+
class File(BaseFile):
|
83
|
+
@override
|
84
|
+
async def read(self) -> bytes:
|
85
|
+
return await super().read()
|
@@ -0,0 +1,38 @@
|
|
1
|
+
from .static import MessageStatus, MessageType
|
2
|
+
from .types import Message
|
3
|
+
|
4
|
+
|
5
|
+
class Filter:
|
6
|
+
def __init__(
|
7
|
+
self,
|
8
|
+
user_id: int | None = None,
|
9
|
+
text: list[str] | None = None,
|
10
|
+
status: MessageStatus | str | None = None,
|
11
|
+
type: MessageType | str | None = None,
|
12
|
+
text_contains: str | None = None,
|
13
|
+
reaction_info: bool | None = None,
|
14
|
+
) -> None:
|
15
|
+
self.user_id = user_id
|
16
|
+
self.text = text
|
17
|
+
self.status = status
|
18
|
+
self.type = type
|
19
|
+
self.reaction_info = reaction_info
|
20
|
+
self.text_contains = text_contains
|
21
|
+
|
22
|
+
def match(self, message: Message) -> bool:
|
23
|
+
if self.user_id is not None and message.sender != self.user_id:
|
24
|
+
return False
|
25
|
+
if self.text is not None and any(
|
26
|
+
text not in message.text for text in self.text
|
27
|
+
):
|
28
|
+
return False
|
29
|
+
if self.text_contains is not None and self.text_contains not in message.text:
|
30
|
+
return False
|
31
|
+
if self.status is not None and message.status != self.status:
|
32
|
+
return False
|
33
|
+
if self.type is not None and message.type != self.type:
|
34
|
+
return False
|
35
|
+
if self.reaction_info is not None and message.reactionInfo is None: # noqa: SIM103
|
36
|
+
return False
|
37
|
+
|
38
|
+
return True
|
@@ -0,0 +1,67 @@
|
|
1
|
+
import asyncio
|
2
|
+
import logging
|
3
|
+
from abc import ABC, abstractmethod
|
4
|
+
from collections.abc import Awaitable, Callable
|
5
|
+
from logging import Logger
|
6
|
+
from pathlib import Path
|
7
|
+
from typing import TYPE_CHECKING, Any
|
8
|
+
|
9
|
+
import websockets
|
10
|
+
|
11
|
+
from .filters import Filter
|
12
|
+
from .static import Constants
|
13
|
+
from .types import Channel, Chat, Dialog, Me, Message, User
|
14
|
+
|
15
|
+
if TYPE_CHECKING:
|
16
|
+
from uuid import UUID
|
17
|
+
|
18
|
+
from .crud import Database
|
19
|
+
|
20
|
+
|
21
|
+
class ClientProtocol(ABC):
|
22
|
+
def __init__(self, logger: Logger) -> None:
|
23
|
+
super().__init__()
|
24
|
+
self.logger = logger
|
25
|
+
self._users: dict[int, User] = {}
|
26
|
+
self.chats: list[Chat] = []
|
27
|
+
self.phone: str = ""
|
28
|
+
self._database: Database
|
29
|
+
self._device_id: UUID
|
30
|
+
self._on_message_handlers: list[
|
31
|
+
tuple[Callable[[Message], Any], Filter | None]
|
32
|
+
] = []
|
33
|
+
self.uri: str
|
34
|
+
self.is_connected: bool = False
|
35
|
+
self.phone: str
|
36
|
+
self.chats: list[Chat] = []
|
37
|
+
self.dialogs: list[Dialog] = []
|
38
|
+
self.channels: list[Channel] = []
|
39
|
+
self.me: Me | None = None
|
40
|
+
self._users: dict[int, User] = {}
|
41
|
+
self._work_dir: str
|
42
|
+
self._database_path: Path
|
43
|
+
self._ws: websockets.ClientConnection | None = None
|
44
|
+
self._seq: int = 0
|
45
|
+
self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
46
|
+
self._recv_task: asyncio.Task[Any] | None = None
|
47
|
+
self._incoming: asyncio.Queue[dict[str, Any]] | None = None
|
48
|
+
self.user_agent = Constants.DEFAULT_USER_AGENT.value
|
49
|
+
self._on_message_handlers: list[
|
50
|
+
tuple[Callable[[Message], Any], Filter | None]
|
51
|
+
] = []
|
52
|
+
self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
|
53
|
+
self._background_tasks: set[asyncio.Task[Any]] = set()
|
54
|
+
|
55
|
+
@abstractmethod
|
56
|
+
async def _send_and_wait(
|
57
|
+
self,
|
58
|
+
opcode: int,
|
59
|
+
payload: dict[str, Any],
|
60
|
+
cmd: int = 0,
|
61
|
+
timeout: float = Constants.DEFAULT_TIMEOUT.value,
|
62
|
+
) -> dict[str, Any]:
|
63
|
+
pass
|
64
|
+
|
65
|
+
@abstractmethod
|
66
|
+
async def _get_chat(self, chat_id: int) -> Chat | None:
|
67
|
+
pass
|
@@ -0,0 +1,18 @@
|
|
1
|
+
from .auth import AuthMixin
|
2
|
+
from .channel import ChannelMixin
|
3
|
+
from .handler import HandlerMixin
|
4
|
+
from .message import MessageMixin
|
5
|
+
from .self import SelfMixin
|
6
|
+
from .user import UserMixin
|
7
|
+
from .websocket import WebSocketMixin
|
8
|
+
|
9
|
+
|
10
|
+
class ApiMixin(
|
11
|
+
AuthMixin,
|
12
|
+
HandlerMixin,
|
13
|
+
UserMixin,
|
14
|
+
ChannelMixin,
|
15
|
+
SelfMixin,
|
16
|
+
MessageMixin,
|
17
|
+
):
|
18
|
+
pass
|