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.
- maxapi_python-2.0.0.dist-info/METADATA +217 -0
- maxapi_python-2.0.0.dist-info/RECORD +140 -0
- {maxapi_python-1.2.4.dist-info → maxapi_python-2.0.0.dist-info}/WHEEL +1 -1
- pymax/__init__.py +50 -105
- pymax/api/__init__.py +17 -0
- pymax/api/auth/__init__.py +1 -0
- pymax/api/auth/enums.py +17 -0
- pymax/api/auth/payloads.py +129 -0
- pymax/api/auth/service.py +313 -0
- pymax/api/auth/types.py +13 -0
- pymax/api/chats/__init__.py +8 -0
- pymax/api/chats/enums.py +27 -0
- pymax/api/chats/payloads.py +103 -0
- pymax/api/chats/service.py +277 -0
- pymax/api/facade.py +32 -0
- pymax/api/messages/__init__.py +1 -0
- pymax/api/messages/enums.py +17 -0
- pymax/api/messages/payloads.py +92 -0
- pymax/api/messages/service.py +337 -0
- pymax/api/models.py +13 -0
- pymax/api/response.py +123 -0
- pymax/api/self/__init__.py +2 -0
- pymax/api/self/enums.py +11 -0
- pymax/api/self/payloads.py +41 -0
- pymax/api/self/service.py +142 -0
- pymax/api/session/__init__.py +1 -0
- pymax/api/session/enums.py +10 -0
- pymax/api/session/payloads.py +76 -0
- pymax/api/session/service.py +72 -0
- pymax/api/uploads/__init__.py +1 -0
- pymax/api/uploads/models.py +49 -0
- pymax/api/uploads/payloads.py +25 -0
- pymax/api/uploads/service.py +458 -0
- pymax/api/users/__init__.py +2 -0
- pymax/api/users/enums.py +12 -0
- pymax/api/users/payloads.py +16 -0
- pymax/api/users/service.py +124 -0
- pymax/app.py +273 -0
- pymax/auth/__init__.py +25 -0
- pymax/auth/base.py +37 -0
- pymax/auth/email.py +0 -0
- pymax/auth/models.py +5 -0
- pymax/auth/providers.py +127 -0
- pymax/auth/qr.py +135 -0
- pymax/auth/service.py +25 -0
- pymax/auth/sms.py +122 -0
- pymax/base.py +204 -0
- pymax/client.py +106 -0
- pymax/client_web.py +83 -0
- pymax/config.py +215 -0
- pymax/connection/__init__.py +1 -0
- pymax/connection/connection.py +205 -0
- pymax/connection/pending.py +46 -0
- pymax/connection/readers/__init__.py +2 -0
- pymax/connection/readers/base.py +6 -0
- pymax/connection/readers/tcp.py +29 -0
- pymax/connection/readers/ws.py +14 -0
- pymax/dispatch/__init__.py +10 -0
- pymax/dispatch/dispatcher.py +222 -0
- pymax/dispatch/enums.py +12 -0
- pymax/dispatch/mapping.py +73 -0
- pymax/dispatch/resolvers.py +52 -0
- pymax/dispatch/router.py +216 -0
- pymax/exceptions.py +22 -89
- pymax/files/__init__.py +9 -0
- pymax/files/base.py +82 -0
- pymax/files/file.py +76 -0
- pymax/files/photo.py +108 -0
- pymax/files/static.py +10 -0
- pymax/files/video.py +74 -0
- pymax/formatting/__init__.py +0 -0
- pymax/formatting/markdown.py +217 -0
- pymax/infra/__init__.py +1 -0
- pymax/infra/auth.py +55 -0
- pymax/infra/base.py +15 -0
- pymax/infra/chat.py +240 -0
- pymax/infra/message.py +252 -0
- pymax/infra/protocol.py +9 -0
- pymax/infra/self.py +139 -0
- pymax/infra/user.py +107 -0
- pymax/logging.py +129 -0
- pymax/protocol/__init__.py +11 -0
- pymax/protocol/base.py +13 -0
- pymax/{static/enum.py → protocol/enums.py} +36 -79
- pymax/protocol/models.py +33 -0
- pymax/protocol/tcp/__init__.py +1 -0
- pymax/protocol/tcp/compression.py +97 -0
- pymax/protocol/tcp/framing.py +68 -0
- pymax/protocol/tcp/payload.py +127 -0
- pymax/protocol/tcp/protocol.py +68 -0
- pymax/protocol/ws/__init__.py +1 -0
- pymax/protocol/ws/protocol.py +27 -0
- pymax/py.typed +0 -0
- pymax/routers.py +8 -0
- pymax/session/__init__.py +3 -0
- pymax/session/models.py +11 -0
- pymax/session/protocol.py +14 -0
- pymax/session/store.py +232 -0
- pymax/telemetry/__init__.py +3 -0
- pymax/telemetry/navigation.py +181 -0
- pymax/telemetry/payloads.py +142 -0
- pymax/telemetry/service.py +225 -0
- pymax/transport/__init__.py +0 -0
- pymax/transport/base.py +14 -0
- pymax/transport/tcp.py +93 -0
- pymax/transport/websocket.py +50 -0
- pymax/types/__init__.py +2 -0
- pymax/types/domain/__init__.py +11 -0
- pymax/types/domain/attachments/__init__.py +11 -0
- pymax/types/domain/attachments/audio.py +35 -0
- pymax/types/domain/attachments/call.py +26 -0
- pymax/types/domain/attachments/contact.py +32 -0
- pymax/types/domain/attachments/control.py +20 -0
- pymax/types/domain/attachments/enums.py +27 -0
- pymax/types/domain/attachments/file.py +56 -0
- pymax/types/domain/attachments/keyboards/__init__.py +1 -0
- pymax/types/domain/attachments/keyboards/inline.py +19 -0
- pymax/types/domain/attachments/photo.py +45 -0
- pymax/types/domain/attachments/share.py +29 -0
- pymax/types/domain/attachments/sticker.py +50 -0
- pymax/types/domain/attachments/video.py +90 -0
- pymax/types/domain/auth.py +161 -0
- pymax/types/domain/base.py +17 -0
- pymax/types/domain/chat.py +426 -0
- pymax/types/domain/element.py +24 -0
- pymax/types/domain/enums.py +24 -0
- pymax/types/domain/error.py +20 -0
- pymax/types/domain/folder.py +74 -0
- pymax/types/domain/login.py +35 -0
- pymax/types/domain/message.py +378 -0
- pymax/types/domain/name.py +20 -0
- pymax/types/domain/profile.py +15 -0
- pymax/types/domain/session.py +52 -0
- pymax/types/domain/sync.py +80 -0
- pymax/types/domain/user.py +117 -0
- pymax/types/events/__init__.py +3 -0
- pymax/types/events/file.py +5 -0
- pymax/types/events/message.py +37 -0
- pymax/types/events/video.py +5 -0
- maxapi_python-1.2.4.dist-info/METADATA +0 -205
- maxapi_python-1.2.4.dist-info/RECORD +0 -33
- pymax/core.py +0 -390
- pymax/crud.py +0 -96
- pymax/files.py +0 -138
- pymax/filters.py +0 -164
- pymax/formatter.py +0 -31
- pymax/formatting.py +0 -74
- pymax/interfaces.py +0 -552
- pymax/mixins/__init__.py +0 -40
- pymax/mixins/auth.py +0 -368
- pymax/mixins/channel.py +0 -130
- pymax/mixins/group.py +0 -458
- pymax/mixins/handler.py +0 -285
- pymax/mixins/message.py +0 -879
- pymax/mixins/scheduler.py +0 -28
- pymax/mixins/self.py +0 -259
- pymax/mixins/socket.py +0 -297
- pymax/mixins/telemetry.py +0 -112
- pymax/mixins/user.py +0 -219
- pymax/mixins/websocket.py +0 -142
- pymax/models.py +0 -8
- pymax/navigation.py +0 -187
- pymax/payloads.py +0 -367
- pymax/protocols.py +0 -123
- pymax/static/constant.py +0 -89
- pymax/types.py +0 -1220
- pymax/utils.py +0 -90
- {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
pymax/session/models.py
ADDED
|
@@ -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,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
|