maxapi-python 1.2.5__py3-none-any.whl → 2.0.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 (169) hide show
  1. maxapi_python-2.0.0.dist-info/METADATA +217 -0
  2. maxapi_python-2.0.0.dist-info/RECORD +140 -0
  3. {maxapi_python-1.2.5.dist-info → maxapi_python-2.0.0.dist-info}/WHEEL +1 -1
  4. pymax/__init__.py +50 -105
  5. pymax/api/__init__.py +17 -0
  6. pymax/api/auth/__init__.py +1 -0
  7. pymax/api/auth/enums.py +17 -0
  8. pymax/api/auth/payloads.py +129 -0
  9. pymax/api/auth/service.py +313 -0
  10. pymax/api/auth/types.py +13 -0
  11. pymax/api/chats/__init__.py +8 -0
  12. pymax/api/chats/enums.py +27 -0
  13. pymax/api/chats/payloads.py +103 -0
  14. pymax/api/chats/service.py +277 -0
  15. pymax/api/facade.py +32 -0
  16. pymax/api/messages/__init__.py +1 -0
  17. pymax/api/messages/enums.py +17 -0
  18. pymax/api/messages/payloads.py +92 -0
  19. pymax/api/messages/service.py +337 -0
  20. pymax/api/models.py +13 -0
  21. pymax/api/response.py +123 -0
  22. pymax/api/self/__init__.py +2 -0
  23. pymax/api/self/enums.py +11 -0
  24. pymax/api/self/payloads.py +41 -0
  25. pymax/api/self/service.py +142 -0
  26. pymax/api/session/__init__.py +1 -0
  27. pymax/api/session/enums.py +10 -0
  28. pymax/api/session/payloads.py +76 -0
  29. pymax/api/session/service.py +72 -0
  30. pymax/api/uploads/__init__.py +1 -0
  31. pymax/api/uploads/models.py +49 -0
  32. pymax/api/uploads/payloads.py +25 -0
  33. pymax/api/uploads/service.py +458 -0
  34. pymax/api/users/__init__.py +2 -0
  35. pymax/api/users/enums.py +12 -0
  36. pymax/api/users/payloads.py +16 -0
  37. pymax/api/users/service.py +124 -0
  38. pymax/app.py +273 -0
  39. pymax/auth/__init__.py +25 -0
  40. pymax/auth/base.py +37 -0
  41. pymax/auth/email.py +0 -0
  42. pymax/auth/models.py +5 -0
  43. pymax/auth/providers.py +127 -0
  44. pymax/auth/qr.py +135 -0
  45. pymax/auth/service.py +25 -0
  46. pymax/auth/sms.py +122 -0
  47. pymax/base.py +204 -0
  48. pymax/client.py +106 -0
  49. pymax/client_web.py +83 -0
  50. pymax/config.py +215 -0
  51. pymax/connection/__init__.py +1 -0
  52. pymax/connection/connection.py +205 -0
  53. pymax/connection/pending.py +46 -0
  54. pymax/connection/readers/__init__.py +2 -0
  55. pymax/connection/readers/base.py +6 -0
  56. pymax/connection/readers/tcp.py +29 -0
  57. pymax/connection/readers/ws.py +14 -0
  58. pymax/dispatch/__init__.py +10 -0
  59. pymax/dispatch/dispatcher.py +222 -0
  60. pymax/dispatch/enums.py +12 -0
  61. pymax/dispatch/mapping.py +73 -0
  62. pymax/dispatch/resolvers.py +52 -0
  63. pymax/dispatch/router.py +216 -0
  64. pymax/exceptions.py +22 -89
  65. pymax/files/__init__.py +9 -0
  66. pymax/files/base.py +82 -0
  67. pymax/files/file.py +76 -0
  68. pymax/files/photo.py +108 -0
  69. pymax/files/static.py +10 -0
  70. pymax/files/video.py +74 -0
  71. pymax/formatting/__init__.py +0 -0
  72. pymax/formatting/markdown.py +217 -0
  73. pymax/infra/__init__.py +1 -0
  74. pymax/infra/auth.py +55 -0
  75. pymax/infra/base.py +15 -0
  76. pymax/infra/chat.py +240 -0
  77. pymax/infra/message.py +252 -0
  78. pymax/infra/protocol.py +9 -0
  79. pymax/infra/self.py +139 -0
  80. pymax/infra/user.py +107 -0
  81. pymax/logging.py +129 -0
  82. pymax/protocol/__init__.py +11 -0
  83. pymax/protocol/base.py +13 -0
  84. pymax/protocol/enums.py +180 -0
  85. pymax/protocol/models.py +33 -0
  86. pymax/protocol/tcp/__init__.py +1 -0
  87. pymax/protocol/tcp/compression.py +97 -0
  88. pymax/protocol/tcp/framing.py +68 -0
  89. pymax/protocol/tcp/payload.py +127 -0
  90. pymax/protocol/tcp/protocol.py +68 -0
  91. pymax/protocol/ws/__init__.py +1 -0
  92. pymax/protocol/ws/protocol.py +27 -0
  93. pymax/py.typed +0 -0
  94. pymax/routers.py +8 -0
  95. pymax/session/__init__.py +3 -0
  96. pymax/session/models.py +11 -0
  97. pymax/session/protocol.py +14 -0
  98. pymax/session/store.py +232 -0
  99. pymax/telemetry/__init__.py +3 -0
  100. pymax/telemetry/navigation.py +181 -0
  101. pymax/telemetry/payloads.py +142 -0
  102. pymax/telemetry/service.py +225 -0
  103. pymax/transport/__init__.py +0 -0
  104. pymax/transport/base.py +14 -0
  105. pymax/transport/tcp.py +93 -0
  106. pymax/transport/websocket.py +50 -0
  107. pymax/types/__init__.py +2 -0
  108. pymax/types/domain/__init__.py +11 -0
  109. pymax/types/domain/attachments/__init__.py +11 -0
  110. pymax/types/domain/attachments/audio.py +35 -0
  111. pymax/types/domain/attachments/call.py +26 -0
  112. pymax/types/domain/attachments/contact.py +32 -0
  113. pymax/types/domain/attachments/control.py +20 -0
  114. pymax/types/domain/attachments/enums.py +27 -0
  115. pymax/types/domain/attachments/file.py +56 -0
  116. pymax/types/domain/attachments/keyboards/__init__.py +1 -0
  117. pymax/types/domain/attachments/keyboards/inline.py +19 -0
  118. pymax/types/domain/attachments/photo.py +45 -0
  119. pymax/types/domain/attachments/share.py +29 -0
  120. pymax/types/domain/attachments/sticker.py +50 -0
  121. pymax/types/domain/attachments/video.py +90 -0
  122. pymax/types/domain/auth.py +161 -0
  123. pymax/types/domain/base.py +17 -0
  124. pymax/types/domain/chat.py +426 -0
  125. pymax/types/domain/element.py +24 -0
  126. pymax/types/domain/enums.py +24 -0
  127. pymax/types/domain/error.py +20 -0
  128. pymax/types/domain/folder.py +74 -0
  129. pymax/types/domain/login.py +35 -0
  130. pymax/types/domain/message.py +378 -0
  131. pymax/types/domain/name.py +20 -0
  132. pymax/types/domain/profile.py +15 -0
  133. pymax/types/domain/session.py +52 -0
  134. pymax/types/domain/sync.py +80 -0
  135. pymax/types/domain/user.py +117 -0
  136. pymax/types/events/__init__.py +3 -0
  137. pymax/types/events/file.py +5 -0
  138. pymax/types/events/message.py +37 -0
  139. pymax/types/events/video.py +5 -0
  140. maxapi_python-1.2.5.dist-info/METADATA +0 -202
  141. maxapi_python-1.2.5.dist-info/RECORD +0 -33
  142. pymax/core.py +0 -398
  143. pymax/crud.py +0 -96
  144. pymax/files.py +0 -138
  145. pymax/filters.py +0 -164
  146. pymax/formatter.py +0 -31
  147. pymax/formatting.py +0 -74
  148. pymax/interfaces.py +0 -558
  149. pymax/mixins/__init__.py +0 -40
  150. pymax/mixins/auth.py +0 -594
  151. pymax/mixins/channel.py +0 -130
  152. pymax/mixins/group.py +0 -458
  153. pymax/mixins/handler.py +0 -285
  154. pymax/mixins/message.py +0 -879
  155. pymax/mixins/scheduler.py +0 -28
  156. pymax/mixins/self.py +0 -259
  157. pymax/mixins/socket.py +0 -306
  158. pymax/mixins/telemetry.py +0 -118
  159. pymax/mixins/user.py +0 -219
  160. pymax/mixins/websocket.py +0 -151
  161. pymax/models.py +0 -8
  162. pymax/navigation.py +0 -187
  163. pymax/payloads.py +0 -403
  164. pymax/protocols.py +0 -123
  165. pymax/static/constant.py +0 -96
  166. pymax/static/enum.py +0 -231
  167. pymax/types.py +0 -1220
  168. pymax/utils.py +0 -90
  169. {maxapi_python-1.2.5.dist-info → maxapi_python-2.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,12 @@
1
+ from enum import Enum
2
+
3
+
4
+ class ContactAction(str, Enum):
5
+ ADD = "ADD"
6
+ REMOVE = "REMOVE"
7
+
8
+
9
+ class UserPayloadKey(str, Enum):
10
+ CONTACT = "contact"
11
+ CONTACTS = "contacts"
12
+ SESSIONS = "sessions"
@@ -0,0 +1,16 @@
1
+ from pymax.api.models import CamelModel
2
+
3
+ from .enums import ContactAction
4
+
5
+
6
+ class FetchContactsPayload(CamelModel):
7
+ contact_ids: list[int]
8
+
9
+
10
+ class SearchByPhonePayload(CamelModel):
11
+ phone: str
12
+
13
+
14
+ class ContactActionPayload(CamelModel):
15
+ contact_id: int
16
+ action: ContactAction
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Literal
4
+
5
+ from pymax.api.response import (
6
+ parse_payload_list,
7
+ require_payload_dict,
8
+ require_payload_item_model,
9
+ )
10
+ from pymax.logging import get_logger
11
+ from pymax.protocol import InboundFrame, Opcode
12
+ from pymax.types.domain import Session, User
13
+
14
+ from .enums import ContactAction, UserPayloadKey
15
+ from .payloads import (
16
+ ContactActionPayload,
17
+ FetchContactsPayload,
18
+ SearchByPhonePayload,
19
+ )
20
+
21
+ if TYPE_CHECKING:
22
+ from pymax.app import App
23
+
24
+
25
+ logger = get_logger(__name__)
26
+
27
+
28
+ class UserService:
29
+ def __init__(self, app: App) -> None:
30
+ self.app = app
31
+
32
+ def _cache_user(self, user: User) -> User:
33
+ self.app.users[user.id] = user
34
+ return user
35
+
36
+ def get_cached_user(self, user_id: int) -> User | None:
37
+ user = self.app.users.get(user_id)
38
+ logger.debug("get_cached_user id=%s hit=%s", user_id, bool(user))
39
+ return user
40
+
41
+ async def get_users(self, user_ids: list[int]) -> list[User]:
42
+ cached = {
43
+ user_id: user
44
+ for user_id in user_ids
45
+ if (user := self.get_cached_user(user_id)) is not None
46
+ }
47
+ missing_ids = [user_id for user_id in user_ids if user_id not in cached]
48
+
49
+ if missing_ids:
50
+ for user in await self.fetch_users(missing_ids):
51
+ cached[user.id] = user
52
+
53
+ return [cached[user_id] for user_id in user_ids if user_id in cached]
54
+
55
+ async def get_user(self, user_id: int) -> User | None:
56
+ if user := self.get_cached_user(user_id):
57
+ return user
58
+
59
+ users = await self.fetch_users([user_id])
60
+ return users[0] if users else None
61
+
62
+ async def fetch_users(self, user_ids: list[int]) -> list[User]:
63
+ logger.info("fetching users count=%s", len(user_ids))
64
+ frame = FetchContactsPayload(contact_ids=user_ids)
65
+ response = await self.app.invoke(Opcode.CONTACT_INFO, frame.to_payload())
66
+
67
+ users = [
68
+ self._cache_user(user)
69
+ for user in parse_payload_list(response, UserPayloadKey.CONTACTS, User)
70
+ ]
71
+ logger.debug("fetched users count=%s", len(users))
72
+ return users
73
+
74
+ async def search_by_phone(self, phone: str) -> User:
75
+ logger.info("searching user by phone phone_set=%s", bool(phone))
76
+ frame = SearchByPhonePayload(phone=phone)
77
+ response = await self.app.invoke(
78
+ Opcode.CONTACT_INFO_BY_PHONE,
79
+ frame.to_payload(),
80
+ )
81
+
82
+ contact = require_payload_item_model(
83
+ response,
84
+ UserPayloadKey.CONTACT,
85
+ User,
86
+ )
87
+ return self._cache_user(contact)
88
+
89
+ async def get_sessions(self) -> list[Session]:
90
+ logger.info("fetching sessions")
91
+ response = await self.app.invoke(Opcode.SESSIONS_INFO, {})
92
+ return parse_payload_list(response, UserPayloadKey.SESSIONS, Session)
93
+
94
+ async def _contact_action(self, payload: ContactActionPayload) -> InboundFrame:
95
+ response = await self.app.invoke(Opcode.CONTACT_UPDATE, payload.to_payload())
96
+ require_payload_dict(response)
97
+ return response
98
+
99
+ async def add_contact(self, contact_id: int) -> User:
100
+ response = await self._contact_action(
101
+ ContactActionPayload(
102
+ contact_id=contact_id,
103
+ action=ContactAction.ADD,
104
+ )
105
+ )
106
+ contact = require_payload_item_model(
107
+ response,
108
+ UserPayloadKey.CONTACT,
109
+ User,
110
+ )
111
+ return self._cache_user(contact)
112
+
113
+ async def remove_contact(self, contact_id: int) -> Literal[True]:
114
+ await self._contact_action(
115
+ ContactActionPayload(
116
+ contact_id=contact_id,
117
+ action=ContactAction.REMOVE,
118
+ )
119
+ )
120
+ self.app.users.pop(contact_id, None)
121
+ return True
122
+
123
+ def get_chat_id(self, first_user_id: int, second_user_id: int) -> int:
124
+ return first_user_id ^ second_user_id
pymax/app.py ADDED
@@ -0,0 +1,273 @@
1
+ import asyncio
2
+ from typing import Any, Generic, TypeVar
3
+
4
+ from pymax.api import ApiFacade
5
+ from pymax.auth import AuthFlow
6
+ from pymax.config import ClientConfig
7
+ from pymax.connection import ConnectionManager
8
+ from pymax.dispatch import Dispatcher
9
+ from pymax.dispatch.router import Router
10
+ from pymax.exceptions import ApiError
11
+ from pymax.logging import get_logger
12
+ from pymax.protocol import Command, InboundFrame, OutboundFrame
13
+ from pymax.protocol.enums import Opcode
14
+ from pymax.session import SessionStore
15
+ from pymax.session.models import SessionInfo
16
+ from pymax.telemetry import TelemetryService
17
+ from pymax.types import MaxApiError, Message
18
+ from pymax.types.domain import Chat, Profile, User
19
+
20
+ logger = get_logger(__name__)
21
+ ClientT = TypeVar("ClientT")
22
+
23
+
24
+ class App(Generic[ClientT]):
25
+ def __init__(
26
+ self,
27
+ connection: ConnectionManager,
28
+ config: ClientConfig,
29
+ auth_flow: AuthFlow,
30
+ root_router: Router[ClientT] | None = None,
31
+ ) -> None:
32
+ self.connection = connection
33
+ self.dispatcher: Dispatcher[ClientT] = Dispatcher(self, root_router)
34
+ self.api = ApiFacade(self)
35
+ self.config = config
36
+ self.store = self.config.store or SessionStore(config.work_dir, config.session_name)
37
+ self.auth_flow = auth_flow
38
+
39
+ self.me: Profile | None = None
40
+ self.chats: list[Chat] | None = None
41
+ self.users: dict[int, User] = {}
42
+ self.contacts: list[User | None] = []
43
+ self.messages: dict[int, list[Message]] = {}
44
+
45
+ self.session: SessionInfo | None = None
46
+
47
+ self.started = False
48
+ self._ping_task: asyncio.Task[None] | None = None
49
+ self._telemetry = TelemetryService(self) if config.telemetry else None
50
+
51
+ self.connection.on_event = self.on_event
52
+ logger.debug(
53
+ "app initialized session=%s work_dir=%s auth_flow=%s",
54
+ config.session_name,
55
+ config.work_dir,
56
+ type(self.auth_flow).__name__,
57
+ )
58
+
59
+ async def start(self) -> None:
60
+ if self.started:
61
+ logger.warning("start skipped: app already started")
62
+ return
63
+
64
+ logger.info("starting pymax client")
65
+ session_data = await self.store.load_session()
66
+ if session_data:
67
+ if session_data.mt_instance_id:
68
+ self.config.device.mt_instance_id = session_data.mt_instance_id
69
+ else:
70
+ session_data.mt_instance_id = self.config.device.mt_instance_id
71
+
72
+ try:
73
+ logger.debug("opening connection")
74
+ await self.connection.open()
75
+
76
+ handshake_device_id = (
77
+ session_data.device_id if session_data else self.config.device.device_id
78
+ )
79
+ logger.debug("running handshake")
80
+ await self.handshake(handshake_device_id)
81
+ except (ConnectionError, EOFError, OSError, TimeoutError) as e:
82
+ logger.exception("failed to connect or handshake")
83
+ await self.connection.close()
84
+ raise ConnectionError(f"Failed to connect and handshake: {e}") from e
85
+
86
+ self._ping_task = asyncio.create_task(self._ping_loop())
87
+
88
+ if not session_data:
89
+ logger.info("saved session not found; authentication required")
90
+
91
+ if self.config.token:
92
+ await self.store.save_session(
93
+ session_data := SessionInfo(
94
+ token=self.config.token,
95
+ device_id=self.config.device.device_id,
96
+ phone=self.config.phone or "",
97
+ mt_instance_id=self.config.device.mt_instance_id,
98
+ )
99
+ )
100
+ else:
101
+ auth_result = await self.auth_flow.authenticate(self)
102
+
103
+ if not auth_result.token:
104
+ logger.error("authentication finished without token")
105
+ raise RuntimeError("Authentication failed: no token received")
106
+
107
+ await self.store.save_session(
108
+ session_data := SessionInfo(
109
+ token=auth_result.token,
110
+ device_id=self.config.device.device_id,
111
+ phone=self.config.phone or "",
112
+ mt_instance_id=self.config.device.mt_instance_id,
113
+ )
114
+ )
115
+ logger.info("new session saved")
116
+ else:
117
+ logger.debug(
118
+ "loaded saved session device_id=%s phone_set=%s",
119
+ session_data.device_id,
120
+ bool(session_data.phone),
121
+ )
122
+
123
+ self.session = session_data
124
+
125
+ logger.debug("logging in")
126
+ response = await self.api.auth.login(
127
+ self.config.device.user_agent,
128
+ )
129
+
130
+ self.me = response.profile
131
+ self.chats = response.chats
132
+ self.users[self.me.contact.id] = self.me.contact
133
+ self.contacts = response.contacts
134
+ self.messages = response.messages
135
+
136
+ self.started = True
137
+ logger.info(
138
+ "client started profile=%s chats=%s",
139
+ self.me.contact.id,
140
+ len(self.chats or []),
141
+ )
142
+
143
+ if self._telemetry:
144
+ self._telemetry.start()
145
+
146
+ async def handshake(self, device_id: str) -> None:
147
+ await self.api.session.handshake(
148
+ self.config.device.mt_instance_id,
149
+ self.config.device.user_agent,
150
+ device_id,
151
+ )
152
+ logger.debug("handshake completed device_id=%s", device_id)
153
+
154
+ async def close(self) -> None:
155
+ if self._telemetry:
156
+ await self._telemetry.stop()
157
+
158
+ if self._ping_task:
159
+ task = self._ping_task
160
+ self._ping_task = None
161
+ if not task.done():
162
+ task.cancel()
163
+ try:
164
+ await task
165
+ except asyncio.CancelledError:
166
+ pass
167
+ except Exception:
168
+ logger.debug("ping task stopped with error", exc_info=True)
169
+
170
+ await self.dispatcher.stop_startup_tasks()
171
+ await self.connection.close()
172
+ await self.store.close()
173
+ self.started = False
174
+
175
+ async def invoke(
176
+ self,
177
+ opcode: int,
178
+ payload: dict[str, Any],
179
+ cmd: int = Command.REQUEST,
180
+ timeout: float | None = 30.0,
181
+ compress: bool = False,
182
+ ) -> InboundFrame:
183
+ seq = self.connection.next_seq()
184
+ payload_keys = sorted(payload.keys()) if payload else []
185
+ frame = OutboundFrame(
186
+ ver=self.connection.protocol.version,
187
+ opcode=opcode,
188
+ cmd=cmd,
189
+ seq=seq,
190
+ payload=payload,
191
+ )
192
+ logger.debug(
193
+ "request opcode=%s cmd=%s seq=%s timeout=%s compress=%s payload_keys=%s",
194
+ opcode,
195
+ cmd,
196
+ seq,
197
+ timeout,
198
+ compress,
199
+ payload_keys,
200
+ )
201
+ logger.debug("Request data=%s", frame.model_dump())
202
+ response = await self.connection.request(frame, timeout=timeout)
203
+ response_keys = sorted(response.payload.keys()) if response.payload else []
204
+ logger.debug(
205
+ "response opcode=%s cmd=%s seq=%s payload_keys=%s",
206
+ response.opcode,
207
+ response.cmd,
208
+ response.seq,
209
+ response_keys,
210
+ )
211
+ if response.cmd == Command.ERROR:
212
+ raise self._build_api_error(response)
213
+ return response
214
+
215
+ async def _ping_loop(self) -> None:
216
+ try:
217
+ while True:
218
+ await self.invoke(
219
+ opcode=Opcode.PING,
220
+ payload={"interactive": True},
221
+ timeout=self.config.request_timeout,
222
+ )
223
+ await asyncio.sleep(30)
224
+ except asyncio.CancelledError:
225
+ raise
226
+ except Exception as e:
227
+ logger.exception("ping loop failed; closing transport")
228
+ await self.connection.fail(ConnectionError(f"Ping failed: {e}"))
229
+
230
+ def _build_api_error(self, response: InboundFrame) -> ApiError:
231
+ try:
232
+ error = MaxApiError.model_validate(response.payload)
233
+ except Exception as e:
234
+ logger.exception("failed to validate API error")
235
+ error = MaxApiError(
236
+ error="unknown_error",
237
+ title="Unknown error",
238
+ message=str(e),
239
+ localized_message=str(e),
240
+ )
241
+
242
+ exc = ApiError(
243
+ opcode=response.opcode,
244
+ error=error.error,
245
+ title=error.title,
246
+ message=error.message,
247
+ localized_message=error.localized_message,
248
+ payload=response.payload,
249
+ )
250
+ logger.error(
251
+ "api error opcode=%s seq=%s error=%s title=%s message=%s localized_message=%s",
252
+ response.opcode,
253
+ response.seq,
254
+ error.error,
255
+ error.title,
256
+ error.message,
257
+ error.localized_message,
258
+ )
259
+ return exc
260
+
261
+ async def on_event(self, frame: InboundFrame) -> None:
262
+ logger.debug(
263
+ "event received opcode=%s cmd=%s seq=%s",
264
+ frame.opcode,
265
+ frame.cmd,
266
+ frame.seq,
267
+ )
268
+ logger.debug("Event data=%s", frame.payload)
269
+ try:
270
+ await self.dispatcher.dispatch(frame)
271
+ except Exception as e:
272
+ logger.exception("failed to dispatch inbound frame")
273
+ raise RuntimeError(f"Failed to dispatch inbound frame: {e}") from e
pymax/auth/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ from .base import AuthFlow
2
+ from .providers import (
3
+ ConsolePasswordProvider,
4
+ ConsoleQrHandler,
5
+ ConsoleSmsCodeProvider,
6
+ EmailCodeProvider,
7
+ PasswordProvider,
8
+ QrHandler,
9
+ SmsCodeProvider,
10
+ )
11
+ from .qr import QrAuthFlow
12
+ from .sms import SmsAuthFlow
13
+
14
+ __all__ = (
15
+ "AuthFlow",
16
+ "ConsolePasswordProvider",
17
+ "ConsoleQrHandler",
18
+ "ConsoleSmsCodeProvider",
19
+ "EmailCodeProvider",
20
+ "PasswordProvider",
21
+ "QrAuthFlow",
22
+ "QrHandler",
23
+ "SmsAuthFlow",
24
+ "SmsCodeProvider",
25
+ )
pymax/auth/base.py ADDED
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Protocol
4
+
5
+ from .models import AuthResult
6
+
7
+ if TYPE_CHECKING:
8
+ from pymax.app import App
9
+
10
+
11
+ class AuthFlow(Protocol):
12
+ """Протокол полного пользовательского сценария авторизации.
13
+
14
+ Используйте полный ``AuthFlow`` только для нестандартной авторизации. Для
15
+ обычных случаев проще заменить ``SmsCodeProvider``, ``PasswordProvider`` или
16
+ ``QrHandler``.
17
+
18
+ Example:
19
+ .. code-block:: python
20
+
21
+ from pymax import Client
22
+ from pymax.app import App
23
+ from pymax.auth.models import AuthResult
24
+
25
+ class StaticTokenFlow:
26
+ async def authenticate(self, app: App) -> AuthResult:
27
+ return AuthResult(token="TOKEN")
28
+
29
+ client = Client(
30
+ phone="+79990000000",
31
+ auth_flow=StaticTokenFlow(),
32
+ )
33
+ """
34
+
35
+ async def authenticate(self, app: App) -> AuthResult:
36
+ """Возвращает результат авторизации для новой локальной сессии."""
37
+ ...
pymax/auth/email.py ADDED
File without changes
pymax/auth/models.py ADDED
@@ -0,0 +1,5 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class AuthResult(BaseModel):
5
+ token: str | None = None
@@ -0,0 +1,127 @@
1
+ import asyncio
2
+ import getpass
3
+ from typing import Protocol
4
+
5
+ import qrcode
6
+
7
+
8
+ class SmsCodeProvider(Protocol):
9
+ """Протокол получения SMS-кода.
10
+
11
+ Реализуйте его, если код приходит не из консоли: из UI, очереди, callback
12
+ или внешнего сервиса.
13
+ """
14
+
15
+ async def get_code(self, phone: str) -> str:
16
+ """Возвращает SMS-код для указанного телефона."""
17
+ ...
18
+
19
+
20
+ class ConsoleSmsCodeProvider:
21
+ """Консольный provider SMS-кода.
22
+
23
+ При авторизации печатает prompt и читает код через ``input``.
24
+ """
25
+
26
+ async def get_code(self, phone: str) -> str:
27
+ """Запрашивает SMS-код в консоли.
28
+
29
+ Args:
30
+ phone: Телефон, для которого запрошен код.
31
+
32
+ Returns:
33
+ Введенный пользователем код.
34
+ """
35
+ prompt = f"Enter SMS code for {phone}: "
36
+ return (await asyncio.to_thread(input, prompt)).strip()
37
+
38
+
39
+ class PasswordProvider(Protocol):
40
+ """Протокол получения пароля 2FA.
41
+
42
+ Вызывается только если Max потребовал дополнительный пароль после SMS-кода.
43
+ """
44
+
45
+ async def get_password(self, hint: str | None = None) -> str:
46
+ """Возвращает пароль 2FA с учетом подсказки, если она есть."""
47
+ ...
48
+
49
+
50
+ class ConsolePasswordProvider:
51
+ """Консольный provider пароля 2FA.
52
+
53
+ Читает пароль через ``getpass`` и не выводит его на экран.
54
+ """
55
+
56
+ async def get_password(self, hint: str | None = None) -> str:
57
+ """Запрашивает пароль 2FA в консоли.
58
+
59
+ Args:
60
+ hint: Подсказка пароля от Max, если сервер ее прислал.
61
+
62
+ Returns:
63
+ Введенный пароль.
64
+ """
65
+ prompt = "Enter 2FA password"
66
+ if hint:
67
+ prompt += f" (hint: {hint})"
68
+ prompt += ": "
69
+ return (await asyncio.to_thread(getpass.getpass, prompt)).strip()
70
+
71
+
72
+ class QrHandler(Protocol):
73
+ """Протокол показа QR-ссылки пользователю.
74
+
75
+ Handler должен только показать ``qr_url``. Подтверждение и polling делает
76
+ ``QrAuthFlow``.
77
+ """
78
+
79
+ async def show_qr(self, qr_url: str) -> None:
80
+ """Показывает пользователю QR-ссылку для подтверждения входа."""
81
+ ...
82
+
83
+
84
+ # async def on_confirmed(self) -> None: ...
85
+
86
+
87
+ class ConsoleQrHandler:
88
+ """Консольный QR handler.
89
+
90
+ Печатает QR-код ASCII-графикой в терминал.
91
+ """
92
+
93
+ async def show_qr(self, qr_url: str) -> None:
94
+ """Показывает QR-ссылку в терминале.
95
+
96
+ Args:
97
+ qr_url: Ссылка, которую нужно открыть или отсканировать.
98
+
99
+ Returns:
100
+ ``None``.
101
+ """
102
+ qr = qrcode.QRCode()
103
+ qr.add_data(qr_url)
104
+ qr.print_ascii()
105
+
106
+
107
+ class EmailCodeProvider(Protocol):
108
+ """Протокол получения кода подтверждения email для настройки 2FA."""
109
+
110
+ async def get_code(self, email: str) -> str:
111
+ """Возвращает код подтверждения, отправленный на email."""
112
+ ...
113
+
114
+
115
+ class ConsoleEmailCodeProvider:
116
+ """Консольный provider кода подтверждения email для 2FA."""
117
+
118
+ async def get_code(self, email: str) -> str:
119
+ """Запрашивает email-код в консоли.
120
+
121
+ Args:
122
+ email: Адрес, на который Max отправил код подтверждения.
123
+
124
+ Returns:
125
+ Введенный пользователем код.
126
+ """
127
+ return input(f"Enter 2FA email code for {email}: ").strip()