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.
Files changed (101) hide show
  1. {maxapi_python-2.4.0.dist-info → maxapi_python-2.5.0.dist-info}/METADATA +22 -12
  2. maxapi_python-2.5.0.dist-info/RECORD +195 -0
  3. {maxapi_python-2.4.0.dist-info → maxapi_python-2.5.0.dist-info}/WHEEL +1 -1
  4. pymax/__init__.py +8 -2
  5. pymax/_data/apk_fingerprints.json +374 -17
  6. pymax/_data/rootca_ssl_rsa2022.crt +33 -0
  7. pymax/api/auth/service.py +15 -2
  8. pymax/api/auth/types.py +3 -0
  9. pymax/api/bots/payloads.py +8 -0
  10. pymax/api/bots/service.py +13 -1
  11. pymax/api/chats/enums.py +1 -0
  12. pymax/api/chats/payloads.py +21 -1
  13. pymax/api/chats/service.py +41 -0
  14. pymax/api/facade.py +2 -0
  15. pymax/api/messages/__init__.py +1 -1
  16. pymax/api/messages/enums.py +1 -0
  17. pymax/api/messages/payloads.py +56 -4
  18. pymax/api/messages/service.py +405 -27
  19. pymax/api/response.py +1 -1
  20. pymax/api/stories/__init__.py +2 -0
  21. pymax/api/stories/enums.py +19 -0
  22. pymax/api/stories/payloads.py +64 -0
  23. pymax/api/stories/service.py +198 -0
  24. pymax/api/uploads/payloads.py +42 -1
  25. pymax/api/uploads/service.py +171 -69
  26. pymax/app.py +39 -8
  27. pymax/auth/__init__.py +2 -0
  28. pymax/auth/exceptions.py +9 -0
  29. pymax/auth/providers.py +6 -2
  30. pymax/auth/qr.py +11 -1
  31. pymax/auth/sms.py +11 -1
  32. pymax/base.py +146 -27
  33. pymax/client.py +39 -11
  34. pymax/client_web.py +15 -12
  35. pymax/config.py +101 -53
  36. pymax/di/__init__.py +2 -0
  37. pymax/di/context.py +18 -0
  38. pymax/di/resolver.py +38 -0
  39. pymax/dispatch/__init__.py +7 -0
  40. pymax/dispatch/dispatcher.py +143 -40
  41. pymax/dispatch/enums.py +1 -0
  42. pymax/dispatch/mapping.py +23 -3
  43. pymax/dispatch/middleware.py +32 -0
  44. pymax/dispatch/resolvers.py +4 -0
  45. pymax/dispatch/router.py +86 -34
  46. pymax/files/base.py +58 -1
  47. pymax/files/video.py +32 -40
  48. pymax/files/voice.py +18 -4
  49. pymax/filters/__init__.py +15 -0
  50. pymax/filters/base.py +218 -0
  51. pymax/filters/containers/__init__.py +2 -0
  52. pymax/filters/containers/chat.py +240 -0
  53. pymax/filters/containers/message.py +147 -0
  54. pymax/filters/filters.py +211 -0
  55. pymax/filters/magic.py +13 -0
  56. pymax/filters/presets.py +88 -0
  57. pymax/filters/refs.py +56 -0
  58. pymax/fingerprint/fingerprint.py +5 -22
  59. pymax/fingerprint/models.py +1 -0
  60. pymax/formatting/markdown.py +55 -1
  61. pymax/fsm/__init__.py +3 -0
  62. pymax/fsm/context.py +30 -0
  63. pymax/fsm/protocol.py +14 -0
  64. pymax/fsm/state.py +70 -0
  65. pymax/fsm/storage.py +151 -0
  66. pymax/infra/base.py +2 -0
  67. pymax/infra/bots.py +20 -0
  68. pymax/infra/chat.py +37 -5
  69. pymax/infra/message.py +204 -9
  70. pymax/infra/protocol.py +5 -1
  71. pymax/infra/stories.py +205 -0
  72. pymax/integrations/__init__.py +0 -0
  73. pymax/integrations/dishka.py +109 -0
  74. pymax/protocol/enums.py +2 -0
  75. pymax/protocol/tcp/payload.py +0 -2
  76. pymax/session/__init__.py +1 -1
  77. pymax/session/models.py +30 -0
  78. pymax/session/protocol.py +33 -7
  79. pymax/session/store.py +73 -5
  80. pymax/transport/tcp.py +11 -3
  81. pymax/types/domain/__init__.py +23 -1
  82. pymax/types/domain/attachments/__init__.py +1 -1
  83. pymax/types/domain/attachments/call.py +7 -1
  84. pymax/types/domain/attachments/contact.py +3 -3
  85. pymax/types/domain/attachments/control.py +3 -0
  86. pymax/types/domain/attachments/enums.py +16 -0
  87. pymax/types/domain/attachments/keyboards/__init__.py +1 -1
  88. pymax/types/domain/attachments/keyboards/enums.py +12 -0
  89. pymax/types/domain/attachments/keyboards/inline.py +74 -3
  90. pymax/types/domain/attachments/video.py +3 -3
  91. pymax/types/domain/callback.py +19 -0
  92. pymax/types/domain/comment.py +24 -0
  93. pymax/types/domain/enums.py +7 -0
  94. pymax/types/domain/message.py +158 -9
  95. pymax/types/domain/story.py +126 -0
  96. pymax/types/events/reaction.py +28 -4
  97. pymax/versions/__init__.py +2 -0
  98. pymax/versions/catalog.py +104 -0
  99. pymax/versions/exceptions.py +10 -0
  100. maxapi_python-2.4.0.dist-info/RECORD +0 -161
  101. {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
@@ -85,7 +85,9 @@ class Opcode(int, Enum):
85
85
  FILE_UPLOAD = 87
86
86
  FILE_DOWNLOAD = 88
87
87
  LINK_INFO = 89
88
+ MSG_GET_COMMENTS_INFO = 91
88
89
  MSG_DELETE_RANGE = 92
90
+ MSG_DELETE_USER_COMMENTS = 94
89
91
  SESSIONS_INFO = 96
90
92
  SESSIONS_CLOSE = 97
91
93
  PHONE_BIND_REQUEST = 98
@@ -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
@@ -1,3 +1,3 @@
1
1
  from .models import SessionInfo
2
2
  from .protocol import StoreProtocol
3
- from .store import SessionStore
3
+ from .store import InMemoryStore, SessionStore
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
- async def save_session(self, session_info: SessionInfo) -> None: ...
9
- async def update_token(self, old_token: str, new_token: str) -> None: ...
10
- async def load_session(self) -> SessionInfo | None: ...
11
- async def load_session_by_device_id(self, device_id: str) -> SessionInfo | None: ...
12
- async def load_session_by_phone(self, phone: str) -> SessionInfo | None: ...
13
- async def delete_session(self, token: str) -> None: ...
14
- async def close(self) -> None: ...
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 SessionStore:
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=self._use_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=self._use_ssl,
61
+ ssl=ssl_ctx,
54
62
  )
55
63
  logger.info(
56
64
  "tcp connected host=%s port=%s ssl=%s",
@@ -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 Message, ReactionCounter, ReactionInfo, ReadState
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