todus-lib 1.3.2__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 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.2"
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
+ ]
@@ -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