maxapi-python 0.1.0__py3-none-any.whl → 0.1.1__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.
pymax/core.py DELETED
@@ -1,600 +0,0 @@
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 Any
9
-
10
- import websockets
11
-
12
- from .crud import Database
13
- from .exceptions import InvalidPhoneError, WebSocketNotConnectedError
14
- from .static import AuthType, ChatType, Constants, Opcode
15
- from .types import Channel, Chat, Dialog, Message, User
16
-
17
- logger = logging.getLogger(__name__)
18
-
19
-
20
- class MaxClient:
21
- """
22
- Основной клиент для работы с WebSocket API сервиса Max.
23
-
24
-
25
- Args:
26
- phone (str): Номер телефона для авторизации.
27
- uri (str, optional): URI WebSocket сервера. По умолчанию Constants.WEBSOCKET_URI.value.
28
- work_dir (str, optional): Рабочая директория для хранения базы данных. По умолчанию ".".
29
- logger (logging.Logger | None): Пользовательский логгер. Если не передан — используется
30
- логгер модуля с именем f"{__name__}.MaxClient".
31
-
32
- Raises:
33
- InvalidPhoneError: Если формат номера телефона неверный.
34
- """
35
-
36
- def __init__(
37
- self,
38
- phone: str,
39
- uri: str = Constants.WEBSOCKET_URI.value,
40
- work_dir: str = ".",
41
- logger: logging.Logger | None = None,
42
- ) -> None:
43
- self.uri: str = uri
44
- self.is_connected: bool = False
45
- self.phone: str = phone
46
- self.chats: list[Chat] = []
47
- self.dialogs: list[Dialog] = []
48
- self.channels: list[Channel] = []
49
- self._users: dict[int, User] = {}
50
- if not self._check_phone():
51
- raise InvalidPhoneError(self.phone)
52
- self._work_dir: str = work_dir
53
- self._database_path: Path = Path(work_dir) / "session.db"
54
- self._database_path.parent.mkdir(parents=True, exist_ok=True)
55
- self._database_path.touch(exist_ok=True)
56
- self._database = Database(self._work_dir)
57
- self._ws: websockets.ClientConnection | None = None
58
- self._seq: int = 0
59
- self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
60
- self._recv_task: asyncio.Task[Any] | None = None
61
- self._incoming: asyncio.Queue[dict[str, Any]] | None = None
62
- self._device_id = self._database.get_device_id()
63
- self._token = self._database.get_auth_token()
64
- self.user_agent = Constants.DEFAULT_USER_AGENT.value
65
- self._on_message_handler: Callable[[Message], Any] | None = None
66
- self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
67
- self._background_tasks: set[asyncio.Task[Any]] = set()
68
- self.logger = logger or logging.getLogger(f"{__name__}.MaxClient")
69
- self._setup_logger()
70
-
71
- self.logger.debug("Initialized MaxClient uri=%s work_dir=%s", self.uri, self._work_dir)
72
-
73
- def _setup_logger(self) -> None:
74
- self.logger.setLevel(logging.INFO)
75
-
76
- if not logger.handlers:
77
- handler = logging.StreamHandler()
78
- formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")
79
- handler.setFormatter(formatter)
80
- logger.addHandler(handler)
81
-
82
- @property
83
- def ws(self) -> websockets.ClientConnection:
84
- if self._ws is None or not self.is_connected:
85
- self.logger.critical("WebSocket not connected when access attempted")
86
- raise WebSocketNotConnectedError
87
- return self._ws
88
-
89
- def on_message(
90
- self, handler: Callable[[Message], Any | Awaitable[Any]]
91
- ) -> Callable[[Message], Any | Awaitable[Any]]:
92
- """
93
- Устанавливает обработчик входящих сообщений.
94
-
95
- Args:
96
- handler: Функция или coroutine, принимающая объект Message.
97
-
98
- Returns:
99
- Установленный обработчик.
100
- """
101
- self._on_message_handler = handler
102
- self.logger.debug("on_message handler set: %r", handler)
103
- return handler
104
-
105
- def on_start(
106
- self, handler: Callable[[], Any | Awaitable[Any]]
107
- ) -> Callable[[], Any | Awaitable[Any]]:
108
- """
109
- Устанавливает обработчик, вызываемый при старте клиента.
110
-
111
- Args:
112
- handler: Функция или coroutine без аргументов.
113
-
114
- Returns:
115
- Установленный обработчик.
116
- """
117
- self._on_start_handler = handler
118
- self.logger.debug("on_start handler set: %r", handler)
119
- return handler
120
-
121
- def add_message_handler(
122
- self, handler: Callable[[Message], Any | Awaitable[Any]]
123
- ) -> Callable[[Message], Any | Awaitable[Any]]:
124
- self.logger.debug("add_message_handler (alias) used")
125
- self._on_message_handler = handler
126
- return handler
127
-
128
- def add_on_start_handler(
129
- self, handler: Callable[[], Any | Awaitable[Any]]
130
- ) -> Callable[[], Any | Awaitable[Any]]:
131
- self.logger.debug("add_on_start_handler (alias) used")
132
- self._on_start_handler = handler
133
- return handler
134
-
135
- def _check_phone(self) -> bool:
136
- return bool(re.match(Constants.PHONE_REGEX.value, self.phone))
137
-
138
- def _make_message(self, opcode: int, payload: dict[str, Any], cmd: int = 0) -> dict[str, Any]:
139
- self._seq += 1
140
- msg = {
141
- "ver": 11,
142
- "cmd": cmd,
143
- "seq": self._seq,
144
- "opcode": opcode,
145
- "payload": payload,
146
- }
147
- self.logger.debug("make_message opcode=%s cmd=%s seq=%s", opcode, cmd, self._seq)
148
- return msg
149
-
150
- async def _send_interactive_ping(self) -> None:
151
- while self.is_connected:
152
- try:
153
- await self._send_and_wait(
154
- opcode=1,
155
- payload={"interactive": True},
156
- cmd=0,
157
- )
158
- self.logger.debug("Interactive ping sent successfully")
159
- except Exception:
160
- self.logger.warning("Interactive ping failed", exc_info=True)
161
- await asyncio.sleep(30)
162
-
163
- async def _connect(self, user_agent: dict[str, Any]) -> dict[str, Any]:
164
- try:
165
- self.logger.info("Connecting to WebSocket %s", self.uri)
166
- self._ws = await websockets.connect(self.uri)
167
- self.is_connected = True
168
- self._incoming = asyncio.Queue()
169
- self._pending = {}
170
- self._recv_task = asyncio.create_task(self._recv_loop())
171
- self.logger.info("WebSocket connected, starting handshake")
172
- return await self._handshake(user_agent)
173
- except Exception as e:
174
- self.logger.error("Failed to connect: %s", e, exc_info=True)
175
- raise ConnectionError(f"Failed to connect: {e}")
176
-
177
- async def _handshake(self, user_agent: dict[str, Any]) -> dict[str, Any]:
178
- try:
179
- self.logger.debug("Sending handshake with user_agent keys=%s", list(user_agent.keys()))
180
- resp = await self._send_and_wait(
181
- opcode=Opcode.HANDSHAKE,
182
- payload={"deviceId": str(self._device_id), "userAgent": user_agent},
183
- )
184
- self.logger.info("Handshake completed")
185
- return resp
186
- except Exception as e:
187
- self.logger.error("Handshake failed: %s", e, exc_info=True)
188
- raise ConnectionError(f"Handshake failed: {e}")
189
-
190
- async def _request_code(self, phone: str, language: str = "ru") -> dict[str, int | str]:
191
- try:
192
- self.logger.info("Requesting auth code")
193
- payload = {
194
- "phone": phone,
195
- "type": AuthType.START_AUTH.value,
196
- "language": language,
197
- }
198
- data = await self._send_and_wait(opcode=Opcode.REQUEST_CODE, payload=payload)
199
- self.logger.debug(
200
- "Code request response opcode=%s seq=%s", data.get("opcode"), data.get("seq")
201
- )
202
- return data.get("payload")
203
- except Exception:
204
- self.logger.error("Request code failed", exc_info=True)
205
- raise RuntimeError("Request code failed")
206
-
207
- async def _send_code(self, code: str, token: str) -> dict[str, Any]:
208
- try:
209
- self.logger.info("Sending verification code")
210
- payload = {
211
- "token": token,
212
- "verifyCode": code,
213
- "authTokenType": AuthType.CHECK_CODE.value,
214
- }
215
- data = await self._send_and_wait(opcode=Opcode.SEND_CODE, payload=payload)
216
- self.logger.debug(
217
- "Send code response opcode=%s seq=%s", data.get("opcode"), data.get("seq")
218
- )
219
- return data.get("payload")
220
- except Exception:
221
- self.logger.error("Send code failed", exc_info=True)
222
- raise RuntimeError("Send code failed")
223
-
224
- async def _recv_loop(self) -> None:
225
- if self._ws is None:
226
- self.logger.warning("Recv loop started without websocket instance")
227
- return
228
-
229
- self.logger.debug("Receive loop started")
230
- while True:
231
- try:
232
- raw = await self._ws.recv()
233
- try:
234
- data = json.loads(raw)
235
- except Exception:
236
- self.logger.warning("JSON parse error", exc_info=True)
237
- continue
238
-
239
- seq = data.get("seq")
240
- fut = self._pending.get(seq) if isinstance(seq, int) else None
241
-
242
- if fut and not fut.done():
243
- fut.set_result(data)
244
- self.logger.debug("Matched response for pending seq=%s", seq)
245
- else:
246
- if self._incoming is not None:
247
- try:
248
- self._incoming.put_nowait(data)
249
- except asyncio.QueueFull:
250
- self.logger.warning(
251
- "Incoming queue full; dropping message seq=%s", data.get("seq")
252
- )
253
-
254
- if data.get("opcode") == Opcode.NEW_MESSAGE and self._on_message_handler:
255
- try:
256
- payload = data.get("payload", {})
257
- msg = Message.from_dict(payload.get("message"))
258
- if msg:
259
- result = self._on_message_handler(msg)
260
- if asyncio.iscoroutine(result):
261
- task = asyncio.create_task(result)
262
- self._background_tasks.add(task)
263
- task.add_done_callback(
264
- lambda t: self._background_tasks.discard(t)
265
- or self._log_task_exception(t)
266
- )
267
- except Exception:
268
- self.logger.exception("Error in on_message_handler")
269
-
270
- except websockets.exceptions.ConnectionClosed:
271
- self.logger.info("WebSocket connection closed; exiting recv loop")
272
- break
273
- except Exception:
274
- self.logger.exception("Error in recv_loop; backing off briefly")
275
- await asyncio.sleep(0.5)
276
-
277
- def _log_task_exception(self, task: asyncio.Task[Any]) -> None:
278
- try:
279
- exc = task.exception()
280
- if exc:
281
- self.logger.exception("Background task exception: %s", exc)
282
- except Exception:
283
- # ignore inspection failures
284
- pass
285
-
286
- async def _send_and_wait(
287
- self,
288
- opcode: int,
289
- payload: dict[str, Any],
290
- cmd: int = 0,
291
- timeout: float = Constants.DEFAULT_TIMEOUT.value,
292
- ) -> dict[str, Any]:
293
- # Проверка соединения — с логированием критичности
294
- ws = self.ws # вызовет исключение и CRITICAL-лог, если не подключены
295
-
296
- msg = self._make_message(opcode, payload, cmd)
297
- loop = asyncio.get_running_loop()
298
- fut: asyncio.Future[dict[str, Any]] = loop.create_future()
299
- self._pending[msg["seq"]] = fut
300
-
301
- try:
302
- self.logger.debug("Sending frame opcode=%s cmd=%s seq=%s", opcode, cmd, msg["seq"])
303
- await ws.send(json.dumps(msg))
304
- data = await asyncio.wait_for(fut, timeout=timeout)
305
- self.logger.debug(
306
- "Received frame for seq=%s opcode=%s", data.get("seq"), data.get("opcode")
307
- )
308
- return data
309
- except Exception:
310
- self.logger.exception("Send and wait failed (opcode=%s, seq=%s)", opcode, msg["seq"])
311
- raise RuntimeError("Send and wait failed")
312
- finally:
313
- self._pending.pop(msg["seq"], None)
314
-
315
- async def _sync(self) -> None:
316
- try:
317
- self.logger.info("Starting initial sync")
318
- payload = {
319
- "interactive": True,
320
- "token": self._token,
321
- "chatsSync": 0,
322
- "contactsSync": 0,
323
- "presenceSync": 0,
324
- "draftsSync": 0,
325
- "chatsCount": 40,
326
- }
327
- data = await self._send_and_wait(opcode=19, payload=payload)
328
- if error := data.get("payload", {}).get("error"):
329
- self.logger.error("Sync error: %s", error)
330
- return
331
-
332
- for raw_chat in data.get("payload", {}).get("chats", []):
333
- try:
334
- if raw_chat.get("type") == ChatType.DIALOG.value:
335
- self.dialogs.append(Dialog.from_dict(raw_chat))
336
- elif raw_chat.get("type") == ChatType.CHAT.value:
337
- self.chats.append(Chat.from_dict(raw_chat))
338
- elif raw_chat.get("type") == ChatType.CHANNEL.value:
339
- self.channels.append(Channel.from_dict(raw_chat))
340
- except Exception:
341
- self.logger.exception("Error parsing chat entry")
342
- self.logger.info(
343
- "Sync completed: dialogs=%d chats=%d channels=%d",
344
- len(self.dialogs),
345
- len(self.chats),
346
- len(self.channels),
347
- )
348
- except Exception:
349
- self.logger.exception("Sync failed")
350
-
351
- async def send_message(self, text: str, chat_id: int, notify: bool) -> Message | None:
352
- """
353
- Отправляет сообщение в чат.
354
- """
355
- try:
356
- self.logger.info("Sending message to chat_id=%s notify=%s", chat_id, notify)
357
- payload = {
358
- "chatId": chat_id,
359
- "message": {
360
- "text": text,
361
- "cid": int(time.time() * 1000),
362
- "elements": [],
363
- "attaches": [],
364
- },
365
- "notify": notify,
366
- }
367
- data = await self._send_and_wait(opcode=Opcode.SEND_MESSAGE, payload=payload)
368
- if error := data.get("payload", {}).get("error"):
369
- self.logger.error("Send message error: %s", error)
370
- msg = Message.from_dict(data["payload"]["message"]) if data.get("payload") else None
371
- self.logger.debug("send_message result: %r", msg)
372
- return msg
373
- except Exception:
374
- self.logger.exception("Send message failed")
375
- return None
376
-
377
- async def edit_message(self, chat_id: int, message_id: int, text: str) -> Message | None:
378
- """
379
- Редактирует сообщение.
380
- """
381
- try:
382
- self.logger.info("Editing message chat_id=%s message_id=%s", chat_id, message_id)
383
- payload = {
384
- "chatId": chat_id,
385
- "messageId": message_id,
386
- "text": text,
387
- "elements": [],
388
- "attaches": [],
389
- }
390
- data = await self._send_and_wait(opcode=Opcode.EDIT_MESSAGE, payload=payload)
391
- if error := data.get("payload", {}).get("error"):
392
- self.logger.error("Edit message error: %s", error)
393
- msg = Message.from_dict(data["payload"]["message"]) if data.get("payload") else None
394
- self.logger.debug("edit_message result: %r", msg)
395
- return msg
396
- except Exception:
397
- self.logger.exception("Edit message failed")
398
- return None
399
-
400
- async def delete_message(self, chat_id: int, message_ids: list[int], for_me: bool) -> bool:
401
- """
402
- Удаляет сообщения.
403
- """
404
- try:
405
- self.logger.info(
406
- "Deleting messages chat_id=%s ids=%s for_me=%s", chat_id, message_ids, for_me
407
- )
408
- payload = {"chatId": chat_id, "messageIds": message_ids, "forMe": for_me}
409
- data = await self._send_and_wait(opcode=Opcode.DELETE_MESSAGE, payload=payload)
410
- if error := data.get("payload", {}).get("error"):
411
- self.logger.error("Delete message error: %s", error)
412
- return False
413
- self.logger.debug("delete_message success")
414
- return True
415
- except Exception:
416
- self.logger.exception("Delete message failed")
417
- return False
418
-
419
- async def close(self) -> None:
420
- try:
421
- self.logger.info("Closing client")
422
- if self._recv_task:
423
- self._recv_task.cancel()
424
- try:
425
- await self._recv_task
426
- except asyncio.CancelledError:
427
- self.logger.debug("recv_task cancelled")
428
- if self._ws:
429
- await self._ws.close()
430
- self.is_connected = False
431
- self.logger.info("Client closed")
432
- except Exception:
433
- self.logger.exception("Error closing client")
434
-
435
- def get_cached_user(self, user_id: int) -> User | None:
436
- """
437
- Получает юзера из кеша по его ID
438
-
439
- Args:
440
- user_id (int): ID пользователя.
441
-
442
- Returns:
443
- User | None: Объект User или None при ошибке.
444
- """
445
- user = self._users.get(user_id)
446
- self.logger.debug("get_cached_user id=%s hit=%s", user_id, bool(user))
447
- return user
448
-
449
- async def get_users(self, user_ids: list[int]) -> list[User]:
450
- """
451
- Получает информацию о пользователях по их ID (с кешем).
452
- """
453
- self.logger.debug("get_users ids=%s", user_ids)
454
- cached = {uid: self._users[uid] for uid in user_ids if uid in self._users}
455
- missing_ids = [uid for uid in user_ids if uid not in self._users]
456
-
457
- if missing_ids:
458
- self.logger.debug("Fetching missing users: %s", missing_ids)
459
- fetched_users = await self.fetch_users(missing_ids)
460
- if fetched_users:
461
- for user in fetched_users:
462
- self._users[user.id] = user
463
- cached[user.id] = user
464
-
465
- ordered = [cached[uid] for uid in user_ids if uid in cached]
466
- self.logger.debug("get_users result_count=%d", len(ordered))
467
- return ordered
468
-
469
- async def get_user(self, user_id: int) -> User | None:
470
- """
471
- Получает информацию о пользователе по его ID (с кешем).
472
- """
473
- self.logger.debug("get_user id=%s", user_id)
474
- if user_id in self._users:
475
- return self._users[user_id]
476
-
477
- users = await self.fetch_users([user_id])
478
- if users:
479
- self._users[user_id] = users[0]
480
- return users[0]
481
- return None
482
-
483
- async def fetch_users(self, user_ids: list[int]) -> None | list[User]:
484
- """
485
- Получает информацию о пользователях по их ID.
486
- """
487
- try:
488
- self.logger.info("Fetching users count=%d", len(user_ids))
489
- payload = {"contactIds": user_ids}
490
-
491
- data = await self._send_and_wait(opcode=Opcode.GET_CONTACTS_INFO, payload=payload)
492
- if error := data.get("payload", {}).get("error"):
493
- self.logger.error("Fetch users error: %s", error)
494
- return None
495
-
496
- users = [User.from_dict(u) for u in data["payload"].get("contacts", [])]
497
- for user in users:
498
- self._users[user.id] = user
499
-
500
- self.logger.debug("Fetched users: %d", len(users))
501
- return users
502
- except Exception:
503
- self.logger.exception("Fetch users failed")
504
- return []
505
-
506
- async def fetch_history(
507
- self,
508
- chat_id: int,
509
- from_time: int | None = None,
510
- forward: int = 0,
511
- backward: int = 200,
512
- ) -> list[Message] | None:
513
- """
514
- Получает историю сообщений чата.
515
- """
516
- if from_time is None:
517
- from_time = int(time.time() * 1000)
518
-
519
- try:
520
- self.logger.info(
521
- "Fetching history chat_id=%s from=%s forward=%s backward=%s",
522
- chat_id,
523
- from_time,
524
- forward,
525
- backward,
526
- )
527
- payload = {
528
- "chatId": chat_id,
529
- "from": from_time,
530
- "forward": forward,
531
- "backward": backward,
532
- "getMessages": True,
533
- }
534
-
535
- data = await self._send_and_wait(opcode=Opcode.FETCH_HISTORY, payload=payload)
536
- if error := data.get("payload", {}).get("error"):
537
- self.logger.error("Fetch history error: %s", error)
538
- return None
539
- messages = [Message.from_dict(msg) for msg in data["payload"].get("messages", [])]
540
- self.logger.debug("History fetched: %d messages", len(messages))
541
- return messages
542
- except Exception:
543
- self.logger.exception("Fetch history failed")
544
- return None
545
-
546
- async def _login(self) -> None:
547
- self.logger.info("Starting login flow")
548
- request_code_payload = await self._request_code(self.phone)
549
- temp_token = request_code_payload.get("token")
550
- if not temp_token or not isinstance(temp_token, str):
551
- self.logger.critical("Failed to request code: token missing")
552
- raise ValueError("Failed to request code")
553
-
554
- code = await asyncio.to_thread(input, "Введите код: ")
555
- if len(code) != 6 or not code.isdigit():
556
- self.logger.error("Invalid code format entered")
557
- raise ValueError("Invalid code format")
558
-
559
- login_resp = await self._send_code(code, temp_token)
560
- token: str | None = login_resp.get("tokenAttrs", {}).get("LOGIN", {}).get("token")
561
- if not token:
562
- self.logger.critical("Failed to login, token not received")
563
- raise ValueError("Failed to login, token not received")
564
-
565
- self._token = token
566
- self._database.update_auth_token(self._device_id, self._token)
567
- self.logger.info("Login successful, token saved to database")
568
-
569
- async def start(self) -> None:
570
- """
571
- Запускает клиент, подключается к WebSocket, авторизует
572
- пользователя (если нужно) и запускает фоновый цикл.
573
- """
574
- try:
575
- self.logger.info("Client starting")
576
- await self._connect(self.user_agent)
577
- if self._token is None:
578
- await self._login()
579
- else:
580
- await self._sync()
581
-
582
- if self._on_start_handler:
583
- self.logger.debug("Calling on_start handler")
584
- result = self._on_start_handler()
585
- if asyncio.iscoroutine(result):
586
- await result
587
-
588
- if self._ws:
589
- ping_task = asyncio.create_task(self._send_interactive_ping())
590
- self._background_tasks.add(ping_task)
591
- ping_task.add_done_callback(
592
- lambda t: self._background_tasks.discard(t) or self._log_task_exception(t)
593
- )
594
-
595
- try:
596
- await self._ws.wait_closed()
597
- except asyncio.CancelledError:
598
- self.logger.debug("wait_closed cancelled")
599
- except Exception:
600
- self.logger.exception("Client start failed")
pymax/crud.py DELETED
@@ -1,99 +0,0 @@
1
- from uuid import UUID
2
-
3
- from sqlalchemy.engine.base import Engine
4
- from sqlmodel import Session, SQLModel, create_engine, select
5
-
6
- from .models import Auth
7
- from .static import DeviceType
8
-
9
-
10
- class Database:
11
- def __init__(self, workdir: str) -> None:
12
- self.workdir = workdir
13
- self.engine = self.get_engine(workdir)
14
- self.create_all()
15
- self._ensure_single_auth()
16
-
17
- self.workdir = workdir
18
- self.engine = self.get_engine(workdir)
19
- self.create_all()
20
- self._ensure_single_auth()
21
-
22
- def create_all(self) -> None:
23
- SQLModel.metadata.create_all(self.engine)
24
-
25
- def get_engine(self, workdir: str) -> Engine:
26
- return create_engine(f"sqlite:///{workdir}/session.db")
27
-
28
- def get_session(self) -> Session:
29
- return Session(bind=self.engine)
30
-
31
- def get_auth_token(self) -> str | None:
32
- with self.get_session() as session:
33
- return session.exec(select(Auth.token)).first()
34
-
35
- def get_device_id(self) -> UUID:
36
- with self.get_session() as session:
37
- device_id = session.exec(select(Auth.device_id)).first()
38
- if device_id is None:
39
- auth = Auth()
40
- session.add(auth)
41
- session.commit()
42
- session.refresh(auth)
43
- return auth.device_id
44
- return device_id
45
-
46
- def insert_auth(self, auth: Auth) -> Auth:
47
- with self.get_session() as session:
48
- session.add(auth)
49
- session.commit()
50
- session.refresh(auth)
51
- return auth
52
-
53
- def update_auth_token(self, device_id: UUID, token: str) -> None:
54
- with self.get_session() as session:
55
- auth = session.exec(select(Auth).where(Auth.device_id == device_id)).first()
56
- if auth:
57
- auth.token = token
58
- session.add(auth)
59
- session.commit()
60
- session.refresh(auth)
61
- return
62
-
63
- existing = session.exec(select(Auth)).first()
64
- if existing:
65
- existing.device_id = device_id
66
- existing.token = token
67
- session.add(existing)
68
- session.commit()
69
- session.refresh(existing)
70
- return
71
-
72
- new_auth = Auth(device_id=device_id, token=token)
73
- session.add(new_auth)
74
- session.commit()
75
- session.refresh(new_auth)
76
-
77
- def update(self, auth: Auth) -> Auth:
78
- with self.get_session() as session:
79
- session.add(auth)
80
- session.commit()
81
- session.refresh(auth)
82
- return auth
83
-
84
- def _ensure_single_auth(self) -> None:
85
- with self.get_session() as session:
86
- rows = session.exec(select(Auth)).all()
87
- if not rows:
88
- # Create default Auth with device type from enum
89
- auth = Auth(device_type=DeviceType.WEB.value)
90
- session.add(auth)
91
- session.commit()
92
- session.refresh(auth)
93
- return
94
-
95
- if len(rows) > 1:
96
- keeper = rows[0]
97
- for extra in rows[1:]:
98
- session.delete(extra)
99
- session.commit()