maxapi-python 2.4.0__py3-none-any.whl → 2.5.0__py3-none-any.whl
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-2.4.0.dist-info → maxapi_python-2.5.0.dist-info}/METADATA +22 -12
- maxapi_python-2.5.0.dist-info/RECORD +195 -0
- {maxapi_python-2.4.0.dist-info → maxapi_python-2.5.0.dist-info}/WHEEL +1 -1
- pymax/__init__.py +8 -2
- pymax/_data/apk_fingerprints.json +374 -17
- pymax/_data/rootca_ssl_rsa2022.crt +33 -0
- pymax/api/auth/service.py +15 -2
- pymax/api/auth/types.py +3 -0
- pymax/api/bots/payloads.py +8 -0
- pymax/api/bots/service.py +13 -1
- pymax/api/chats/enums.py +1 -0
- pymax/api/chats/payloads.py +21 -1
- pymax/api/chats/service.py +41 -0
- pymax/api/facade.py +2 -0
- pymax/api/messages/__init__.py +1 -1
- pymax/api/messages/enums.py +1 -0
- pymax/api/messages/payloads.py +56 -4
- pymax/api/messages/service.py +405 -27
- pymax/api/response.py +1 -1
- pymax/api/stories/__init__.py +2 -0
- pymax/api/stories/enums.py +19 -0
- pymax/api/stories/payloads.py +64 -0
- pymax/api/stories/service.py +198 -0
- pymax/api/uploads/payloads.py +42 -1
- pymax/api/uploads/service.py +171 -69
- pymax/app.py +39 -8
- pymax/auth/__init__.py +2 -0
- pymax/auth/exceptions.py +9 -0
- pymax/auth/providers.py +6 -2
- pymax/auth/qr.py +11 -1
- pymax/auth/sms.py +11 -1
- pymax/base.py +146 -27
- pymax/client.py +39 -11
- pymax/client_web.py +15 -12
- pymax/config.py +101 -53
- pymax/di/__init__.py +2 -0
- pymax/di/context.py +18 -0
- pymax/di/resolver.py +38 -0
- pymax/dispatch/__init__.py +7 -0
- pymax/dispatch/dispatcher.py +143 -40
- pymax/dispatch/enums.py +1 -0
- pymax/dispatch/mapping.py +23 -3
- pymax/dispatch/middleware.py +32 -0
- pymax/dispatch/resolvers.py +4 -0
- pymax/dispatch/router.py +86 -34
- pymax/files/base.py +58 -1
- pymax/files/video.py +32 -40
- pymax/files/voice.py +18 -4
- pymax/filters/__init__.py +15 -0
- pymax/filters/base.py +218 -0
- pymax/filters/containers/__init__.py +2 -0
- pymax/filters/containers/chat.py +240 -0
- pymax/filters/containers/message.py +147 -0
- pymax/filters/filters.py +211 -0
- pymax/filters/magic.py +13 -0
- pymax/filters/presets.py +88 -0
- pymax/filters/refs.py +56 -0
- pymax/fingerprint/fingerprint.py +5 -22
- pymax/fingerprint/models.py +1 -0
- pymax/formatting/markdown.py +55 -1
- pymax/fsm/__init__.py +3 -0
- pymax/fsm/context.py +30 -0
- pymax/fsm/protocol.py +14 -0
- pymax/fsm/state.py +70 -0
- pymax/fsm/storage.py +151 -0
- pymax/infra/base.py +2 -0
- pymax/infra/bots.py +20 -0
- pymax/infra/chat.py +37 -5
- pymax/infra/message.py +204 -9
- pymax/infra/protocol.py +5 -1
- pymax/infra/stories.py +205 -0
- pymax/integrations/__init__.py +0 -0
- pymax/integrations/dishka.py +109 -0
- pymax/protocol/enums.py +2 -0
- pymax/protocol/tcp/payload.py +0 -2
- pymax/session/__init__.py +1 -1
- pymax/session/models.py +30 -0
- pymax/session/protocol.py +33 -7
- pymax/session/store.py +73 -5
- pymax/transport/tcp.py +11 -3
- pymax/types/domain/__init__.py +23 -1
- pymax/types/domain/attachments/__init__.py +1 -1
- pymax/types/domain/attachments/call.py +7 -1
- pymax/types/domain/attachments/contact.py +3 -3
- pymax/types/domain/attachments/control.py +3 -0
- pymax/types/domain/attachments/enums.py +16 -0
- pymax/types/domain/attachments/keyboards/__init__.py +1 -1
- pymax/types/domain/attachments/keyboards/enums.py +12 -0
- pymax/types/domain/attachments/keyboards/inline.py +74 -3
- pymax/types/domain/attachments/video.py +3 -3
- pymax/types/domain/callback.py +19 -0
- pymax/types/domain/comment.py +24 -0
- pymax/types/domain/enums.py +7 -0
- pymax/types/domain/message.py +158 -9
- pymax/types/domain/story.py +126 -0
- pymax/types/events/reaction.py +28 -4
- pymax/versions/__init__.py +2 -0
- pymax/versions/catalog.py +104 -0
- pymax/versions/exceptions.py +10 -0
- maxapi_python-2.4.0.dist-info/RECORD +0 -161
- {maxapi_python-2.4.0.dist-info → maxapi_python-2.5.0.dist-info}/licenses/LICENSE +0 -0
pymax/infra/stories.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
from pymax.api.stories import StoryStatsFilter
|
|
2
|
+
from pymax.files import Photo, Video
|
|
3
|
+
from pymax.types.domain import Story, StoryDetailedStats, StoryPreview
|
|
4
|
+
|
|
5
|
+
from .protocol import IClientProtocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class StoriesMixin(IClientProtocol):
|
|
9
|
+
"""Методы клиента для публикации, чтения и удаления сторис."""
|
|
10
|
+
|
|
11
|
+
async def send_story(
|
|
12
|
+
self,
|
|
13
|
+
media: Photo | Video,
|
|
14
|
+
*,
|
|
15
|
+
duration: int | None = None,
|
|
16
|
+
link: str | None = None,
|
|
17
|
+
) -> Story:
|
|
18
|
+
"""Публикует фото или видео в сторис на 48 часов.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
media: Фото или видео. Принимает локальный файл, URL или байты
|
|
22
|
+
через ``Photo`` или ``Video``.
|
|
23
|
+
duration: Положительная длительность видео в миллисекундах.
|
|
24
|
+
Обязательна для ``Video``; автоматически не определяется.
|
|
25
|
+
link: Необязательная ссылка в нижней части кадра.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Опубликованная сторис.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
ValueError: Если длительность видео не задана или неположительна.
|
|
32
|
+
TypeError: Если передан неподдерживаемый тип медиа.
|
|
33
|
+
"""
|
|
34
|
+
return await self._app.api.stories.send_story(
|
|
35
|
+
media=media,
|
|
36
|
+
duration=duration,
|
|
37
|
+
link=link,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
async def get_stories(self, owner_id: int, owner_type: int = 0) -> list[Story]:
|
|
41
|
+
"""Возвращает сторис указанного владельца.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
owner_id: ID владельца, чьи сторис нужно получить.
|
|
45
|
+
owner_type: Тип из ``StoryPreview.owner.type``. ``0`` — пользователь.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Список сторис. Если сторис нет, возвращается пустой список.
|
|
49
|
+
"""
|
|
50
|
+
return await self._app.api.stories.get_stories(owner_id=owner_id, owner_type=owner_type)
|
|
51
|
+
|
|
52
|
+
async def delete_story(self, story_id: int) -> bool:
|
|
53
|
+
"""Удаляет сторис по ее ID.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
story_id: ID удаляемой сторис.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
``True``, если запрос завершился без API-ошибки.
|
|
60
|
+
"""
|
|
61
|
+
return await self._app.api.stories.delete_story(story_id=story_id)
|
|
62
|
+
|
|
63
|
+
async def get_stories_feed(
|
|
64
|
+
self,
|
|
65
|
+
cursor: str = "0",
|
|
66
|
+
count: int = 15,
|
|
67
|
+
) -> tuple[list[StoryPreview], str]:
|
|
68
|
+
"""Возвращает страницу превью владельцев сторис.
|
|
69
|
+
|
|
70
|
+
Превью не содержит ID отдельных сторис. Для их получения используйте
|
|
71
|
+
``get_stories(preview.owner.owner_id, preview.owner.type)``.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
cursor: Курсор страницы. ``"0"`` запрашивает первую страницу.
|
|
75
|
+
count: Количество превью в запрашиваемой странице.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
``(previews, next_cursor)``. Передайте ``next_cursor`` в следующий
|
|
79
|
+
вызов. Пустая строка означает, что дальше страниц нет.
|
|
80
|
+
"""
|
|
81
|
+
return await self._app.api.stories.get_stories_feed(cursor=cursor, count=count)
|
|
82
|
+
|
|
83
|
+
async def get_stories_by_id(
|
|
84
|
+
self,
|
|
85
|
+
owner_id: int,
|
|
86
|
+
story_ids: list[int],
|
|
87
|
+
owner_type: int = 0,
|
|
88
|
+
) -> list[Story]:
|
|
89
|
+
"""Возвращает конкретные сторис владельца по их ID.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
owner_id: ID владельца сторис.
|
|
93
|
+
story_ids: ID запрашиваемых сторис.
|
|
94
|
+
owner_type: Тип из ``StoryPreview.owner.type``. ``0`` — пользователь.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Список найденных сторис.
|
|
98
|
+
"""
|
|
99
|
+
return await self._app.api.stories.get_stories_by_id(
|
|
100
|
+
owner_id=owner_id,
|
|
101
|
+
story_ids=story_ids,
|
|
102
|
+
owner_type=owner_type,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
async def get_story_detailed_stats(
|
|
106
|
+
self,
|
|
107
|
+
story_id: int,
|
|
108
|
+
filter: StoryStatsFilter,
|
|
109
|
+
marker: int = 0,
|
|
110
|
+
) -> tuple[list[StoryDetailedStats], int]:
|
|
111
|
+
"""Возвращает страницу подробной статистики сторис.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
story_id: ID сторис.
|
|
115
|
+
filter: ``StoryStatsFilter.VIEWS`` — просмотры,
|
|
116
|
+
``StoryStatsFilter.REACTIONS`` — реакции.
|
|
117
|
+
marker: Маркер страницы. ``0`` запрашивает первую страницу.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
``(stats, next_marker)``. Нулевой маркер означает конец списка.
|
|
121
|
+
"""
|
|
122
|
+
return await self._app.api.stories.get_story_detailed_stats(
|
|
123
|
+
story_id=story_id,
|
|
124
|
+
filter=filter,
|
|
125
|
+
marker=marker,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
async def read_story(self, story_id: int, owner_id: int, owner_type: int = 0) -> bool:
|
|
129
|
+
"""Отмечает сторис просмотренной.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
story_id: ID просмотренной сторис.
|
|
133
|
+
owner_id: ID владельца сторис.
|
|
134
|
+
owner_type: Тип из ``StoryPreview.owner.type``. ``0`` — пользователь.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
``True``, если запрос завершился без API-ошибки.
|
|
138
|
+
"""
|
|
139
|
+
return await self._app.api.stories.read_story(
|
|
140
|
+
story_id=story_id,
|
|
141
|
+
owner_id=owner_id,
|
|
142
|
+
owner_type=owner_type,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
async def edit_story(self, story_id: int, settings: int) -> Story:
|
|
146
|
+
"""Меняет настройки опубликованной сторис.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
story_id: ID редактируемой сторис.
|
|
150
|
+
settings: Битовая маска настроек в формате Max.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Обновленная сторис.
|
|
154
|
+
"""
|
|
155
|
+
return await self._app.api.stories.edit_story(story_id=story_id, settings=settings)
|
|
156
|
+
|
|
157
|
+
async def add_story_reaction(
|
|
158
|
+
self,
|
|
159
|
+
owner_id: int,
|
|
160
|
+
story_id: int,
|
|
161
|
+
reaction: str,
|
|
162
|
+
reaction_type: int = 0,
|
|
163
|
+
owner_type: int = 0,
|
|
164
|
+
) -> bool:
|
|
165
|
+
"""Добавляет или заменяет свою реакцию на сторис.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
owner_id: ID владельца сторис.
|
|
169
|
+
story_id: ID сторис.
|
|
170
|
+
reaction: Эмодзи или строковый ID реакции.
|
|
171
|
+
reaction_type: ``0`` для эмодзи, ``1`` для реакции по ID.
|
|
172
|
+
owner_type: Тип из ``StoryPreview.owner.type``. ``0`` — пользователь.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Значение ``success`` из ответа Max.
|
|
176
|
+
"""
|
|
177
|
+
return await self._app.api.stories.add_story_reaction(
|
|
178
|
+
owner_id=owner_id,
|
|
179
|
+
story_id=story_id,
|
|
180
|
+
reaction=reaction,
|
|
181
|
+
reaction_type=reaction_type,
|
|
182
|
+
owner_type=owner_type,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
async def remove_story_reaction(
|
|
186
|
+
self,
|
|
187
|
+
owner_id: int,
|
|
188
|
+
story_id: int,
|
|
189
|
+
owner_type: int = 0,
|
|
190
|
+
) -> bool:
|
|
191
|
+
"""Снимает свою реакцию со сторис.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
owner_id: ID владельца сторис.
|
|
195
|
+
story_id: ID сторис.
|
|
196
|
+
owner_type: Тип из ``StoryPreview.owner.type``. ``0`` — пользователь.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Значение ``success`` из ответа Max.
|
|
200
|
+
"""
|
|
201
|
+
return await self._app.api.stories.remove_story_reaction(
|
|
202
|
+
owner_id=owner_id,
|
|
203
|
+
story_id=story_id,
|
|
204
|
+
owner_type=owner_type,
|
|
205
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from pymax import Router
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
from dishka import AsyncContainer
|
|
5
|
+
from dishka.integrations.base import is_dishka_injected, wrap_injection
|
|
6
|
+
except ImportError as e:
|
|
7
|
+
raise RuntimeError(
|
|
8
|
+
"Dishka integration requires the 'dishka' extra. "
|
|
9
|
+
"Install it with `uv add 'maxapi-python[dishka]'`"
|
|
10
|
+
) from e
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable, Generator
|
|
13
|
+
from typing import Any, ParamSpec, TypeVar
|
|
14
|
+
from weakref import WeakKeyDictionary
|
|
15
|
+
|
|
16
|
+
from pymax.base import BaseClient
|
|
17
|
+
|
|
18
|
+
P = ParamSpec("P")
|
|
19
|
+
T = TypeVar("T")
|
|
20
|
+
|
|
21
|
+
_containers: WeakKeyDictionary[BaseClient[Any], AsyncContainer] = WeakKeyDictionary()
|
|
22
|
+
ClientT = TypeVar("ClientT", bound=BaseClient)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_client(
|
|
26
|
+
args: tuple[Any, ...],
|
|
27
|
+
kwargs: dict[str, Any],
|
|
28
|
+
) -> BaseClient[Any]:
|
|
29
|
+
client = kwargs.get("client")
|
|
30
|
+
|
|
31
|
+
if isinstance(client, BaseClient):
|
|
32
|
+
return client
|
|
33
|
+
|
|
34
|
+
for arg in reversed(args):
|
|
35
|
+
if isinstance(arg, BaseClient):
|
|
36
|
+
return arg
|
|
37
|
+
|
|
38
|
+
raise RuntimeError("Cannot resolve PyMax client for Dishka injection")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_container(
|
|
42
|
+
args: tuple[Any, ...],
|
|
43
|
+
kwargs: dict[str, Any],
|
|
44
|
+
) -> AsyncContainer:
|
|
45
|
+
client = _get_client(args, kwargs)
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
return _containers[client]
|
|
49
|
+
except KeyError:
|
|
50
|
+
raise RuntimeError(
|
|
51
|
+
"Dishka is not configured for this client. Call setup_dishka(container, client)."
|
|
52
|
+
) from None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def inject(func: Callable[P, T]) -> Callable[P, T]:
|
|
56
|
+
"""Внедряет параметры ``FromDishka[T]`` в обработчик события.
|
|
57
|
+
|
|
58
|
+
Размещайте ``@inject`` под декоратором регистрации обработчика,
|
|
59
|
+
например ``@router.on_message()``. Контейнер для переданного в обработчик
|
|
60
|
+
клиента должен быть зарегистрирован через ``setup_dishka()``.
|
|
61
|
+
"""
|
|
62
|
+
return wrap_injection(
|
|
63
|
+
func=func,
|
|
64
|
+
is_async=True,
|
|
65
|
+
container_getter=_get_container,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def inject_router(router: Router[ClientT]) -> None:
|
|
70
|
+
"""Оборачивает уже зарегистрированные обработчики роутера и его детей.
|
|
71
|
+
|
|
72
|
+
Пропускает обработчики, уже обернутые через ``inject``. Не затрагивает
|
|
73
|
+
``on_start``, ``on_error``, ``on_disconnect`` и последующие регистрации.
|
|
74
|
+
"""
|
|
75
|
+
for current_router in _iter_routers(router):
|
|
76
|
+
for entries in current_router.handlers.values():
|
|
77
|
+
for entry in entries:
|
|
78
|
+
if not is_dishka_injected(entry.callback):
|
|
79
|
+
entry.callback = inject(entry.callback)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _iter_routers(router: Router[ClientT]) -> Generator[Router[ClientT] | Any, Any, None]:
|
|
83
|
+
yield router
|
|
84
|
+
|
|
85
|
+
for child in router.children:
|
|
86
|
+
yield from _iter_routers(child)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def setup_dishka(
|
|
90
|
+
container: AsyncContainer,
|
|
91
|
+
client: BaseClient[Any],
|
|
92
|
+
*,
|
|
93
|
+
auto_inject: bool = False,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Привязывает контейнер Dishka к клиенту.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
container: Контейнер, из которого обработчики получают зависимости.
|
|
99
|
+
client: Клиент, для которого включается интеграция.
|
|
100
|
+
auto_inject: Обернуть уже зарегистрированные обработчики всего дерева
|
|
101
|
+
роутеров клиента. По умолчанию используется явный ``@inject``.
|
|
102
|
+
|
|
103
|
+
Интеграция не открывает отдельный scope для события, не добавляет событие
|
|
104
|
+
или клиента в контекст Dishka и не закрывает контейнер автоматически.
|
|
105
|
+
"""
|
|
106
|
+
_containers[client] = container
|
|
107
|
+
|
|
108
|
+
if auto_inject:
|
|
109
|
+
inject_router(client._router)
|
pymax/protocol/enums.py
CHANGED
pymax/protocol/tcp/payload.py
CHANGED
|
@@ -128,8 +128,6 @@ class TcpPayloadDecoder:
|
|
|
128
128
|
except ValueError:
|
|
129
129
|
logger.debug("tcp Zstd payload decompression failed", exc_info=True)
|
|
130
130
|
raise
|
|
131
|
-
elif flags > 0x7F:
|
|
132
|
-
raise ValueError(f"invalid TCP compression factor: {flags}")
|
|
133
131
|
elif flags > 0:
|
|
134
132
|
if self.compression is None:
|
|
135
133
|
raise ValueError("LZ4-compressed TCP payload without a decoder")
|
pymax/session/__init__.py
CHANGED
pymax/session/models.py
CHANGED
|
@@ -1,11 +1,41 @@
|
|
|
1
1
|
from pydantic import BaseModel, Field
|
|
2
2
|
|
|
3
|
+
from pymax.api.session.payloads import MobileUserAgentPayload
|
|
3
4
|
from pymax.types.domain.sync import SyncState
|
|
4
5
|
|
|
5
6
|
|
|
6
7
|
class SessionInfo(BaseModel):
|
|
8
|
+
"""Состояние авторизованной сессии, сохраняемое через ``StoreProtocol``.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
token: Login token Max.
|
|
12
|
+
device_id: ID устройства, связанный с сессией.
|
|
13
|
+
phone: Номер телефона сессии; для web-сессии может быть пустой строкой.
|
|
14
|
+
mt_instance_id: Instance ID устройства. Пустая строка поддерживается для
|
|
15
|
+
сессий, созданных предыдущими версиями PyMax.
|
|
16
|
+
user_agent: Device/user-agent сессии. ``None`` поддерживается для старых
|
|
17
|
+
или пользовательских store, которые его не сохраняют.
|
|
18
|
+
sync: Последние sync-маркеры login.
|
|
19
|
+
"""
|
|
20
|
+
|
|
7
21
|
token: str
|
|
8
22
|
device_id: str
|
|
9
23
|
phone: str
|
|
10
24
|
mt_instance_id: str = ""
|
|
25
|
+
user_agent: MobileUserAgentPayload | None = None
|
|
11
26
|
sync: SyncState = Field(default_factory=SyncState)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve_session_user_agent(
|
|
30
|
+
current: MobileUserAgentPayload,
|
|
31
|
+
stored: MobileUserAgentPayload | None,
|
|
32
|
+
) -> MobileUserAgentPayload:
|
|
33
|
+
if stored is None or stored.device_type != current.device_type:
|
|
34
|
+
return current
|
|
35
|
+
|
|
36
|
+
return stored.model_copy(
|
|
37
|
+
update={
|
|
38
|
+
"app_version": current.app_version,
|
|
39
|
+
"build_number": current.build_number,
|
|
40
|
+
}
|
|
41
|
+
)
|
pymax/session/protocol.py
CHANGED
|
@@ -5,10 +5,36 @@ from .models import SessionInfo
|
|
|
5
5
|
|
|
6
6
|
@runtime_checkable
|
|
7
7
|
class StoreProtocol(Protocol):
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
async def
|
|
8
|
+
"""Асинхронный контракт пользовательского хранилища сессии.
|
|
9
|
+
|
|
10
|
+
PyMax использует переданный ``ExtraConfig.store`` только при
|
|
11
|
+
``persist_session=True`` и вызывает :meth:`close` при закрытии runtime.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
async def save_session(self, session_info: SessionInfo) -> None:
|
|
15
|
+
"""Сохраняет или полностью заменяет данные сессии."""
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
async def update_token(self, old_token: str, new_token: str, /) -> None:
|
|
19
|
+
"""Заменяет ``old_token`` у соответствующей сессии."""
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
async def load_session(self) -> SessionInfo | None:
|
|
23
|
+
"""Возвращает сессию для запуска клиента или ``None``."""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
async def load_session_by_device_id(self, device_id: str) -> SessionInfo | None:
|
|
27
|
+
"""Ищет сессию по device ID."""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
async def load_session_by_phone(self, phone: str) -> SessionInfo | None:
|
|
31
|
+
"""Ищет сессию по номеру телефона."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
async def delete_session(self, token: str, /) -> None:
|
|
35
|
+
"""Удаляет сессию с переданным token, если она существует."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
async def close(self) -> None:
|
|
39
|
+
"""Освобождает ресурсы хранилища при закрытии runtime."""
|
|
40
|
+
...
|
pymax/session/store.py
CHANGED
|
@@ -2,10 +2,12 @@ from pathlib import Path
|
|
|
2
2
|
|
|
3
3
|
import aiosqlite
|
|
4
4
|
|
|
5
|
+
from pymax.api.session.payloads import MobileUserAgentPayload
|
|
5
6
|
from pymax.logging import get_logger
|
|
6
7
|
from pymax.types.domain.sync import DEFAULT_CONFIG_HASH, SyncState
|
|
7
8
|
|
|
8
9
|
from .models import SessionInfo
|
|
10
|
+
from .protocol import StoreProtocol
|
|
9
11
|
|
|
10
12
|
logger = get_logger(__name__)
|
|
11
13
|
|
|
@@ -18,11 +20,64 @@ SESSION_COLUMNS = """
|
|
|
18
20
|
contacts_sync,
|
|
19
21
|
drafts_sync,
|
|
20
22
|
presence_sync,
|
|
21
|
-
config_hash
|
|
23
|
+
config_hash,
|
|
24
|
+
user_agent
|
|
22
25
|
"""
|
|
23
26
|
|
|
24
27
|
|
|
25
|
-
class
|
|
28
|
+
class InMemoryStore(StoreProtocol):
|
|
29
|
+
"""Хранилище одной сессии в памяти текущего runtime.
|
|
30
|
+
|
|
31
|
+
Данные не записываются на диск и становятся недоступны после пересоздания
|
|
32
|
+
``App``. Обычно этот store выбирается автоматически при
|
|
33
|
+
``ExtraConfig(persist_session=False)``.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._session: SessionInfo | None = None
|
|
40
|
+
|
|
41
|
+
async def save_session(self, session_info: SessionInfo) -> None:
|
|
42
|
+
self._session = session_info
|
|
43
|
+
|
|
44
|
+
async def update_token(self, _: str, new_token: str) -> None:
|
|
45
|
+
if not self._session:
|
|
46
|
+
return
|
|
47
|
+
self._session.token = new_token
|
|
48
|
+
|
|
49
|
+
async def load_session(self) -> SessionInfo | None:
|
|
50
|
+
return self._session
|
|
51
|
+
|
|
52
|
+
async def load_session_by_device_id(self, device_id: str) -> SessionInfo | None:
|
|
53
|
+
if self._session and self._session.device_id != device_id:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
return self._session
|
|
57
|
+
|
|
58
|
+
async def load_session_by_phone(self, phone: str) -> SessionInfo | None:
|
|
59
|
+
if self._session and self._session.phone != phone:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
return self._session
|
|
63
|
+
|
|
64
|
+
async def delete_session(self, _: str) -> None:
|
|
65
|
+
self._session = None
|
|
66
|
+
|
|
67
|
+
async def close(self) -> None:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class SessionStore(StoreProtocol):
|
|
72
|
+
"""SQLite-хранилище сессий, используемое по умолчанию.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
work_dir: Директория файла базы данных; создается автоматически.
|
|
76
|
+
db_name: Имя файла базы данных. По умолчанию ``session.db``.
|
|
77
|
+
|
|
78
|
+
Схема существующего файла автоматически дополняется недостающими колонками.
|
|
79
|
+
"""
|
|
80
|
+
|
|
26
81
|
def __init__(self, work_dir: str, db_name: str = "session.db") -> None:
|
|
27
82
|
self.work_dir = Path(work_dir)
|
|
28
83
|
self.work_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -51,7 +106,8 @@ class SessionStore:
|
|
|
51
106
|
contacts_sync INTEGER NOT NULL DEFAULT -1,
|
|
52
107
|
drafts_sync INTEGER NOT NULL DEFAULT -1,
|
|
53
108
|
presence_sync INTEGER NOT NULL DEFAULT -1,
|
|
54
|
-
config_hash TEXT NOT NULL DEFAULT ''
|
|
109
|
+
config_hash TEXT NOT NULL DEFAULT '',
|
|
110
|
+
user_agent TEXT
|
|
55
111
|
)
|
|
56
112
|
"""
|
|
57
113
|
)
|
|
@@ -61,6 +117,7 @@ class SessionStore:
|
|
|
61
117
|
await self._ensure_column(conn, "drafts_sync", "INTEGER NOT NULL DEFAULT -1")
|
|
62
118
|
await self._ensure_column(conn, "presence_sync", "INTEGER NOT NULL DEFAULT -1")
|
|
63
119
|
await self._ensure_column(conn, "config_hash", "TEXT NOT NULL DEFAULT ''")
|
|
120
|
+
await self._ensure_column(conn, "user_agent", "TEXT")
|
|
64
121
|
await conn.execute(
|
|
65
122
|
"""
|
|
66
123
|
UPDATE sessions
|
|
@@ -102,9 +159,10 @@ class SessionStore:
|
|
|
102
159
|
contacts_sync,
|
|
103
160
|
drafts_sync,
|
|
104
161
|
presence_sync,
|
|
105
|
-
config_hash
|
|
162
|
+
config_hash,
|
|
163
|
+
user_agent
|
|
106
164
|
)
|
|
107
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
165
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
108
166
|
""",
|
|
109
167
|
(
|
|
110
168
|
session_info.token,
|
|
@@ -116,6 +174,11 @@ class SessionStore:
|
|
|
116
174
|
session_info.sync.drafts_sync,
|
|
117
175
|
session_info.sync.presence_sync,
|
|
118
176
|
session_info.sync.config_hash,
|
|
177
|
+
(
|
|
178
|
+
session_info.user_agent.model_dump_json(by_alias=True, exclude_none=True)
|
|
179
|
+
if session_info.user_agent is not None
|
|
180
|
+
else None
|
|
181
|
+
),
|
|
119
182
|
),
|
|
120
183
|
)
|
|
121
184
|
await conn.commit()
|
|
@@ -233,6 +296,11 @@ class SessionStore:
|
|
|
233
296
|
device_id=row["device_id"],
|
|
234
297
|
phone=row["phone"],
|
|
235
298
|
mt_instance_id=row["mt_instance_id"] or "",
|
|
299
|
+
user_agent=(
|
|
300
|
+
MobileUserAgentPayload.model_validate_json(row["user_agent"])
|
|
301
|
+
if row["user_agent"] is not None
|
|
302
|
+
else None
|
|
303
|
+
),
|
|
236
304
|
sync=SyncState(
|
|
237
305
|
chats_sync=row["chats_sync"],
|
|
238
306
|
contacts_sync=row["contacts_sync"],
|
pymax/transport/tcp.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import ssl
|
|
3
|
+
from importlib import resources
|
|
2
4
|
|
|
3
5
|
from python_socks.async_.asyncio import Proxy
|
|
4
6
|
|
|
@@ -16,6 +18,10 @@ class TCPTransport(Transport):
|
|
|
16
18
|
self._port = port
|
|
17
19
|
self._proxy = proxy
|
|
18
20
|
self._use_ssl = use_ssl
|
|
21
|
+
self._ssl_ctx = ssl.create_default_context()
|
|
22
|
+
self._ssl_ctx.load_verify_locations(
|
|
23
|
+
str(resources.files("pymax._data") / "rootca_ssl_rsa2022.crt")
|
|
24
|
+
)
|
|
19
25
|
self._reader = None
|
|
20
26
|
self._writer = None
|
|
21
27
|
|
|
@@ -26,6 +32,9 @@ class TCPTransport(Transport):
|
|
|
26
32
|
self._port,
|
|
27
33
|
self._use_ssl,
|
|
28
34
|
)
|
|
35
|
+
ssl_ctx = self._ssl_ctx if self._use_ssl else None
|
|
36
|
+
server_hostname = self._host if self._use_ssl else None
|
|
37
|
+
|
|
29
38
|
if self._proxy:
|
|
30
39
|
logger.debug("tcp connecting via proxy %s", self._proxy)
|
|
31
40
|
proxy = Proxy.from_url(self._proxy)
|
|
@@ -33,10 +42,9 @@ class TCPTransport(Transport):
|
|
|
33
42
|
dest_host=self._host,
|
|
34
43
|
dest_port=self._port,
|
|
35
44
|
)
|
|
36
|
-
server_hostname = self._host if self._use_ssl else None
|
|
37
45
|
self._reader, self._writer = await asyncio.open_connection(
|
|
38
46
|
sock=sock,
|
|
39
|
-
ssl=
|
|
47
|
+
ssl=ssl_ctx,
|
|
40
48
|
server_hostname=server_hostname,
|
|
41
49
|
)
|
|
42
50
|
logger.info(
|
|
@@ -50,7 +58,7 @@ class TCPTransport(Transport):
|
|
|
50
58
|
self._reader, self._writer = await asyncio.open_connection(
|
|
51
59
|
self._host,
|
|
52
60
|
self._port,
|
|
53
|
-
ssl=
|
|
61
|
+
ssl=ssl_ctx,
|
|
54
62
|
)
|
|
55
63
|
logger.info(
|
|
56
64
|
"tcp connected host=%s port=%s ssl=%s",
|
pymax/types/domain/__init__.py
CHANGED
|
@@ -1,15 +1,37 @@
|
|
|
1
1
|
from .attachments import *
|
|
2
2
|
from .bots import InitData
|
|
3
|
+
from .callback import CallbackResponse
|
|
3
4
|
from .chat import Chat
|
|
5
|
+
from .comment import CommentsInfo, CommentsInfoUpdate
|
|
4
6
|
from .error import MaxApiError
|
|
5
7
|
from .folder import Folder, FolderList, FolderUpdate
|
|
6
8
|
from .handshake import HandshakeResponse
|
|
7
9
|
from .login import Login2Flags, Login2Response, LoginResponse
|
|
8
10
|
from .member import Member
|
|
9
|
-
from .message import
|
|
11
|
+
from .message import (
|
|
12
|
+
ForwardLink,
|
|
13
|
+
LinkType,
|
|
14
|
+
Message,
|
|
15
|
+
ReactionCounter,
|
|
16
|
+
ReactionInfo,
|
|
17
|
+
ReadState,
|
|
18
|
+
ReplyLink,
|
|
19
|
+
)
|
|
10
20
|
from .name import Name
|
|
11
21
|
from .presence import Presence
|
|
12
22
|
from .profile import Profile
|
|
13
23
|
from .session import Session
|
|
24
|
+
from .story import (
|
|
25
|
+
Story,
|
|
26
|
+
StoryClickableLink,
|
|
27
|
+
StoryCoordinates,
|
|
28
|
+
StoryDetailedStats,
|
|
29
|
+
StoryLayer,
|
|
30
|
+
StoryMedia,
|
|
31
|
+
StoryOwner,
|
|
32
|
+
StoryPhotoMedia,
|
|
33
|
+
StoryPreview,
|
|
34
|
+
StoryVideoMedia,
|
|
35
|
+
)
|
|
14
36
|
from .sync import SyncOverrides, SyncState
|
|
15
37
|
from .user import ContactInfo, User
|
|
@@ -4,7 +4,7 @@ from .contact import ContactAttachment
|
|
|
4
4
|
from .control import ControlAttachment
|
|
5
5
|
from .enums import AttachmentType, PollFlags
|
|
6
6
|
from .file import FileAttachment, FileRequest
|
|
7
|
-
from .keyboards import InlineKeyboardAttachment
|
|
7
|
+
from .keyboards import Button, ButtonType, InlineKeyboardAttachment
|
|
8
8
|
from .photo import PhotoAttachment
|
|
9
9
|
from .poll import Poll, PollAnswer, PollAttachment, PollState
|
|
10
10
|
from .share import ShareAttachment
|
|
@@ -4,7 +4,7 @@ from pydantic import Field
|
|
|
4
4
|
|
|
5
5
|
from pymax.types.domain.base import CamelModel
|
|
6
6
|
|
|
7
|
-
from .enums import AttachmentType
|
|
7
|
+
from .enums import AttachmentType, CallType, HangupType
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class CallAttachment(CamelModel):
|
|
@@ -18,9 +18,15 @@ class CallAttachment(CamelModel):
|
|
|
18
18
|
:vartype conversation_id: str | int | None
|
|
19
19
|
:ivar contact_ids: ID участников звонка.
|
|
20
20
|
:vartype contact_ids: list[int]
|
|
21
|
+
:ivar call_type: Тип звонка, если Max его прислал.
|
|
22
|
+
:vartype call_type: CallType | None
|
|
23
|
+
:ivar hangup_type: Причина завершения звонка, если она есть в payload-е.
|
|
24
|
+
:vartype hangup_type: HangupType | None
|
|
21
25
|
"""
|
|
22
26
|
|
|
23
27
|
type: Literal[AttachmentType.CALL] = Field(alias="_type")
|
|
24
28
|
duration: int | None = None
|
|
25
29
|
conversation_id: str | int | None = None
|
|
26
30
|
contact_ids: list[int] = Field(default_factory=list)
|
|
31
|
+
call_type: CallType | None = None
|
|
32
|
+
hangup_type: HangupType | None = None
|