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/client/file.py ADDED
@@ -0,0 +1,190 @@
1
+ import os
2
+ import re
3
+ import time
4
+ import logging
5
+ from typing import Callable
6
+ import requests
7
+ from .. import util, stanza
8
+ from ..types import FileType
9
+ from ..errors import ConnectionLostError, TokenExpiredError, UploadError
10
+
11
+ logger = logging.getLogger("todus")
12
+
13
+
14
+ class _ProgressReader:
15
+ def __init__(self, data: bytes, progress_callback: Callable[[int, int], None]) -> None:
16
+ self.data = data
17
+ self.total = len(data)
18
+ self.offset = 0
19
+ self.progress_callback = progress_callback
20
+
21
+ def read(self, size: int = -1) -> bytes:
22
+ if self.offset >= self.total:
23
+ return b""
24
+
25
+ if size is None or size < 0:
26
+ chunk = self.data[self.offset:]
27
+ self.offset = self.total
28
+ else:
29
+ end = min(self.offset + size, self.total)
30
+ chunk = self.data[self.offset:end]
31
+ self.offset = end
32
+
33
+ if chunk and self.progress_callback:
34
+ self.progress_callback(self.offset, self.total)
35
+
36
+ return chunk
37
+
38
+
39
+ class ToDusFileMixin:
40
+ """Mixin que contiene los métodos de subida y descarga de archivos de ToDus."""
41
+
42
+ # --- Archivos ---
43
+
44
+ def reserve_upload_url(self, token: str, size: int, file_type: FileType, file_name: str = "") -> tuple[str, str]:
45
+ phone, authstr = self._authstr_from_token(token)
46
+ sid = util.generate_token(5)
47
+ up_url = down_url = ""
48
+
49
+ # Sanitizar nombre del archivo
50
+ sanitized_name = util.sanitize_filename(file_name, int(file_type))
51
+
52
+ with self._xmpp_session(token) as sock:
53
+ sock.send(stanza.upload_query(sid, size, int(file_type), file_name=sanitized_name).encode())
54
+ while True:
55
+ response = self._recv_all(sock)
56
+ if response is None:
57
+ raise ConnectionLostError()
58
+ if response == "":
59
+ continue
60
+ if "i='" + sid + "-3'" in response and "put='" in response:
61
+ put_match = re.search(r"put=['\"]([^'\"]+)['\"]", response)
62
+ get_match = re.search(r"get=['\"]([^'\"]+)['\"]", response)
63
+ if put_match and get_match:
64
+ up_url = put_match.group(1).replace("amp;", "")
65
+ down_url = get_match.group(1)
66
+ break
67
+ if "<not-authorized/>" in response:
68
+ raise TokenExpiredError()
69
+
70
+ return up_url, down_url
71
+
72
+ def get_real_download_url(self, token: str, url: str) -> str:
73
+ _, authstr = self._authstr_from_token(token)
74
+ sid = util.generate_token(5)
75
+
76
+ with self._xmpp_session(token) as sock:
77
+ sock.send(stanza.download_query(sid, url).encode())
78
+ while True:
79
+ response = self._recv_all(sock)
80
+ if response is None:
81
+ raise ConnectionLostError()
82
+ if response == "":
83
+ continue
84
+ if "i='" + sid + "-2'" in response and "du='" in response:
85
+ match = re.match(".*du='(.*)' stat.*", response)
86
+ if match:
87
+ return match.group(1).replace("amp;", "")
88
+ break
89
+ if "<not-authorized/>" in response:
90
+ raise TokenExpiredError()
91
+
92
+ return ""
93
+
94
+ def upload_file(self, token: str, data: bytes, file_type: FileType = FileType.FILE, progress_callback: Callable[[int, int], None] = None, file_name: str = "") -> str:
95
+ up_url, down_url = self.reserve_upload_url(token, len(data), file_type, file_name=file_name)
96
+ upload_data = _ProgressReader(data, progress_callback) if progress_callback else data
97
+ resp = requests.put(
98
+ up_url,
99
+ data=upload_data,
100
+ headers={"Content-Length": str(len(data))},
101
+ timeout=60,
102
+ )
103
+ resp.raise_for_status()
104
+ if progress_callback:
105
+ progress_callback(len(data), len(data))
106
+ return down_url
107
+
108
+ def download_file(self, token: str, url: str, path: str) -> int:
109
+ real_url = self.get_real_download_url(token, url)
110
+ headers = {
111
+ "User-Agent": "ToDus " + self.version_name + " HTTP-Download",
112
+ "Authorization": "Bearer " + token,
113
+ }
114
+ temp_path = path + ".part"
115
+ size = -1
116
+ with open(temp_path, "ab") as f:
117
+ pos = f.tell()
118
+ while pos < size or size == -1:
119
+ if pos:
120
+ headers["Range"] = "bytes=" + str(pos) + "-"
121
+ try:
122
+ with self.session.get(real_url, headers=headers, stream=True, timeout=60) as resp:
123
+ resp.raise_for_status()
124
+ size = pos + int(resp.headers.get("Content-Length", 0))
125
+ for chunk in resp.iter_content(chunk_size=8192):
126
+ f.write(chunk)
127
+ except Exception:
128
+ time.sleep(5)
129
+ pos = f.tell()
130
+ os.rename(temp_path, path)
131
+ return size
132
+
133
+ def download_file_to_folder(self, token: str, url: str, folder: str, filename: str = "") -> tuple[int, str]:
134
+ headers = {
135
+ "User-Agent": "ToDus " + self.version_name + " HTTP-Download",
136
+ "Authorization": "Bearer " + token,
137
+ }
138
+
139
+ os.makedirs(folder, exist_ok=True)
140
+
141
+ if not filename:
142
+ filename = os.path.basename(url.split("?")[0]) or "download"
143
+
144
+ final_path = os.path.join(folder, filename)
145
+ temp_path = final_path + ".part"
146
+
147
+ if os.path.exists(temp_path):
148
+ os.remove(temp_path)
149
+
150
+ try:
151
+ test_resp = self.session.head(url, headers=headers, timeout=15, allow_redirects=True)
152
+ if test_resp.status_code in (200, 206, 401, 403, 302, 301):
153
+ real_url = url
154
+ else:
155
+ real_url = self.get_real_download_url(token, url)
156
+ if not real_url:
157
+ raise UploadError("No se pudo resolver URL de descarga")
158
+ except Exception:
159
+ real_url = self.get_real_download_url(token, url)
160
+ if not real_url:
161
+ raise UploadError("No se pudo obtener URL de descarga")
162
+
163
+ size = -1
164
+ downloaded = 0
165
+ start_time = time.time()
166
+ last_progress = 0
167
+
168
+ with open(temp_path, "wb") as f:
169
+ with self.session.get(real_url, headers=headers, stream=True, timeout=300) as resp:
170
+ if resp.status_code not in (200, 206):
171
+ raise UploadError(f"HTTP {resp.status_code}: {resp.text[:100]}")
172
+
173
+ size = int(resp.headers.get("Content-Length", 0))
174
+
175
+ for chunk in resp.iter_content(chunk_size=8192):
176
+ if chunk:
177
+ f.write(chunk)
178
+ downloaded += len(chunk)
179
+ if downloaded - last_progress >= (500 * 1024):
180
+ elapsed = time.time() - start_time
181
+ speed = downloaded / elapsed if elapsed > 0 else 0
182
+ logger.info("Descargando %s / %s @ %s/s",
183
+ util.format_size(downloaded),
184
+ util.format_size(size),
185
+ util.format_size(int(speed)))
186
+ last_progress = downloaded
187
+
188
+ os.rename(temp_path, final_path)
189
+ logger.info("Descarga completa: %s", util.format_size(downloaded))
190
+ return downloaded, final_path
@@ -0,0 +1,202 @@
1
+ import logging
2
+ import threading
3
+ import time
4
+ import socket
5
+ import hashlib
6
+ from typing import Callable
7
+ from .. import util, stanza, constants
8
+ from ..errors import TokenExpiredError, ConnectionLostError
9
+ from ..types import FileType
10
+
11
+ logger = logging.getLogger("todus")
12
+
13
+
14
+ class ToDusMessageMixin:
15
+ """Mixin que contiene la lógica de mensajería (envío y recepción) de ToDus."""
16
+
17
+ # --- Mensajeria Privada ---
18
+
19
+ def send_message(self, token: str, to_jid: str, body: str) -> str:
20
+ """Envía mensaje de texto privado. Retorna el msg_id generado."""
21
+ mid = util.generate_token(8)
22
+ msg = stanza.message(to_jid, body, msg_id=mid)
23
+ with self._xmpp_session(token) as sock:
24
+ sock.send(msg.encode())
25
+ return mid
26
+
27
+ def edit_message(self, token: str, to_jid: str, new_body: str, original_msg_id: str) -> str:
28
+ """Edita un mensaje privado."""
29
+ edit_id = util.generate_token(8)
30
+ msg = stanza.edit_message(to_jid, new_body, original_msg_id, edit_id=edit_id)
31
+ with self._xmpp_session(token) as sock:
32
+ sock.send(msg.encode())
33
+ return edit_id
34
+
35
+ def send_file_message(self, token: str, to_jid: str, url: str, file_type: FileType, caption: str = "", file_name: str = "", file_size: int = 0) -> str:
36
+ mid = util.generate_token(8)
37
+ msg = stanza.file_message(to_jid, url, int(file_type), caption, msg_id=mid, file_name=file_name, file_size=file_size)
38
+ with self._xmpp_session(token) as sock:
39
+ sock.send(msg.encode())
40
+ return mid
41
+
42
+ def send_image_message(self, token: str, to_jid: str, url: str, file_name: str, file_size: int, width: int = 0, height: int = 0, thumbnail: str = "", caption: str = "") -> str:
43
+ """Envía mensaje privado con imagen adjunta."""
44
+ mid = util.generate_token(8)
45
+ msg = stanza.image_message(to_jid, url, file_name, file_size, width, height, thumbnail, caption, msg_id=mid)
46
+ with self._xmpp_session(token) as sock:
47
+ sock.send(msg.encode())
48
+ return mid
49
+
50
+ def send_image_message_simple(self, token: str, to_jid: str, url: str, file_name: str, file_size: int, msg_id: str = "") -> str:
51
+ """Envía mensaje privado con imagen SIN metadata."""
52
+ mid = msg_id or util.generate_token(8)
53
+ msg = stanza.image_message_simple(to_jid, url, file_name, file_size, msg_id=mid)
54
+ with self._xmpp_session(token) as sock:
55
+ sock.send(msg.encode())
56
+ return mid
57
+
58
+ def send_button_message(self, token: str, to_jid: str, text: str, buttons: list[dict]) -> str:
59
+ """Envía mensaje con botones interactivos."""
60
+ mid = util.generate_token(8)
61
+ msg = stanza.button_message(to_jid, text, buttons, msg_id=mid)
62
+ with self._xmpp_session(token) as sock:
63
+ sock.send(msg.encode())
64
+ return mid
65
+
66
+ def send_contact_message(self, token: str, to_jid: str, contact_id: str, contact_name: str, contact_phone: str) -> str:
67
+ mid = util.generate_token(8)
68
+ msg = stanza.contact_message(to_jid, contact_id, contact_name, contact_phone, msg_id=mid)
69
+ with self._xmpp_session(token) as sock:
70
+ sock.send(msg.encode())
71
+ return mid
72
+
73
+ def send_sticker_message(self, token: str, to_jid: str, sticker_id: str, sticker_name: str, sticker_pack: str, sticker_hash: str) -> str:
74
+ mid = util.generate_token(8)
75
+ msg = stanza.sticker_message(to_jid, sticker_id, sticker_name, sticker_pack, sticker_hash, msg_id=mid)
76
+ with self._xmpp_session(token) as sock:
77
+ sock.send(msg.encode())
78
+ return mid
79
+
80
+ def send_video_message(self, token: str, to_jid: str, url: str, video_id: str, file_name: str, file_size: int, duration: int, width: int, height: int, thumbnail: str, info_text: str = "") -> str:
81
+ mid = hashlib.md5(util.generate_token(16).encode()).hexdigest()
82
+ msg = stanza.video_message(to_jid, url, video_id, file_name, file_size, duration, width, height, thumbnail, msg_id=mid, info_text=info_text)
83
+ with self._xmpp_session(token) as sock:
84
+ sock.send(msg.encode())
85
+ return mid
86
+
87
+ def send_location_message(self, token: str, to_jid: str, lat: float, lon: float, zoom: float = 11.0, text: str = "") -> str:
88
+ """Envía un mensaje con ubicación adjunta."""
89
+ mid = util.generate_token(8)
90
+ msg = stanza.location_message(to_jid, lat, lon, zoom, text, msg_id=mid)
91
+ with self._xmpp_session(token) as sock:
92
+ sock.send(msg.encode())
93
+ return mid
94
+
95
+ def send_event_message(self, token: str, to_jid: str, title: str, start: int, end: int, all_day: bool, ics_data: str, event_id: str = "") -> str:
96
+ """Envía un mensaje con evento/calendario adjunto."""
97
+ mid = util.generate_token(8)
98
+ msg = stanza.event_message(to_jid, event_id, title, start, end, all_day, ics_data, msg_id=mid)
99
+ with self._xmpp_session(token) as sock:
100
+ sock.send(msg.encode())
101
+ return mid
102
+
103
+ def send_chat_state(self, token: str, to_jid: str, state: str) -> None:
104
+ st = stanza.chat_state(to_jid, state)
105
+ with self._xmpp_session(token) as sock:
106
+ sock.send(st.encode())
107
+
108
+ def delete_message(self, token: str, to_jid: str, message_id: str, msg_type: str = "c", body: str = "", media_xml: str = "") -> str:
109
+ """Elimina un mensaje propio."""
110
+ msg = stanza.delete_message(to_jid, message_id, msg_id=message_id, msg_type=msg_type, body=body, media_xml=media_xml)
111
+ with self._xmpp_session(token) as sock:
112
+ sock.send(msg.encode())
113
+ return message_id
114
+
115
+ def send_read_receipt(self, token: str, to_jid: str, msg_id: str, msg_type: str = "c") -> str:
116
+ """Envía una confirmación de lectura (read receipt)."""
117
+ rid = util.generate_token(8)
118
+ msg = stanza.read_receipt(to_jid, msg_id, receipt_id=rid, msg_type=msg_type)
119
+ with self._xmpp_session(token) as sock:
120
+ sock.send(msg.encode())
121
+ return rid
122
+
123
+ # --- Recepción de mensajes ---
124
+
125
+ def listen_messages(self, token: str, callback: Callable[[dict], None]) -> None:
126
+ while True:
127
+ try:
128
+ with self._xmpp_session(token) as sock:
129
+ self._listen_loop(sock, callback)
130
+ except TokenExpiredError:
131
+ raise
132
+ except (ConnectionLostError, OSError, socket.error):
133
+ time.sleep(15)
134
+
135
+ def _listen_loop(self, sock, callback: Callable[[dict], None]) -> None:
136
+ stop_event = threading.Event()
137
+ ping_id = util.generate_token(5)
138
+ ka = threading.Thread(
139
+ target=self._keepalive_worker,
140
+ args=(sock, stop_event, ping_id),
141
+ daemon=True,
142
+ )
143
+ ka.start()
144
+ self._xml_parser.reset()
145
+
146
+ try:
147
+ while True:
148
+ try:
149
+ response = self._recv_all(sock)
150
+ except OSError as e:
151
+ raise ConnectionLostError(e)
152
+
153
+ if response is None:
154
+ raise ConnectionLostError("Servidor cerro conexion")
155
+
156
+ if response == "":
157
+ continue
158
+
159
+ stanzas = self._xml_parser.feed(response)
160
+
161
+ for msg in stanzas:
162
+ is_content = (
163
+ msg.get("body")
164
+ or msg.get("url")
165
+ or msg.get("contact_id")
166
+ or msg.get("sticker_id")
167
+ or msg.get("video_url")
168
+ or msg.get("buttons")
169
+ or msg.get("location_id")
170
+ )
171
+
172
+ if is_content and not msg.get("deleted"):
173
+ msg_id = msg.get("id", "")
174
+ msg_from = msg.get("from", "")
175
+ if msg_id and msg_from:
176
+ try:
177
+ receipt = stanza.receipt(msg_from, msg_id)
178
+ sock.send(receipt.encode())
179
+ except Exception:
180
+ pass
181
+
182
+ if (
183
+ is_content
184
+ or msg.get("chat_state")
185
+ or msg.get("receipt")
186
+ or msg.get("deleted")
187
+ ):
188
+ callback(msg)
189
+
190
+ finally:
191
+ stop_event.set()
192
+ self._xml_parser.reset()
193
+
194
+ def _keepalive_worker(self, sock, stop: threading.Event, ping_id: str) -> None:
195
+ while not stop.is_set():
196
+ time.sleep(constants.KEEPALIVE_INTERVAL)
197
+ if stop.is_set():
198
+ break
199
+ try:
200
+ sock.send(stanza.ping(ping_id).encode())
201
+ except OSError:
202
+ break
@@ -0,0 +1,56 @@
1
+ import requests
2
+ from ..types import FileType
3
+
4
+
5
+ class ToDusProfileMixin:
6
+ """Mixin que contiene los métodos de manejo de perfil y avatar de ToDus."""
7
+
8
+ # --- Perfil ---
9
+
10
+ def update_profile(self, token: str, alias: str = "", bio: str = "", picture_url: str = "", thumbnail_url: str = "") -> bool:
11
+ headers = {
12
+ "Authorization": token,
13
+ "Content-Type": "application/json",
14
+ }
15
+ payload = {
16
+ "alias": alias,
17
+ "description": bio,
18
+ "picture_url": picture_url,
19
+ "picture_thumbnail_url": thumbnail_url,
20
+ }
21
+ try:
22
+ resp = self.session.post(
23
+ "https://auth.todus.cu/v2/todus/users.me.json",
24
+ json=payload,
25
+ headers=headers,
26
+ timeout=30,
27
+ )
28
+ return resp.status_code == 200
29
+ except Exception:
30
+ return False
31
+
32
+ def upload_avatar(self, token: str, image_data: bytes, thumbnail_data: bytes = None) -> tuple[str, str]:
33
+ if thumbnail_data is None:
34
+ thumbnail_data = image_data
35
+
36
+ up_url, down_url = self.reserve_upload_url(token, len(image_data), FileType.PROFILE)
37
+ resp = requests.put(
38
+ up_url,
39
+ data=image_data,
40
+ headers={"Content-Length": str(len(image_data)), "Content-Type": "application/octet-stream"},
41
+ timeout=60,
42
+ )
43
+ resp.raise_for_status()
44
+ profile_url = down_url
45
+
46
+ up_url, down_url = self.reserve_upload_url(token, len(thumbnail_data), FileType.PROFILE_THUMBNAIL)
47
+ resp = requests.put(
48
+ up_url,
49
+ data=thumbnail_data,
50
+ headers={"Content-Length": str(len(thumbnail_data)), "Content-Type": "application/octet-stream"},
51
+ timeout=60,
52
+ )
53
+ resp.raise_for_status()
54
+ thumbnail_url = down_url
55
+
56
+ return profile_url, thumbnail_url
todus/constants.py ADDED
@@ -0,0 +1,10 @@
1
+ """Constantes del protocolo ToDus."""
2
+
3
+ XMPP_HOST = "im.todus.cu"
4
+ XMPP_PORT = 5222
5
+ MUCLIGHT_HOST = "muclight.im.todus.cu"
6
+ AUTH_VERSION_NAME = "0.40.29"
7
+ AUTH_VERSION_CODE = "21833"
8
+ BUFFER_SIZE = 1024 * 1024
9
+ KEEPALIVE_INTERVAL = 25
10
+ DEFAULT_TIMEOUT = 15
todus/errors.py ADDED
@@ -0,0 +1,41 @@
1
+ """Excepciones de ToDus."""
2
+
3
+
4
+ class ToDusError(Exception):
5
+ """Base para todos los errores de ToDus."""
6
+
7
+
8
+ class AuthenticationError(ToDusError):
9
+ """Credenciales inválidas o sesión expirada."""
10
+
11
+
12
+ class TokenExpiredError(ToDusError):
13
+ """El token JWT ya no es válido."""
14
+
15
+
16
+ class ConnectionLostError(ToDusError):
17
+ """Conexión XMPP perdida inesperadamente."""
18
+
19
+
20
+ class MessageError(ToDusError):
21
+ """Error al enviar o recibir mensaje."""
22
+
23
+
24
+ class UploadError(ToDusError):
25
+ """Error en subida/descarga de archivo."""
26
+
27
+
28
+ class ParseError(ToDusError):
29
+ """Error parseando stanza XMPP."""
30
+
31
+
32
+ class RateLimitError(ToDusError):
33
+ """Demasiadas peticiones en poco tiempo."""
34
+
35
+
36
+ class StanzaError(ToDusError):
37
+ """Stanza malformada o no soportada."""
38
+
39
+
40
+ class GroupError(ToDusError):
41
+ """Error relacionado con grupos MUC Light."""