toDus-API 1.3.3__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.
- todus/__init__.py +52 -0
- todus/client/__init__.py +277 -0
- todus/client/auth.py +89 -0
- todus/client/base.py +183 -0
- todus/client/file.py +190 -0
- todus/client/message.py +202 -0
- todus/client/profile.py +56 -0
- todus/constants.py +10 -0
- todus/errors.py +41 -0
- todus/group.py +370 -0
- todus/parser.py +415 -0
- todus/stanza.py +54 -0
- todus/stanzas/__init__.py +1 -0
- todus/stanzas/group.py +175 -0
- todus/stanzas/presence.py +37 -0
- todus/stanzas/private.py +203 -0
- todus/stanzas/utils.py +122 -0
- todus/types.py +54 -0
- todus/util.py +177 -0
- todus_api-1.3.3.dist-info/METADATA +299 -0
- todus_api-1.3.3.dist-info/RECORD +24 -0
- todus_api-1.3.3.dist-info/WHEEL +5 -0
- todus_api-1.3.3.dist-info/licenses/LICENSE +21 -0
- todus_api-1.3.3.dist-info/top_level.txt +1 -0
todus/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""ToDus Python Library - Cliente XMPP/HTTP para ToDus."""
|
|
2
|
+
|
|
3
|
+
from .client import ToDusClient, ToDusClient2
|
|
4
|
+
from .group import GroupClient, GroupRole, GroupEvent
|
|
5
|
+
from .types import FileType, ChatState, MessageType, PresenceShow, ButtonSize, ButtonCommand
|
|
6
|
+
from .errors import (
|
|
7
|
+
ToDusError,
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
TokenExpiredError,
|
|
10
|
+
ConnectionLostError,
|
|
11
|
+
MessageError,
|
|
12
|
+
UploadError,
|
|
13
|
+
ParseError,
|
|
14
|
+
RateLimitError,
|
|
15
|
+
StanzaError,
|
|
16
|
+
GroupError,
|
|
17
|
+
)
|
|
18
|
+
from .util import normalize_phone, build_jid, generate_token, jwt_decode_payload, timestamp_ms, format_size
|
|
19
|
+
from .parser import IncrementalParser, parse_tdack
|
|
20
|
+
|
|
21
|
+
__version__ = "1.3.3"
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ToDusClient",
|
|
24
|
+
"ToDusClient2",
|
|
25
|
+
"GroupClient",
|
|
26
|
+
"GroupRole",
|
|
27
|
+
"GroupEvent",
|
|
28
|
+
"FileType",
|
|
29
|
+
"ChatState",
|
|
30
|
+
"MessageType",
|
|
31
|
+
"PresenceShow",
|
|
32
|
+
"ButtonSize",
|
|
33
|
+
"ButtonCommand",
|
|
34
|
+
"ToDusError",
|
|
35
|
+
"AuthenticationError",
|
|
36
|
+
"TokenExpiredError",
|
|
37
|
+
"ConnectionLostError",
|
|
38
|
+
"MessageError",
|
|
39
|
+
"UploadError",
|
|
40
|
+
"ParseError",
|
|
41
|
+
"RateLimitError",
|
|
42
|
+
"StanzaError",
|
|
43
|
+
"GroupError",
|
|
44
|
+
"normalize_phone",
|
|
45
|
+
"build_jid",
|
|
46
|
+
"generate_token",
|
|
47
|
+
"jwt_decode_payload",
|
|
48
|
+
"timestamp_ms",
|
|
49
|
+
"format_size",
|
|
50
|
+
"IncrementalParser",
|
|
51
|
+
"parse_tdack",
|
|
52
|
+
]
|
todus/client/__init__.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Cliente XMPP/HTTP para ToDus, unificado mediante Mixins."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import socket
|
|
5
|
+
import time
|
|
6
|
+
from base64 import b64encode
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
from .base import ToDusClientBase
|
|
10
|
+
from .auth import ToDusAuthMixin
|
|
11
|
+
from .message import ToDusMessageMixin
|
|
12
|
+
from .file import ToDusFileMixin
|
|
13
|
+
from .profile import ToDusProfileMixin
|
|
14
|
+
from ..errors import AuthenticationError, TokenExpiredError, ConnectionLostError
|
|
15
|
+
from ..types import FileType
|
|
16
|
+
from .. import util
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("todus")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ToDusClient(
|
|
22
|
+
ToDusAuthMixin,
|
|
23
|
+
ToDusMessageMixin,
|
|
24
|
+
ToDusFileMixin,
|
|
25
|
+
ToDusProfileMixin,
|
|
26
|
+
ToDusClientBase,
|
|
27
|
+
):
|
|
28
|
+
"""Cliente stateless para la API de ToDus unificado."""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ToDusClient2(ToDusClient):
|
|
33
|
+
"""Cliente stateful con auto-login, auto-reconnect, soporte para grupos y auto-detección de destino."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, phone_number: str, password: str = "", proxy: str | None = None, **kwargs) -> None:
|
|
36
|
+
super().__init__(proxy=proxy, **kwargs)
|
|
37
|
+
self.phone_number = phone_number
|
|
38
|
+
self.password = password
|
|
39
|
+
self._token = ""
|
|
40
|
+
self._group_client = None
|
|
41
|
+
|
|
42
|
+
def _authstr_from_token(self, token: str) -> tuple[str, bytes]:
|
|
43
|
+
phone, authstr = super()._authstr_from_token(token)
|
|
44
|
+
if not phone and self.phone_number:
|
|
45
|
+
phone = util.normalize_phone(self.phone_number)
|
|
46
|
+
authstr = b64encode((chr(0) + phone + chr(0) + token).encode("utf-8"))
|
|
47
|
+
return phone, authstr
|
|
48
|
+
|
|
49
|
+
def _is_group_target(self, target: str) -> bool:
|
|
50
|
+
"""Detecta si el target es un group_id en lugar de un teléfono."""
|
|
51
|
+
if not target:
|
|
52
|
+
return False
|
|
53
|
+
# Los teléfonos cubanos en ToDus son 10 dígitos empezando por 53
|
|
54
|
+
return not (target.isdigit() and len(target) == 10 and target.startswith("53"))
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def token(self) -> str:
|
|
58
|
+
return self._token
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def registered(self) -> bool:
|
|
62
|
+
return bool(self.phone_number and self.password)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def logged(self) -> bool:
|
|
66
|
+
return bool(self._token)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def groups(self):
|
|
70
|
+
"""Acceso al cliente de grupos MUC Light."""
|
|
71
|
+
if self._group_client is None:
|
|
72
|
+
from ..group import GroupClient
|
|
73
|
+
self._group_client = GroupClient(self)
|
|
74
|
+
return self._group_client
|
|
75
|
+
|
|
76
|
+
def login(self) -> None:
|
|
77
|
+
if not self.password:
|
|
78
|
+
raise AuthenticationError("No hay password")
|
|
79
|
+
self._token = super().login(self.phone_number, self.password)
|
|
80
|
+
|
|
81
|
+
def request_code(self) -> None:
|
|
82
|
+
super().request_code(self.phone_number)
|
|
83
|
+
|
|
84
|
+
def validate_code(self, code: str) -> None:
|
|
85
|
+
self.password = super().validate_code(self.phone_number, code)
|
|
86
|
+
|
|
87
|
+
# --- Mensajería Privada / Grupo (auto-detección) ---
|
|
88
|
+
|
|
89
|
+
def send_message(self, to_phone: str, body: str) -> str:
|
|
90
|
+
if not self._token:
|
|
91
|
+
raise AuthenticationError("No autenticado")
|
|
92
|
+
if self._is_group_target(to_phone):
|
|
93
|
+
return self.groups.send_message(to_phone, body)
|
|
94
|
+
to_jid = util.build_jid(to_phone)
|
|
95
|
+
return super().send_message(self._token, to_jid, body)
|
|
96
|
+
|
|
97
|
+
def edit_message(self, to_phone: str, new_body: str, original_msg_id: str) -> str:
|
|
98
|
+
if not self._token:
|
|
99
|
+
raise AuthenticationError("No autenticado")
|
|
100
|
+
if self._is_group_target(to_phone):
|
|
101
|
+
return self.groups.edit_message(to_phone, new_body, original_msg_id)
|
|
102
|
+
to_jid = util.build_jid(to_phone)
|
|
103
|
+
return super().edit_message(self._token, to_jid, new_body, original_msg_id)
|
|
104
|
+
|
|
105
|
+
def send_file_message(self, to_phone: str, url: str, file_type: FileType, caption: str = "", file_name: str = "", file_size: int = 0) -> str:
|
|
106
|
+
if not self._token:
|
|
107
|
+
raise AuthenticationError("No autenticado")
|
|
108
|
+
if self._is_group_target(to_phone):
|
|
109
|
+
return self.groups.send_file(to_phone, url, file_name, file_size, caption)
|
|
110
|
+
to_jid = util.build_jid(to_phone)
|
|
111
|
+
return super().send_file_message(self._token, to_jid, url, file_type, caption, file_name=file_name, file_size=file_size)
|
|
112
|
+
|
|
113
|
+
def send_image_message(self, to_phone: str, url: str, file_name: str, file_size: int, width: int = 0, height: int = 0, thumbnail: str = "", caption: str = "") -> str:
|
|
114
|
+
if not self._token:
|
|
115
|
+
raise AuthenticationError("No autenticado")
|
|
116
|
+
if self._is_group_target(to_phone):
|
|
117
|
+
return self.groups.send_image(to_phone, url, file_name, file_size, width, height, thumbnail, caption)
|
|
118
|
+
to_jid = util.build_jid(to_phone)
|
|
119
|
+
return super().send_image_message(self._token, to_jid, url, file_name, file_size, width, height, thumbnail, caption)
|
|
120
|
+
|
|
121
|
+
def send_image_message_simple(self, to_phone: str, url: str, file_name: str, file_size: int) -> str:
|
|
122
|
+
if not self._token:
|
|
123
|
+
raise AuthenticationError("No autenticado")
|
|
124
|
+
if self._is_group_target(to_phone):
|
|
125
|
+
return self.groups.send_image(to_phone, url, file_name, file_size, 0, 0, "", "")
|
|
126
|
+
to_jid = util.build_jid(to_phone)
|
|
127
|
+
return super().send_image_message_simple(self._token, to_jid, url, file_name, file_size)
|
|
128
|
+
|
|
129
|
+
def send_button_message(self, to_phone: str, text: str, buttons: list[dict]) -> str:
|
|
130
|
+
if not self._token:
|
|
131
|
+
raise AuthenticationError("No autenticado")
|
|
132
|
+
if self._is_group_target(to_phone):
|
|
133
|
+
return self.groups.send_message(to_phone, text)
|
|
134
|
+
to_jid = util.build_jid(to_phone)
|
|
135
|
+
return super().send_button_message(self._token, to_jid, text, buttons)
|
|
136
|
+
|
|
137
|
+
def send_contact_message(self, to_phone: str, contact_id: str, contact_name: str, contact_phone: str) -> str:
|
|
138
|
+
if not self._token:
|
|
139
|
+
raise AuthenticationError("No autenticado")
|
|
140
|
+
if self._is_group_target(to_phone):
|
|
141
|
+
return self.groups.send_contact(to_phone, contact_id, contact_name, contact_phone)
|
|
142
|
+
to_jid = util.build_jid(to_phone)
|
|
143
|
+
return super().send_contact_message(self._token, to_jid, contact_id, contact_name, contact_phone)
|
|
144
|
+
|
|
145
|
+
def send_sticker_message(self, to_phone: str, sticker_id: str, sticker_name: str, sticker_pack: str, sticker_hash: str) -> str:
|
|
146
|
+
if not self._token:
|
|
147
|
+
raise AuthenticationError("No autenticado")
|
|
148
|
+
if self._is_group_target(to_phone):
|
|
149
|
+
return self.groups.send_sticker(to_phone, sticker_id, sticker_name, sticker_pack, sticker_hash)
|
|
150
|
+
to_jid = util.build_jid(to_phone)
|
|
151
|
+
return super().send_sticker_message(self._token, to_jid, sticker_id, sticker_name, sticker_pack, sticker_hash)
|
|
152
|
+
|
|
153
|
+
def send_video_message(self, to_phone: str, url: str, video_id: str, file_name: str, file_size: int, duration: int, width: int, height: int, thumbnail: str, info_text: str = "") -> str:
|
|
154
|
+
if not self._token:
|
|
155
|
+
raise AuthenticationError("No autenticado")
|
|
156
|
+
if self._is_group_target(to_phone):
|
|
157
|
+
return self.groups.send_video(to_phone, url, video_id, file_name, file_size, duration, width, height, thumbnail, info_text)
|
|
158
|
+
to_jid = util.build_jid(to_phone)
|
|
159
|
+
return super().send_video_message(self._token, to_jid, url, video_id, file_name, file_size, duration, width, height, thumbnail, info_text=info_text)
|
|
160
|
+
|
|
161
|
+
def send_location_message(self, to_phone: str, lat: float, lon: float, zoom: float = 11.0, text: str = "") -> str:
|
|
162
|
+
if not self._token:
|
|
163
|
+
raise AuthenticationError("No autenticado")
|
|
164
|
+
if self._is_group_target(to_phone):
|
|
165
|
+
return self.groups.send_location(to_phone, lat, lon, zoom, text)
|
|
166
|
+
to_jid = util.build_jid(to_phone)
|
|
167
|
+
return super().send_location_message(self._token, to_jid, lat, lon, zoom, text)
|
|
168
|
+
|
|
169
|
+
def send_event_message(self, to_phone: str, title: str, start: int, end: int, all_day: bool, ics_data: str, event_id: str = "") -> str:
|
|
170
|
+
if not self._token:
|
|
171
|
+
raise AuthenticationError("No autenticado")
|
|
172
|
+
if self._is_group_target(to_phone):
|
|
173
|
+
return self.groups.send_event(to_phone, title, start, end, all_day, ics_data, event_id)
|
|
174
|
+
to_jid = util.build_jid(to_phone)
|
|
175
|
+
return super().send_event_message(self._token, to_jid, title, start, end, all_day, ics_data, event_id)
|
|
176
|
+
|
|
177
|
+
def send_chat_state(self, to_phone: str, state: str) -> None:
|
|
178
|
+
if not self._token:
|
|
179
|
+
raise AuthenticationError("No autenticado")
|
|
180
|
+
if self._is_group_target(to_phone):
|
|
181
|
+
return
|
|
182
|
+
super().send_chat_state(self._token, util.build_jid(to_phone), state)
|
|
183
|
+
|
|
184
|
+
def delete_message(self, to_phone: str, message_id: str, body: str = "", media_xml: str = "") -> str:
|
|
185
|
+
if not self._token:
|
|
186
|
+
raise AuthenticationError("No autenticado")
|
|
187
|
+
if self._is_group_target(to_phone):
|
|
188
|
+
return self.groups.delete_message(to_phone, message_id, body=body, media_xml=media_xml)
|
|
189
|
+
to_jid = util.build_jid(to_phone)
|
|
190
|
+
return super().delete_message(self._token, to_jid, message_id, body=body, media_xml=media_xml)
|
|
191
|
+
|
|
192
|
+
def send_read_receipt(self, to_phone: str, msg_id: str) -> str:
|
|
193
|
+
if not self._token:
|
|
194
|
+
raise AuthenticationError("No autenticado")
|
|
195
|
+
if self._is_group_target(to_phone):
|
|
196
|
+
return ""
|
|
197
|
+
to_jid = util.build_jid(to_phone)
|
|
198
|
+
return super().send_read_receipt(self._token, to_jid, msg_id)
|
|
199
|
+
|
|
200
|
+
# --- Recepción de mensajes (con soporte para grupos) ---
|
|
201
|
+
|
|
202
|
+
def listen_messages(self, callback: Callable[[dict], None]) -> None:
|
|
203
|
+
if not self._token:
|
|
204
|
+
raise AuthenticationError("No autenticado")
|
|
205
|
+
|
|
206
|
+
def group_aware_callback(msg: dict):
|
|
207
|
+
# Procesar mensajes de grupo
|
|
208
|
+
if msg.get("type") == "gc":
|
|
209
|
+
msg = self.groups.process_group_message(msg)
|
|
210
|
+
|
|
211
|
+
# Notificar callbacks específicos del grupo
|
|
212
|
+
group_id = msg.get("group_id")
|
|
213
|
+
if group_id and self._group_client:
|
|
214
|
+
if group_id in self._group_client._group_callbacks:
|
|
215
|
+
for cb in self._group_client._group_callbacks[group_id]:
|
|
216
|
+
try:
|
|
217
|
+
cb(msg.copy())
|
|
218
|
+
except Exception as e:
|
|
219
|
+
logger.error(f"Error en callback de grupo: {e}")
|
|
220
|
+
|
|
221
|
+
callback(msg)
|
|
222
|
+
|
|
223
|
+
while True:
|
|
224
|
+
try:
|
|
225
|
+
super().listen_messages(self._token, group_aware_callback)
|
|
226
|
+
except TokenExpiredError:
|
|
227
|
+
self.login()
|
|
228
|
+
except (ConnectionLostError, OSError, socket.error):
|
|
229
|
+
time.sleep(15)
|
|
230
|
+
|
|
231
|
+
# --- Archivos ---
|
|
232
|
+
|
|
233
|
+
def reserve_upload_url(self, size: int, file_type: FileType, file_name: str = "") -> tuple[str, str]:
|
|
234
|
+
if not self._token:
|
|
235
|
+
raise AuthenticationError("No autenticado")
|
|
236
|
+
return super().reserve_upload_url(self._token, size, file_type, file_name=file_name)
|
|
237
|
+
|
|
238
|
+
def get_real_download_url(self, url: str) -> str:
|
|
239
|
+
if not self._token:
|
|
240
|
+
raise AuthenticationError("No autenticado")
|
|
241
|
+
return super().get_real_download_url(self._token, url)
|
|
242
|
+
|
|
243
|
+
def upload_file(self, data: bytes, file_type: FileType = FileType.FILE, progress_callback: Callable[[int, int], None] = None, file_name: str = "") -> str:
|
|
244
|
+
if not self._token:
|
|
245
|
+
raise AuthenticationError("No autenticado")
|
|
246
|
+
return super().upload_file(self._token, data, file_type, progress_callback, file_name=file_name)
|
|
247
|
+
|
|
248
|
+
def download_file(self, url: str, path: str) -> int:
|
|
249
|
+
if not self._token:
|
|
250
|
+
raise AuthenticationError("No autenticado")
|
|
251
|
+
return super().download_file(self._token, url, path)
|
|
252
|
+
|
|
253
|
+
def download_file_to_folder(self, url: str, folder: str, filename: str = "") -> tuple[int, str]:
|
|
254
|
+
if not self._token:
|
|
255
|
+
raise AuthenticationError("No autenticado")
|
|
256
|
+
return super().download_file_to_folder(self._token, url, folder, filename)
|
|
257
|
+
|
|
258
|
+
# --- Perfil ---
|
|
259
|
+
|
|
260
|
+
def update_profile(self, alias: str = "", bio: str = "", picture_url: str = "", thumbnail_url: str = "") -> bool:
|
|
261
|
+
if not self._token:
|
|
262
|
+
raise AuthenticationError("No autenticado")
|
|
263
|
+
return super().update_profile(self._token, alias, bio, picture_url, thumbnail_url)
|
|
264
|
+
|
|
265
|
+
def upload_avatar(self, image_data: bytes, thumbnail_data: bytes = None) -> tuple[str, str]:
|
|
266
|
+
if not self._token:
|
|
267
|
+
raise AuthenticationError("No autenticado")
|
|
268
|
+
return super().upload_avatar(self._token, image_data, thumbnail_data)
|
|
269
|
+
|
|
270
|
+
def upload_avatar_from_file(self, filepath: str, thumbnail_path: str = None) -> tuple[str, str]:
|
|
271
|
+
with open(filepath, "rb") as f:
|
|
272
|
+
image_data = f.read()
|
|
273
|
+
thumbnail_data = None
|
|
274
|
+
if thumbnail_path:
|
|
275
|
+
with open(thumbnail_path, "rb") as f:
|
|
276
|
+
thumbnail_data = f.read()
|
|
277
|
+
return self.upload_avatar(image_data, thumbnail_data)
|
todus/client/auth.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import string
|
|
2
|
+
import re
|
|
3
|
+
from ..errors import AuthenticationError
|
|
4
|
+
from .. import util
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ToDusAuthMixin:
|
|
8
|
+
"""Mixin que contiene los métodos de autenticación HTTP de ToDus."""
|
|
9
|
+
|
|
10
|
+
def request_code(self, phone_number: str) -> None:
|
|
11
|
+
headers = {
|
|
12
|
+
"Host": "auth.todus.cu",
|
|
13
|
+
"User-Agent": "ToDus " + self.version_name + " Auth",
|
|
14
|
+
"Content-Type": "application/x-protobuf",
|
|
15
|
+
}
|
|
16
|
+
data = (
|
|
17
|
+
bytes([0x0A, 0x0A])
|
|
18
|
+
+ phone_number.encode()
|
|
19
|
+
+ bytes([0x12, 0x96, 0x01])
|
|
20
|
+
+ util.generate_token(150).encode()
|
|
21
|
+
)
|
|
22
|
+
resp = self.session.post(
|
|
23
|
+
"https://auth.todus.cu/v2/auth/users.reserve",
|
|
24
|
+
data=data,
|
|
25
|
+
headers=headers,
|
|
26
|
+
timeout=30,
|
|
27
|
+
)
|
|
28
|
+
resp.raise_for_status()
|
|
29
|
+
|
|
30
|
+
def validate_code(self, phone_number: str, code: str) -> str:
|
|
31
|
+
headers = {
|
|
32
|
+
"Host": "auth.todus.cu",
|
|
33
|
+
"User-Agent": "ToDus " + self.version_name + " Auth",
|
|
34
|
+
"Content-Type": "application/x-protobuf",
|
|
35
|
+
}
|
|
36
|
+
data = (
|
|
37
|
+
bytes([0x0A, 0x0A])
|
|
38
|
+
+ phone_number.encode()
|
|
39
|
+
+ bytes([0x12, 0x96, 0x01])
|
|
40
|
+
+ util.generate_token(150).encode()
|
|
41
|
+
+ bytes([0x1A, 0x06])
|
|
42
|
+
+ code.encode()
|
|
43
|
+
)
|
|
44
|
+
resp = self.session.post(
|
|
45
|
+
"https://auth.todus.cu/v2/auth/users.register",
|
|
46
|
+
data=data,
|
|
47
|
+
headers=headers,
|
|
48
|
+
timeout=30,
|
|
49
|
+
)
|
|
50
|
+
resp.raise_for_status()
|
|
51
|
+
content = resp.content
|
|
52
|
+
try:
|
|
53
|
+
if b"`" in content:
|
|
54
|
+
idx = content.index(b"`") + 1
|
|
55
|
+
return content[idx : idx + 96].decode("utf-8")
|
|
56
|
+
return content[5:166].decode("utf-8")
|
|
57
|
+
except UnicodeDecodeError:
|
|
58
|
+
raw = content.decode("latin-1", errors="ignore")
|
|
59
|
+
match = re.search(r"[a-f0-9]{96}", raw)
|
|
60
|
+
if match:
|
|
61
|
+
return match.group(0)
|
|
62
|
+
return "".join(c for c in raw if c in string.printable and c not in "\r\n")[:96]
|
|
63
|
+
|
|
64
|
+
def login(self, phone_number: str, password: str) -> str:
|
|
65
|
+
headers = {
|
|
66
|
+
"Host": "auth.todus.cu",
|
|
67
|
+
"user-agent": "ToDus " + self.version_name + " Auth",
|
|
68
|
+
"content-type": "application/x-protobuf",
|
|
69
|
+
}
|
|
70
|
+
data = (
|
|
71
|
+
bytes([0x0A, 0x0A])
|
|
72
|
+
+ phone_number.encode()
|
|
73
|
+
+ bytes([0x12, 0x96, 0x01])
|
|
74
|
+
+ util.generate_token(150).encode()
|
|
75
|
+
+ bytes([0x12, 0x60])
|
|
76
|
+
+ password.encode()
|
|
77
|
+
+ bytes([0x1A, 0x05])
|
|
78
|
+
+ self.version_code.encode()
|
|
79
|
+
)
|
|
80
|
+
resp = self.session.post(
|
|
81
|
+
"https://auth.todus.cu/v2/auth/token",
|
|
82
|
+
data=data,
|
|
83
|
+
headers=headers,
|
|
84
|
+
timeout=30,
|
|
85
|
+
)
|
|
86
|
+
if resp.status_code == 403:
|
|
87
|
+
raise AuthenticationError("Credenciales invalidas")
|
|
88
|
+
resp.raise_for_status()
|
|
89
|
+
return "".join([c for c in resp.text if c in string.printable])
|
todus/client/base.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
import socket
|
|
4
|
+
import ssl
|
|
5
|
+
import string
|
|
6
|
+
from base64 import b64encode
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
import requests
|
|
9
|
+
from .. import constants, parser, stanza, util
|
|
10
|
+
from ..errors import ConnectionLostError, TokenExpiredError
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("todus")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ToDusClientBase:
|
|
16
|
+
"""Clase base para el cliente ToDus que maneja el socket XMPP y HTTP."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
version_name: str = constants.AUTH_VERSION_NAME,
|
|
21
|
+
version_code: str = constants.AUTH_VERSION_CODE,
|
|
22
|
+
proxy: str | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
self.version_name = version_name
|
|
25
|
+
self.version_code = version_code
|
|
26
|
+
self.proxy = proxy
|
|
27
|
+
self.session = requests.Session()
|
|
28
|
+
self.session.headers.update({"Accept-Encoding": "gzip"})
|
|
29
|
+
if self.proxy:
|
|
30
|
+
self.session.proxies = {
|
|
31
|
+
"http": self.proxy,
|
|
32
|
+
"https": self.proxy,
|
|
33
|
+
}
|
|
34
|
+
self._xml_parser = parser.IncrementalParser()
|
|
35
|
+
|
|
36
|
+
def _parse_proxy(self, proxy_url: str):
|
|
37
|
+
from urllib.parse import urlparse
|
|
38
|
+
import socks
|
|
39
|
+
|
|
40
|
+
parsed = urlparse(proxy_url)
|
|
41
|
+
scheme = parsed.scheme.lower()
|
|
42
|
+
|
|
43
|
+
if "socks5" in scheme:
|
|
44
|
+
proxy_type = socks.SOCKS5
|
|
45
|
+
elif "socks4" in scheme:
|
|
46
|
+
proxy_type = socks.SOCKS4
|
|
47
|
+
elif "http" in scheme:
|
|
48
|
+
proxy_type = socks.HTTP
|
|
49
|
+
else:
|
|
50
|
+
raise ValueError(f"Tipo de proxy no soportado: {scheme}")
|
|
51
|
+
|
|
52
|
+
port = parsed.port
|
|
53
|
+
if port is None:
|
|
54
|
+
if proxy_type == socks.HTTP:
|
|
55
|
+
port = 8080
|
|
56
|
+
else:
|
|
57
|
+
port = 1080
|
|
58
|
+
|
|
59
|
+
return proxy_type, parsed.hostname, port, parsed.username, parsed.password
|
|
60
|
+
|
|
61
|
+
# --- XMPP Socket ---
|
|
62
|
+
|
|
63
|
+
def _connect_xmpp(self) -> ssl.SSLSocket:
|
|
64
|
+
if self.proxy:
|
|
65
|
+
import socks
|
|
66
|
+
proxy_type, host, port, username, password = self._parse_proxy(self.proxy)
|
|
67
|
+
raw_sock = socks.socksocket(socket.AF_INET)
|
|
68
|
+
raw_sock.set_proxy(proxy_type, host, port, username=username, password=password)
|
|
69
|
+
else:
|
|
70
|
+
raw_sock = socket.socket(socket.AF_INET)
|
|
71
|
+
|
|
72
|
+
raw_sock.settimeout(constants.DEFAULT_TIMEOUT)
|
|
73
|
+
raw_sock.connect((constants.XMPP_HOST, constants.XMPP_PORT))
|
|
74
|
+
|
|
75
|
+
ctx = ssl.create_default_context()
|
|
76
|
+
ctx.check_hostname = False
|
|
77
|
+
sock = ctx.wrap_socket(raw_sock, server_hostname=constants.XMPP_HOST)
|
|
78
|
+
sock.send(stanza.stream_open().encode())
|
|
79
|
+
return sock
|
|
80
|
+
|
|
81
|
+
def _recv_all(self, sock: ssl.SSLSocket) -> str | None:
|
|
82
|
+
data = b""
|
|
83
|
+
while True:
|
|
84
|
+
try:
|
|
85
|
+
chunk = sock.recv(constants.BUFFER_SIZE)
|
|
86
|
+
if not chunk:
|
|
87
|
+
return None
|
|
88
|
+
data += chunk
|
|
89
|
+
if len(chunk) < constants.BUFFER_SIZE:
|
|
90
|
+
break
|
|
91
|
+
except socket.timeout:
|
|
92
|
+
break
|
|
93
|
+
except OSError:
|
|
94
|
+
return None
|
|
95
|
+
return data.decode("utf-8", errors="replace")
|
|
96
|
+
|
|
97
|
+
def _authstr_from_token(self, token: str) -> tuple[str, bytes]:
|
|
98
|
+
payload = util.jwt_decode_payload(token)
|
|
99
|
+
phone = payload.get("username", "")
|
|
100
|
+
if not phone:
|
|
101
|
+
match = re.search(r"(53\d{8})", token)
|
|
102
|
+
if match:
|
|
103
|
+
phone = match.group(1)
|
|
104
|
+
authstr = b64encode((chr(0) + phone + chr(0) + token).encode("utf-8"))
|
|
105
|
+
return phone, authstr
|
|
106
|
+
|
|
107
|
+
def _process_handshake(self, response: str, sock, authstr: bytes, sid: str, state: dict) -> bool:
|
|
108
|
+
phase = state.get("phase", "init")
|
|
109
|
+
|
|
110
|
+
if phase == "init":
|
|
111
|
+
if "<stream:features><es xmlns='x2'>" in response:
|
|
112
|
+
sock.send(stanza.sasl_auth(authstr))
|
|
113
|
+
state["phase"] = "auth_sent"
|
|
114
|
+
return True
|
|
115
|
+
if response.startswith("<?xml version='1.0'?><stream:stream"):
|
|
116
|
+
if "<stream:features>" in response:
|
|
117
|
+
sock.send(stanza.sasl_auth(authstr))
|
|
118
|
+
state["phase"] = "auth_sent"
|
|
119
|
+
return True
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
if phase == "auth_sent":
|
|
123
|
+
if "<ok xmlns='x2'/>" in response:
|
|
124
|
+
sock.send(stanza.stream_restart().encode())
|
|
125
|
+
state["phase"] = "restream"
|
|
126
|
+
return True
|
|
127
|
+
if "<not-authorized/>" in response:
|
|
128
|
+
raise TokenExpiredError()
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
if phase == "restream":
|
|
132
|
+
if "<stream:features><b1 xmlns='x4'/>" in response:
|
|
133
|
+
sock.send(stanza.bind(sid + "-1").encode())
|
|
134
|
+
state["phase"] = "bind_sent"
|
|
135
|
+
return True
|
|
136
|
+
if response.startswith("<?xml version='1.0'?><stream:stream") and "<stream:features><b1 xmlns='x4'/>" in response:
|
|
137
|
+
sock.send(stanza.bind(sid + "-1").encode())
|
|
138
|
+
state["phase"] = "bind_sent"
|
|
139
|
+
return True
|
|
140
|
+
return True
|
|
141
|
+
|
|
142
|
+
if phase == "bind_sent":
|
|
143
|
+
if "t='result' i='" + sid + "-1'>" in response:
|
|
144
|
+
return False
|
|
145
|
+
if "<not-authorized/>" in response:
|
|
146
|
+
raise TokenExpiredError()
|
|
147
|
+
return True
|
|
148
|
+
|
|
149
|
+
return True
|
|
150
|
+
|
|
151
|
+
def _handshake(self, sock: ssl.SSLSocket, token: str) -> None:
|
|
152
|
+
_, authstr = self._authstr_from_token(token)
|
|
153
|
+
sid = util.generate_token(5)
|
|
154
|
+
state = {"phase": "init"}
|
|
155
|
+
|
|
156
|
+
while True:
|
|
157
|
+
response = self._recv_all(sock)
|
|
158
|
+
if response is None:
|
|
159
|
+
raise ConnectionLostError("Servidor cerro conexion durante handshake")
|
|
160
|
+
if response == "":
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
if not self._process_handshake(response, sock, authstr, sid, state):
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
# --- Context Manager XMPP ---
|
|
167
|
+
|
|
168
|
+
@contextmanager
|
|
169
|
+
def _xmpp_session(self, token: str):
|
|
170
|
+
sock = self._connect_xmpp()
|
|
171
|
+
try:
|
|
172
|
+
self._handshake(sock, token)
|
|
173
|
+
sock.send(stanza.presence().encode())
|
|
174
|
+
yield sock
|
|
175
|
+
finally:
|
|
176
|
+
try:
|
|
177
|
+
sock.send(stanza.stream_close().encode())
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
try:
|
|
181
|
+
sock.close()
|
|
182
|
+
except Exception:
|
|
183
|
+
pass
|