maxapi-python 1.2.4__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 (168) 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.4.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/{static/enum.py → protocol/enums.py} +36 -79
  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.4.dist-info/METADATA +0 -205
  141. maxapi_python-1.2.4.dist-info/RECORD +0 -33
  142. pymax/core.py +0 -390
  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 -552
  149. pymax/mixins/__init__.py +0 -40
  150. pymax/mixins/auth.py +0 -368
  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 -297
  158. pymax/mixins/telemetry.py +0 -112
  159. pymax/mixins/user.py +0 -219
  160. pymax/mixins/websocket.py +0 -142
  161. pymax/models.py +0 -8
  162. pymax/navigation.py +0 -187
  163. pymax/payloads.py +0 -367
  164. pymax/protocols.py +0 -123
  165. pymax/static/constant.py +0 -89
  166. pymax/types.py +0 -1220
  167. pymax/utils.py +0 -90
  168. {maxapi_python-1.2.4.dist-info → maxapi_python-2.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,127 @@
1
+ from enum import Enum
2
+ from typing import Any
3
+
4
+ import msgpack
5
+
6
+ from pymax.logging import get_logger
7
+
8
+ from .compression import Lz4BlockCompression
9
+
10
+
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ class MsgpackPayloadCodec:
15
+ def _to_msgpack_value(self, value: Any) -> Any:
16
+ if isinstance(value, Enum):
17
+ return value.value
18
+ if isinstance(value, dict):
19
+ return {
20
+ self._to_msgpack_value(k): self._to_msgpack_value(v)
21
+ for k, v in value.items()
22
+ }
23
+ if isinstance(value, list):
24
+ return [self._to_msgpack_value(item) for item in value]
25
+ if isinstance(value, tuple):
26
+ return tuple(self._to_msgpack_value(item) for item in value)
27
+ return value
28
+
29
+ def encode(self, payload: object) -> bytes:
30
+ if payload is None:
31
+ return b""
32
+ return (
33
+ msgpack.packb(self._to_msgpack_value(payload), use_bin_type=True)
34
+ or b""
35
+ )
36
+
37
+ def _unpack_stream(self, payload_bytes: bytes, *, raw: bool) -> list[Any]:
38
+ unpacker = msgpack.Unpacker(raw=raw, strict_map_key=False)
39
+ unpacker.feed(payload_bytes)
40
+ return list(unpacker)
41
+
42
+ def decode(self, payload_bytes: bytes) -> dict[Any, Any]:
43
+ if not payload_bytes:
44
+ return {}
45
+
46
+ try:
47
+ return msgpack.unpackb(payload_bytes, raw=False, strict_map_key=False)
48
+ except msgpack.exceptions.ExtraData as e:
49
+ if isinstance(e.unpacked, dict):
50
+ logger.debug(
51
+ "msgpack payload has trailing data unpacked_type=%s extra_bytes=%s extra_head=%s",
52
+ type(e.unpacked).__name__,
53
+ len(e.extra),
54
+ e.extra[:16].hex(),
55
+ )
56
+ return e.unpacked
57
+
58
+ try:
59
+ values = self._unpack_stream(payload_bytes, raw=False)
60
+ except UnicodeDecodeError:
61
+ values = self._unpack_stream(payload_bytes, raw=True)
62
+ logger.debug(
63
+ "msgpack payload has extra data objects=%s extra_bytes=%s",
64
+ [type(value).__name__ for value in values],
65
+ len(e.extra),
66
+ )
67
+ for value in values:
68
+ if isinstance(value, dict):
69
+ return value
70
+ raise
71
+ except UnicodeDecodeError:
72
+ values = self._unpack_stream(payload_bytes, raw=True)
73
+ logger.debug(
74
+ "msgpack payload decoded with raw bytes objects=%s",
75
+ [type(value).__name__ for value in values],
76
+ )
77
+ for value in values:
78
+ if isinstance(value, dict):
79
+ return value
80
+ raise
81
+
82
+
83
+ class TcpPayloadDecoder:
84
+ def __init__(
85
+ self,
86
+ *,
87
+ serializer: MsgpackPayloadCodec,
88
+ compression: Lz4BlockCompression | None = None,
89
+ ) -> None:
90
+ self.serializer = serializer
91
+ self.compression = compression
92
+
93
+ def _normalize_keys(self, obj: Any) -> Any:
94
+ if isinstance(obj, dict):
95
+ return {
96
+ self._normalize_key(k): self._normalize_keys(v)
97
+ for k, v in obj.items()
98
+ }
99
+ if isinstance(obj, list):
100
+ return [self._normalize_keys(item) for item in obj]
101
+ if isinstance(obj, tuple):
102
+ return tuple(self._normalize_keys(item) for item in obj)
103
+ return obj
104
+
105
+ def _normalize_key(self, key: Any) -> Any:
106
+ if isinstance(key, int):
107
+ return str(key)
108
+ if isinstance(key, bytes):
109
+ try:
110
+ return key.decode("utf-8")
111
+ except UnicodeDecodeError:
112
+ return key.hex()
113
+ return key
114
+
115
+ def decode(self, payload_bytes: bytes, flags: int = 0) -> dict[str, Any]:
116
+ if not payload_bytes:
117
+ return {}
118
+
119
+ if flags & 0x03 and self.compression:
120
+ try:
121
+ payload_bytes = self.compression.decompress(payload_bytes)
122
+ logger.debug("tcp payload decompressed flags=%s", flags)
123
+ except ValueError:
124
+ logger.debug("tcp payload decompress skipped flags=%s", flags)
125
+
126
+ result = self.serializer.decode(payload_bytes)
127
+ return self._normalize_keys(result)
@@ -0,0 +1,68 @@
1
+ from pymax.logging import get_logger
2
+ from pymax.protocol import InboundFrame, OutboundFrame
3
+ from pymax.protocol.base import BaseProtocol
4
+
5
+ from .framing import TcpPacketFramer
6
+ from .payload import Lz4BlockCompression, MsgpackPayloadCodec, TcpPayloadDecoder
7
+
8
+ logger = get_logger(__name__)
9
+
10
+
11
+ class TcpProtocol(BaseProtocol):
12
+ version = 10
13
+
14
+ def __init__(self) -> None:
15
+ super().__init__()
16
+ self.framer = TcpPacketFramer()
17
+ self.serializer = MsgpackPayloadCodec()
18
+ self.compression = Lz4BlockCompression()
19
+ self.payload_decoder = TcpPayloadDecoder(
20
+ serializer=self.serializer, compression=self.compression
21
+ )
22
+
23
+ def encode(self, frame: OutboundFrame) -> bytes:
24
+ payload_bytes = self.serializer.encode(frame.payload) if frame.payload is not None else b""
25
+
26
+ flags = 0
27
+
28
+ # if frame.compress and payload_bytes:
29
+ # payload_bytes = self.compression.compress(payload_bytes)
30
+ # flags = 0x01
31
+
32
+ return self.framer.pack(
33
+ ver=self.version,
34
+ cmd=frame.cmd,
35
+ seq=frame.seq,
36
+ opcode=frame.opcode,
37
+ flags=flags,
38
+ payload_bytes=payload_bytes,
39
+ )
40
+
41
+ def decode(self, raw: bytes | str) -> InboundFrame:
42
+ if isinstance(raw, str):
43
+ raw = raw.encode("utf-8")
44
+
45
+ packed_packet = self.framer.unpack(raw)
46
+ if not packed_packet:
47
+ return InboundFrame(opcode=0, cmd=0, seq=None, payload=None, raw=None)
48
+
49
+ logger.debug(
50
+ "tcp frame decoded header ver=%s cmd=%s seq=%s opcode=%s flags=%s payload_len=%s",
51
+ packed_packet.header.ver,
52
+ packed_packet.header.cmd,
53
+ packed_packet.header.seq,
54
+ packed_packet.header.opcode,
55
+ packed_packet.header.flags,
56
+ packed_packet.header.payload_len,
57
+ )
58
+ payload = self.payload_decoder.decode(
59
+ packed_packet.payload_bytes, flags=packed_packet.header.flags
60
+ )
61
+
62
+ return InboundFrame(
63
+ opcode=packed_packet.header.opcode,
64
+ cmd=packed_packet.header.cmd,
65
+ seq=packed_packet.header.seq,
66
+ payload=payload,
67
+ raw=payload,
68
+ )
@@ -0,0 +1 @@
1
+ from .protocol import WsProtocol
@@ -0,0 +1,27 @@
1
+ import json
2
+
3
+ from pydantic import ValidationError
4
+
5
+ from pymax.logging import get_logger
6
+ from pymax.protocol import InboundFrame, OutboundFrame
7
+ from pymax.protocol.base import BaseProtocol
8
+
9
+ logger = get_logger(__name__)
10
+
11
+
12
+ class WsProtocol(BaseProtocol):
13
+ version = 11
14
+
15
+ def encode(self, frame: OutboundFrame) -> str:
16
+ return frame.model_dump_json()
17
+
18
+ def decode(self, raw: bytes | str) -> InboundFrame:
19
+ try:
20
+ data = json.loads(raw)
21
+ return InboundFrame.model_validate(data)
22
+ except json.JSONDecodeError:
23
+ logger.debug("failed to decode websocket frame json", exc_info=True)
24
+ return InboundFrame(opcode=0, cmd=0, seq=None, payload=None, raw=None)
25
+ except ValidationError:
26
+ logger.debug("failed to validate websocket frame", exc_info=True)
27
+ return InboundFrame(opcode=0, cmd=0, seq=None, payload=None, raw=None)
pymax/py.typed ADDED
File without changes
pymax/routers.py ADDED
@@ -0,0 +1,8 @@
1
+ from typing import TypeAlias
2
+
3
+ from pymax.client import Client
4
+ from pymax.client_web import WebClient
5
+ from pymax.dispatch.router import Router
6
+
7
+ ClientRouter: TypeAlias = Router[Client]
8
+ WebRouter: TypeAlias = Router[WebClient]
@@ -0,0 +1,3 @@
1
+ from .models import SessionInfo
2
+ from .protocol import StoreProtocol
3
+ from .store import SessionStore
@@ -0,0 +1,11 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+ from pymax.types.domain.sync import SyncState
4
+
5
+
6
+ class SessionInfo(BaseModel):
7
+ token: str
8
+ device_id: str
9
+ phone: str
10
+ mt_instance_id: str = ""
11
+ sync: SyncState = Field(default_factory=SyncState)
@@ -0,0 +1,14 @@
1
+ from typing import Protocol, runtime_checkable
2
+
3
+ from .models import SessionInfo
4
+
5
+
6
+ @runtime_checkable
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: ...
pymax/session/store.py ADDED
@@ -0,0 +1,232 @@
1
+ from pathlib import Path
2
+
3
+ import aiosqlite
4
+
5
+ from pymax.logging import get_logger
6
+ from pymax.types.domain.sync import DEFAULT_CONFIG_HASH, SyncState
7
+
8
+ from .models import SessionInfo
9
+
10
+ logger = get_logger(__name__)
11
+
12
+ SESSION_COLUMNS = """
13
+ token,
14
+ device_id,
15
+ phone,
16
+ mt_instance_id,
17
+ chats_sync,
18
+ contacts_sync,
19
+ drafts_sync,
20
+ presence_sync,
21
+ config_hash
22
+ """
23
+
24
+
25
+ class SessionStore:
26
+ def __init__(self, work_dir: str, db_name: str = "session.db") -> None:
27
+ self.work_dir = Path(work_dir)
28
+ self.work_dir.mkdir(parents=True, exist_ok=True)
29
+ self.db_path = str(self.work_dir / db_name)
30
+ self.conn: aiosqlite.Connection | None = None
31
+ logger.debug("session store initialized db=%s", self.db_path)
32
+
33
+ async def _get_connection(self) -> aiosqlite.Connection:
34
+ if self.conn is None:
35
+ logger.debug("opening session database db=%s", self.db_path)
36
+ self.conn = await aiosqlite.connect(self.db_path)
37
+ self.conn.row_factory = aiosqlite.Row
38
+ await self._initialize_db(self.conn)
39
+ return self.conn
40
+
41
+ async def _initialize_db(self, conn: aiosqlite.Connection) -> None:
42
+ logger.debug("initializing session database")
43
+ await conn.execute(
44
+ """
45
+ CREATE TABLE IF NOT EXISTS sessions (
46
+ token TEXT NOT NULL PRIMARY KEY,
47
+ device_id TEXT NOT NULL,
48
+ phone TEXT NOT NULL,
49
+ mt_instance_id TEXT NOT NULL DEFAULT '',
50
+ chats_sync INTEGER NOT NULL DEFAULT -1,
51
+ contacts_sync INTEGER NOT NULL DEFAULT -1,
52
+ drafts_sync INTEGER NOT NULL DEFAULT -1,
53
+ presence_sync INTEGER NOT NULL DEFAULT -1,
54
+ config_hash TEXT NOT NULL DEFAULT ''
55
+ )
56
+ """
57
+ )
58
+ await self._ensure_column(conn, "mt_instance_id", "TEXT NOT NULL DEFAULT ''")
59
+ await self._ensure_column(conn, "chats_sync", "INTEGER NOT NULL DEFAULT -1")
60
+ await self._ensure_column(conn, "contacts_sync", "INTEGER NOT NULL DEFAULT -1")
61
+ await self._ensure_column(conn, "drafts_sync", "INTEGER NOT NULL DEFAULT -1")
62
+ await self._ensure_column(conn, "presence_sync", "INTEGER NOT NULL DEFAULT -1")
63
+ await self._ensure_column(conn, "config_hash", "TEXT NOT NULL DEFAULT ''")
64
+ await conn.execute(
65
+ """
66
+ UPDATE sessions
67
+ SET config_hash = ?
68
+ WHERE config_hash = ''
69
+ """,
70
+ (DEFAULT_CONFIG_HASH,),
71
+ )
72
+ await conn.commit()
73
+
74
+ async def _ensure_column(
75
+ self,
76
+ conn: aiosqlite.Connection,
77
+ name: str,
78
+ definition: str,
79
+ ) -> None:
80
+ async with conn.execute("PRAGMA table_info(sessions)") as cursor:
81
+ columns = {row["name"] for row in await cursor.fetchall()}
82
+
83
+ if name not in columns:
84
+ await conn.execute(f"ALTER TABLE sessions ADD COLUMN {name} {definition}")
85
+
86
+ async def save_session(self, session_info: SessionInfo) -> None:
87
+ conn = await self._get_connection()
88
+ logger.debug(
89
+ "saving session device_id=%s phone_set=%s mt_instance_id_set=%s",
90
+ session_info.device_id,
91
+ bool(session_info.phone),
92
+ bool(session_info.mt_instance_id),
93
+ )
94
+ await conn.execute(
95
+ """
96
+ INSERT OR REPLACE INTO sessions (
97
+ token,
98
+ device_id,
99
+ phone,
100
+ mt_instance_id,
101
+ chats_sync,
102
+ contacts_sync,
103
+ drafts_sync,
104
+ presence_sync,
105
+ config_hash
106
+ )
107
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
108
+ """,
109
+ (
110
+ session_info.token,
111
+ session_info.device_id,
112
+ session_info.phone,
113
+ session_info.mt_instance_id,
114
+ session_info.sync.chats_sync,
115
+ session_info.sync.contacts_sync,
116
+ session_info.sync.drafts_sync,
117
+ session_info.sync.presence_sync,
118
+ session_info.sync.config_hash,
119
+ ),
120
+ )
121
+ await conn.commit()
122
+ logger.info("session saved")
123
+
124
+ async def load_session(self) -> SessionInfo | None:
125
+ conn = await self._get_connection()
126
+ logger.debug("loading first session")
127
+ async with conn.execute(
128
+ f"""
129
+ SELECT {SESSION_COLUMNS}
130
+ FROM sessions
131
+ LIMIT 1
132
+ """,
133
+ ) as cursor:
134
+ row = await cursor.fetchone()
135
+
136
+ if row is None:
137
+ logger.debug("session not found")
138
+ return None
139
+
140
+ logger.debug(
141
+ "session loaded device_id=%s phone_set=%s",
142
+ row["device_id"],
143
+ bool(row["phone"]),
144
+ )
145
+ return self._row_to_session(row)
146
+
147
+ async def load_session_by_device_id(self, device_id: str) -> SessionInfo | None:
148
+ conn = await self._get_connection()
149
+ logger.debug("loading session by device_id=%s", device_id)
150
+ async with conn.execute(
151
+ f"""
152
+ SELECT {SESSION_COLUMNS}
153
+ FROM sessions
154
+ WHERE device_id = ?
155
+ """,
156
+ (device_id,),
157
+ ) as cursor:
158
+ row = await cursor.fetchone()
159
+
160
+ if row is None:
161
+ logger.debug("session not found by device_id=%s", device_id)
162
+ return None
163
+
164
+ return self._row_to_session(row)
165
+
166
+ async def load_session_by_phone(self, phone: str) -> SessionInfo | None:
167
+ conn = await self._get_connection()
168
+ logger.debug("loading session by phone_set=%s", bool(phone))
169
+ async with conn.execute(
170
+ f"""
171
+ SELECT {SESSION_COLUMNS}
172
+ FROM sessions
173
+ WHERE phone = ?
174
+ """,
175
+ (phone,),
176
+ ) as cursor:
177
+ row = await cursor.fetchone()
178
+
179
+ if row is None:
180
+ logger.debug("session not found by phone_set=%s", bool(phone))
181
+ return None
182
+
183
+ return self._row_to_session(row)
184
+
185
+ async def delete_session(self, token: str) -> None:
186
+ conn = await self._get_connection()
187
+ logger.warning("deleting session token_set=%s", bool(token))
188
+ await conn.execute(
189
+ """
190
+ DELETE FROM sessions WHERE token = ?
191
+ """,
192
+ (token,),
193
+ )
194
+ await conn.commit()
195
+ logger.info("session deleted")
196
+
197
+ async def update_token(self, old_token: str, new_token: str) -> None:
198
+ conn = await self._get_connection()
199
+ logger.debug(
200
+ "updating session token old_token_set=%s new_token_set=%s",
201
+ bool(old_token),
202
+ bool(new_token),
203
+ )
204
+ await conn.execute(
205
+ """
206
+ UPDATE sessions SET token = ? WHERE token = ?
207
+ """,
208
+ (new_token, old_token),
209
+ )
210
+ await conn.commit()
211
+ logger.info("session token updated")
212
+
213
+ async def close(self) -> None:
214
+ if self.conn is not None:
215
+ logger.debug("closing session database")
216
+ await self.conn.close()
217
+ self.conn = None
218
+
219
+ def _row_to_session(self, row: aiosqlite.Row) -> SessionInfo:
220
+ return SessionInfo(
221
+ token=row["token"],
222
+ device_id=row["device_id"],
223
+ phone=row["phone"],
224
+ mt_instance_id=row["mt_instance_id"] or "",
225
+ sync=SyncState(
226
+ chats_sync=row["chats_sync"],
227
+ contacts_sync=row["contacts_sync"],
228
+ drafts_sync=row["drafts_sync"],
229
+ presence_sync=row["presence_sync"],
230
+ config_hash=row["config_hash"] or DEFAULT_CONFIG_HASH,
231
+ ),
232
+ )
@@ -0,0 +1,3 @@
1
+ from .service import TelemetryService
2
+
3
+ __all__ = ("TelemetryService",)
@@ -0,0 +1,181 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import IntEnum
4
+ from random import Random
5
+
6
+ from pydantic import BaseModel
7
+
8
+
9
+ class Screen(IntEnum):
10
+ BACKGROUND = 1
11
+ CONTACTS = 100
12
+ CHATS = 150
13
+ SEARCH = 151
14
+ CALLS = 300
15
+ CHAT = 350
16
+ SETTINGS = 450
17
+ MINIAPP = 500
18
+
19
+
20
+ MAIN_TAB_PARAMS = {
21
+ "source_type": 5,
22
+ "source_id": 1,
23
+ "tab_config": 2,
24
+ }
25
+
26
+
27
+ class ScreenTransition(BaseModel):
28
+ model_config = {"frozen": True}
29
+
30
+ screen: Screen
31
+ weight: int
32
+
33
+
34
+ class RouteProfile(BaseModel):
35
+ model_config = {"frozen": True}
36
+
37
+ steps: int
38
+ min_pause: float
39
+ max_pause: float
40
+ long_pause_chance: float
41
+ min_long_pause: float
42
+ max_long_pause: float
43
+ back_chance: float
44
+
45
+ def pause(self, rng: Random) -> float:
46
+ if rng.random() < self.long_pause_chance:
47
+ return rng.uniform(self.min_long_pause, self.max_long_pause)
48
+ return rng.uniform(self.min_pause, self.max_pause)
49
+
50
+
51
+ class NavigationRules(BaseModel):
52
+ model_config = {"frozen": True}
53
+
54
+ profiles: dict[str, RouteProfile]
55
+ graph: dict[Screen, tuple[ScreenTransition, ...]]
56
+
57
+ def choose_profile(self, rng: Random) -> RouteProfile:
58
+ name = rng.choice(tuple(self.profiles))
59
+ return self.profiles[name]
60
+
61
+
62
+ DEFAULT_RULES = NavigationRules(
63
+ profiles={
64
+ "quick": RouteProfile(
65
+ steps=2,
66
+ min_pause=35.0,
67
+ max_pause=95.0,
68
+ long_pause_chance=0.05,
69
+ min_long_pause=180.0,
70
+ max_long_pause=420.0,
71
+ back_chance=0.30,
72
+ ),
73
+ "browse": RouteProfile(
74
+ steps=4,
75
+ min_pause=70.0,
76
+ max_pause=210.0,
77
+ long_pause_chance=0.12,
78
+ min_long_pause=240.0,
79
+ max_long_pause=720.0,
80
+ back_chance=0.22,
81
+ ),
82
+ "read": RouteProfile(
83
+ steps=3,
84
+ min_pause=140.0,
85
+ max_pause=360.0,
86
+ long_pause_chance=0.25,
87
+ min_long_pause=420.0,
88
+ max_long_pause=1200.0,
89
+ back_chance=0.18,
90
+ ),
91
+ },
92
+ graph={
93
+ Screen.BACKGROUND: (
94
+ ScreenTransition(screen=Screen.CHATS, weight=10),
95
+ ScreenTransition(screen=Screen.SETTINGS, weight=1),
96
+ ),
97
+ Screen.CHATS: (
98
+ ScreenTransition(screen=Screen.CHAT, weight=7),
99
+ ScreenTransition(screen=Screen.CONTACTS, weight=2),
100
+ ScreenTransition(screen=Screen.SEARCH, weight=2),
101
+ ScreenTransition(screen=Screen.CALLS, weight=1),
102
+ ScreenTransition(screen=Screen.SETTINGS, weight=1),
103
+ ScreenTransition(screen=Screen.CHATS, weight=2),
104
+ ),
105
+ Screen.CHAT: (
106
+ ScreenTransition(screen=Screen.CHATS, weight=8),
107
+ ScreenTransition(screen=Screen.CHAT, weight=2),
108
+ ScreenTransition(screen=Screen.SETTINGS, weight=1),
109
+ ),
110
+ Screen.CONTACTS: (
111
+ ScreenTransition(screen=Screen.CHATS, weight=6),
112
+ ScreenTransition(screen=Screen.CHAT, weight=2),
113
+ ScreenTransition(screen=Screen.SEARCH, weight=1),
114
+ ),
115
+ Screen.SEARCH: (
116
+ ScreenTransition(screen=Screen.CHATS, weight=5),
117
+ ScreenTransition(screen=Screen.CHAT, weight=3),
118
+ ScreenTransition(screen=Screen.CONTACTS, weight=1),
119
+ ),
120
+ Screen.CALLS: (
121
+ ScreenTransition(screen=Screen.CHATS, weight=5),
122
+ ScreenTransition(screen=Screen.CONTACTS, weight=2),
123
+ ScreenTransition(screen=Screen.SETTINGS, weight=2),
124
+ ),
125
+ Screen.SETTINGS: (
126
+ ScreenTransition(screen=Screen.CHATS, weight=7),
127
+ ScreenTransition(screen=Screen.CONTACTS, weight=2),
128
+ ScreenTransition(screen=Screen.CALLS, weight=2),
129
+ ScreenTransition(screen=Screen.MINIAPP, weight=1),
130
+ ),
131
+ Screen.MINIAPP: (
132
+ ScreenTransition(screen=Screen.SETTINGS, weight=3),
133
+ ScreenTransition(screen=Screen.CHATS, weight=6),
134
+ ),
135
+ },
136
+ )
137
+
138
+
139
+ class NavigationPlanner:
140
+ def __init__(
141
+ self,
142
+ rng: Random,
143
+ rules: NavigationRules = DEFAULT_RULES,
144
+ ) -> None:
145
+ self.rng = rng
146
+ self.rules = rules
147
+ self.current_screen = Screen.BACKGROUND
148
+ self.history: list[Screen] = []
149
+
150
+ def new_profile(self) -> RouteProfile:
151
+ return self.rules.choose_profile(self.rng)
152
+
153
+ def next_screen(self, profile: RouteProfile) -> Screen:
154
+ if self.history and self.rng.random() < profile.back_chance:
155
+ self.current_screen = self.history.pop()
156
+ return self.current_screen
157
+
158
+ next_screen = self._weighted_choice(self.rules.graph[self.current_screen])
159
+ if next_screen != self.current_screen:
160
+ self.history.append(self.current_screen)
161
+ if len(self.history) > 4:
162
+ del self.history[0]
163
+ self.current_screen = next_screen
164
+ return next_screen
165
+
166
+ def reset_to_background(self) -> None:
167
+ self.current_screen = Screen.BACKGROUND
168
+ self.history.clear()
169
+
170
+ def _weighted_choice(
171
+ self,
172
+ transitions: tuple[ScreenTransition, ...],
173
+ ) -> Screen:
174
+ total = sum(item.weight for item in transitions)
175
+ point = self.rng.randint(1, total)
176
+ current = 0
177
+ for item in transitions:
178
+ current += item.weight
179
+ if point <= current:
180
+ return item.screen
181
+ return transitions[-1].screen