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/group.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""Soporte para grupos MUC Light de ToDus."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
import hashlib
|
|
8
|
+
import time
|
|
9
|
+
import socket
|
|
10
|
+
from typing import Callable, Optional, List, Dict, TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from . import util, constants, stanza
|
|
13
|
+
from .errors import AuthenticationError, ConnectionLostError, GroupError, TokenExpiredError
|
|
14
|
+
from .types import FileType
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .client import ToDusClient2
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("todus.groups")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GroupRole:
|
|
23
|
+
"""Roles en grupo MUC Light."""
|
|
24
|
+
PARTICIPANT = "participant"
|
|
25
|
+
MODERATOR = "moderator"
|
|
26
|
+
OWNER = "owner"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class GroupEvent:
|
|
30
|
+
"""Eventos de grupo."""
|
|
31
|
+
MEMBER_JOINED = "joined"
|
|
32
|
+
MEMBER_LEFT = "left"
|
|
33
|
+
MEMBER_KICKED = "kicked"
|
|
34
|
+
MEMBER_BANNED = "banned"
|
|
35
|
+
SUBJECT_CHANGED = "subject_changed"
|
|
36
|
+
ROOM_CREATED = "created"
|
|
37
|
+
ROOM_DESTROYED = "destroyed"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class GroupClient:
|
|
41
|
+
"""Cliente para manejo de grupos MUC Light de ToDus."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, parent_client: ToDusClient2):
|
|
44
|
+
self.client = parent_client
|
|
45
|
+
self._joined_groups: set[str] = set()
|
|
46
|
+
self._group_callbacks: Dict[str, List[Callable]] = {}
|
|
47
|
+
|
|
48
|
+
def _get_group_jid(self, group_id: str) -> str:
|
|
49
|
+
"""Construye JID del grupo."""
|
|
50
|
+
return f"{group_id}@muclight.im.todus.cu"
|
|
51
|
+
|
|
52
|
+
def _generate_msg_id(self) -> str:
|
|
53
|
+
"""Genera ID de mensaje en formato hex MD5 de 32 chars."""
|
|
54
|
+
return hashlib.md5(util.generate_token(16).encode()).hexdigest()
|
|
55
|
+
|
|
56
|
+
def _extract_group_info(self, stanza_dict: dict) -> tuple[str, str]:
|
|
57
|
+
"""Extrae group_id y sender_phone de una stanza de grupo."""
|
|
58
|
+
from_jid = stanza_dict.get("from", "")
|
|
59
|
+
if "@muclight.im.todus.cu" not in from_jid:
|
|
60
|
+
return "", ""
|
|
61
|
+
|
|
62
|
+
# group_id@muclight.im.todus.cu/sender@im.todus.cu
|
|
63
|
+
parts = from_jid.split("/", 1)
|
|
64
|
+
group_id = ""
|
|
65
|
+
if parts:
|
|
66
|
+
group_part = parts[0]
|
|
67
|
+
if "@" in group_part:
|
|
68
|
+
group_id = group_part.split("@")[0]
|
|
69
|
+
|
|
70
|
+
sender = ""
|
|
71
|
+
if len(parts) > 1:
|
|
72
|
+
sender_jid = parts[1]
|
|
73
|
+
if "@" in sender_jid:
|
|
74
|
+
sender = sender_jid.split("@")[0]
|
|
75
|
+
|
|
76
|
+
return group_id, sender
|
|
77
|
+
|
|
78
|
+
# --- Acciones de grupo ---
|
|
79
|
+
|
|
80
|
+
def join(self, group_id: str, nickname: str = "") -> bool:
|
|
81
|
+
"""Unirse a un grupo MUC Light."""
|
|
82
|
+
if not self.client.token:
|
|
83
|
+
raise AuthenticationError("No autenticado")
|
|
84
|
+
|
|
85
|
+
group_jid = self._get_group_jid(group_id)
|
|
86
|
+
nick = nickname or self.client.phone_number
|
|
87
|
+
|
|
88
|
+
presence = stanza.muc_presence(group_jid, nick)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
with self.client._xmpp_session(self.client.token) as sock:
|
|
92
|
+
sock.send(presence.encode())
|
|
93
|
+
self._joined_groups.add(group_id)
|
|
94
|
+
logger.info(f"Unido al grupo: {group_id}")
|
|
95
|
+
return True
|
|
96
|
+
except Exception as e:
|
|
97
|
+
logger.error(f"Error uniéndose al grupo {group_id}: {e}")
|
|
98
|
+
raise GroupError(f"No se pudo unir al grupo: {e}")
|
|
99
|
+
|
|
100
|
+
def leave(self, group_id: str) -> bool:
|
|
101
|
+
"""Salir de un grupo."""
|
|
102
|
+
if group_id not in self._joined_groups:
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
group_jid = self._get_group_jid(group_id)
|
|
106
|
+
unavailable = stanza.muc_unavailable(group_jid)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
with self.client._xmpp_session(self.client.token) as sock:
|
|
110
|
+
sock.send(unavailable.encode())
|
|
111
|
+
self._joined_groups.discard(group_id)
|
|
112
|
+
logger.info(f"Salido del grupo: {group_id}")
|
|
113
|
+
return True
|
|
114
|
+
except Exception as e:
|
|
115
|
+
logger.error(f"Error saliendo del grupo {group_id}: {e}")
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
def send_message(self, group_id: str, body: str, msg_id: str = "") -> str:
|
|
119
|
+
"""Enviar mensaje de texto a un grupo."""
|
|
120
|
+
if not self.client.token:
|
|
121
|
+
raise AuthenticationError("No autenticado")
|
|
122
|
+
|
|
123
|
+
group_jid = self._get_group_jid(group_id)
|
|
124
|
+
mid = msg_id or self._generate_msg_id()
|
|
125
|
+
|
|
126
|
+
msg = stanza.group_message(group_jid, body, msg_id=mid)
|
|
127
|
+
|
|
128
|
+
with self.client._xmpp_session(self.client.token) as sock:
|
|
129
|
+
sock.send(msg.encode())
|
|
130
|
+
|
|
131
|
+
return mid
|
|
132
|
+
|
|
133
|
+
def send_file(self, group_id: str, url: str, file_name: str,
|
|
134
|
+
file_size: int, caption: str = "", msg_id: str = "") -> str:
|
|
135
|
+
"""Enviar archivo a un grupo."""
|
|
136
|
+
if not self.client.token:
|
|
137
|
+
raise AuthenticationError("No autenticado")
|
|
138
|
+
|
|
139
|
+
group_jid = self._get_group_jid(group_id)
|
|
140
|
+
mid = msg_id or self._generate_msg_id()
|
|
141
|
+
|
|
142
|
+
msg = stanza.group_file_message(group_jid, url, file_name, file_size, caption, msg_id=mid)
|
|
143
|
+
|
|
144
|
+
with self.client._xmpp_session(self.client.token) as sock:
|
|
145
|
+
sock.send(msg.encode())
|
|
146
|
+
|
|
147
|
+
return mid
|
|
148
|
+
|
|
149
|
+
def send_image(self, group_id: str, url: str, file_name: str,
|
|
150
|
+
file_size: int, width: int, height: int,
|
|
151
|
+
thumbnail: str = "", caption: str = "", msg_id: str = "") -> str:
|
|
152
|
+
"""Enviar imagen a un grupo."""
|
|
153
|
+
if not self.client.token:
|
|
154
|
+
raise AuthenticationError("No autenticado")
|
|
155
|
+
|
|
156
|
+
group_jid = self._get_group_jid(group_id)
|
|
157
|
+
mid = msg_id or self._generate_msg_id()
|
|
158
|
+
|
|
159
|
+
msg = stanza.group_image_message(group_jid, url, file_name, file_size,
|
|
160
|
+
width, height, thumbnail, caption, msg_id=mid)
|
|
161
|
+
|
|
162
|
+
with self.client._xmpp_session(self.client.token) as sock:
|
|
163
|
+
sock.send(msg.encode())
|
|
164
|
+
|
|
165
|
+
return mid
|
|
166
|
+
|
|
167
|
+
def send_video(self, group_id: str, url: str, video_id: str,
|
|
168
|
+
file_name: str, file_size: int, duration: int,
|
|
169
|
+
width: int, height: int, thumbnail: str,
|
|
170
|
+
caption: str = "", msg_id: str = "") -> str:
|
|
171
|
+
"""Enviar video a un grupo."""
|
|
172
|
+
if not self.client.token:
|
|
173
|
+
raise AuthenticationError("No autenticado")
|
|
174
|
+
|
|
175
|
+
group_jid = self._get_group_jid(group_id)
|
|
176
|
+
mid = msg_id or self._generate_msg_id()
|
|
177
|
+
|
|
178
|
+
msg = stanza.group_video_message(group_jid, url, video_id, file_name,
|
|
179
|
+
file_size, duration, width, height,
|
|
180
|
+
thumbnail, caption, msg_id=mid)
|
|
181
|
+
|
|
182
|
+
with self.client._xmpp_session(self.client.token) as sock:
|
|
183
|
+
sock.send(msg.encode())
|
|
184
|
+
|
|
185
|
+
return mid
|
|
186
|
+
|
|
187
|
+
def send_sticker(self, group_id: str, sticker_id: str,
|
|
188
|
+
sticker_name: str, sticker_pack: str,
|
|
189
|
+
sticker_hash: str, msg_id: str = "") -> str:
|
|
190
|
+
"""Enviar sticker a un grupo."""
|
|
191
|
+
if not self.client.token:
|
|
192
|
+
raise AuthenticationError("No autenticado")
|
|
193
|
+
|
|
194
|
+
group_jid = self._get_group_jid(group_id)
|
|
195
|
+
mid = msg_id or self._generate_msg_id()
|
|
196
|
+
|
|
197
|
+
msg = stanza.group_sticker_message(group_jid, sticker_id, sticker_name,
|
|
198
|
+
sticker_pack, sticker_hash, msg_id=mid)
|
|
199
|
+
|
|
200
|
+
with self.client._xmpp_session(self.client.token) as sock:
|
|
201
|
+
sock.send(msg.encode())
|
|
202
|
+
|
|
203
|
+
return mid
|
|
204
|
+
|
|
205
|
+
def send_contact(self, group_id: str, contact_id: str,
|
|
206
|
+
contact_name: str, contact_phone: str,
|
|
207
|
+
msg_id: str = "") -> str:
|
|
208
|
+
"""Enviar contacto a un grupo."""
|
|
209
|
+
if not self.client.token:
|
|
210
|
+
raise AuthenticationError("No autenticado")
|
|
211
|
+
|
|
212
|
+
group_jid = self._get_group_jid(group_id)
|
|
213
|
+
mid = msg_id or self._generate_msg_id()
|
|
214
|
+
|
|
215
|
+
msg = stanza.group_contact_message(group_jid, contact_id, contact_name,
|
|
216
|
+
contact_phone, msg_id=mid)
|
|
217
|
+
|
|
218
|
+
with self.client._xmpp_session(self.client.token) as sock:
|
|
219
|
+
sock.send(msg.encode())
|
|
220
|
+
|
|
221
|
+
return mid
|
|
222
|
+
|
|
223
|
+
def send_location(self, group_id: str, lat: float, lon: float,
|
|
224
|
+
zoom: float = 11.0, text: str = "", msg_id: str = "") -> str:
|
|
225
|
+
"""Enviar ubicación a un grupo."""
|
|
226
|
+
if not self.client.token:
|
|
227
|
+
raise AuthenticationError("No autenticado")
|
|
228
|
+
|
|
229
|
+
group_jid = self._get_group_jid(group_id)
|
|
230
|
+
mid = msg_id or self._generate_msg_id()
|
|
231
|
+
|
|
232
|
+
msg = stanza.group_location_message(group_jid, lat, lon, zoom, text, msg_id=mid)
|
|
233
|
+
|
|
234
|
+
with self.client._xmpp_session(self.client.token) as sock:
|
|
235
|
+
sock.send(msg.encode())
|
|
236
|
+
|
|
237
|
+
return mid
|
|
238
|
+
|
|
239
|
+
def send_event(self, group_id: str, title: str, start: int, end: int,
|
|
240
|
+
all_day: bool, ics_data: str, event_id: str = "") -> str:
|
|
241
|
+
"""Enviar evento a un grupo."""
|
|
242
|
+
if not self.client.token:
|
|
243
|
+
raise AuthenticationError("No autenticado")
|
|
244
|
+
|
|
245
|
+
group_jid = self._get_group_jid(group_id)
|
|
246
|
+
mid = self._generate_msg_id()
|
|
247
|
+
|
|
248
|
+
msg = stanza.group_event_message(group_jid, event_id, title, start, end, all_day, ics_data, msg_id=mid)
|
|
249
|
+
|
|
250
|
+
with self.client._xmpp_session(self.client.token) as sock:
|
|
251
|
+
sock.send(msg.encode())
|
|
252
|
+
|
|
253
|
+
return mid
|
|
254
|
+
|
|
255
|
+
def edit_message(self, group_id: str, new_body: str,
|
|
256
|
+
original_msg_id: str) -> str:
|
|
257
|
+
"""Editar un mensaje en grupo."""
|
|
258
|
+
if not self.client.token:
|
|
259
|
+
raise AuthenticationError("No autenticado")
|
|
260
|
+
|
|
261
|
+
group_jid = self._get_group_jid(group_id)
|
|
262
|
+
edit_id = self._generate_msg_id()
|
|
263
|
+
|
|
264
|
+
msg = stanza.group_edit_message(group_jid, new_body, original_msg_id, edit_id)
|
|
265
|
+
|
|
266
|
+
with self.client._xmpp_session(self.client.token) as sock:
|
|
267
|
+
sock.send(msg.encode())
|
|
268
|
+
|
|
269
|
+
return edit_id
|
|
270
|
+
|
|
271
|
+
def delete_message(self, group_id: str, message_id: str, body: str = "", media_xml: str = "") -> str:
|
|
272
|
+
"""Eliminar un mensaje en grupo."""
|
|
273
|
+
if not self.client.token:
|
|
274
|
+
raise AuthenticationError("No autenticado")
|
|
275
|
+
|
|
276
|
+
group_jid = self._get_group_jid(group_id)
|
|
277
|
+
mid = self._generate_msg_id()
|
|
278
|
+
|
|
279
|
+
msg = stanza.group_delete_message(group_jid, message_id, mid, body=body, media_xml=media_xml)
|
|
280
|
+
|
|
281
|
+
with self.client._xmpp_session(self.client.token) as sock:
|
|
282
|
+
sock.send(msg.encode())
|
|
283
|
+
|
|
284
|
+
return mid
|
|
285
|
+
|
|
286
|
+
# --- Recepción de mensajes de grupo ---
|
|
287
|
+
|
|
288
|
+
def process_group_message(self, msg: dict) -> dict:
|
|
289
|
+
"""Procesa y enriquece un mensaje de grupo."""
|
|
290
|
+
if msg.get("type") != "gc":
|
|
291
|
+
return msg
|
|
292
|
+
|
|
293
|
+
group_id, sender = self._extract_group_info(msg)
|
|
294
|
+
|
|
295
|
+
msg["is_group"] = True
|
|
296
|
+
msg["group_id"] = group_id
|
|
297
|
+
msg["sender_phone"] = sender
|
|
298
|
+
|
|
299
|
+
# Detectar eventos de grupo
|
|
300
|
+
raw = msg.get("raw", "")
|
|
301
|
+
if "<x xmlns='http://jabber.org/protocol/muc#user'>" in raw:
|
|
302
|
+
msg["is_group_event"] = True
|
|
303
|
+
msg["event"] = self._parse_group_event(raw)
|
|
304
|
+
|
|
305
|
+
# Extraer subject (tema del grupo)
|
|
306
|
+
if "<subject>" in raw:
|
|
307
|
+
subj_match = re.search(r"<subject>(.*?)</subject>", raw)
|
|
308
|
+
if subj_match:
|
|
309
|
+
msg["subject"] = util.unescape_xml(subj_match.group(1))
|
|
310
|
+
|
|
311
|
+
return msg
|
|
312
|
+
|
|
313
|
+
def _parse_group_event(self, raw_stanza: str) -> Optional[str]:
|
|
314
|
+
"""Parsea eventos de grupo (entrada/salida, etc.)."""
|
|
315
|
+
if "<status code='110'/>" in raw_stanza:
|
|
316
|
+
return GroupEvent.MEMBER_JOINED
|
|
317
|
+
if '<status code="303"/>' in raw_stanza:
|
|
318
|
+
return GroupEvent.MEMBER_LEFT
|
|
319
|
+
if '<status code="307"/>' in raw_stanza:
|
|
320
|
+
return GroupEvent.MEMBER_KICKED
|
|
321
|
+
if '<status code="301"/>' in raw_stanza:
|
|
322
|
+
return GroupEvent.MEMBER_BANNED
|
|
323
|
+
if "<subject" in raw_stanza and "</subject>" in raw_stanza:
|
|
324
|
+
return GroupEvent.SUBJECT_CHANGED
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
def on_group_message(self, group_id: str, callback: Callable):
|
|
328
|
+
"""Registra callback para mensajes de un grupo específico."""
|
|
329
|
+
if group_id not in self._group_callbacks:
|
|
330
|
+
self._group_callbacks[group_id] = []
|
|
331
|
+
self._group_callbacks[group_id].append(callback)
|
|
332
|
+
|
|
333
|
+
def remove_callback(self, group_id: str, callback: Callable = None):
|
|
334
|
+
"""Elimina callback(s) de un grupo."""
|
|
335
|
+
if group_id not in self._group_callbacks:
|
|
336
|
+
return
|
|
337
|
+
if callback is None:
|
|
338
|
+
del self._group_callbacks[group_id]
|
|
339
|
+
else:
|
|
340
|
+
self._group_callbacks[group_id] = [
|
|
341
|
+
cb for cb in self._group_callbacks[group_id] if cb != callback
|
|
342
|
+
]
|
|
343
|
+
|
|
344
|
+
# --- Utilidades ---
|
|
345
|
+
|
|
346
|
+
def is_joined(self, group_id: str) -> bool:
|
|
347
|
+
"""Verifica si está unido a un grupo."""
|
|
348
|
+
return group_id in self._joined_groups
|
|
349
|
+
|
|
350
|
+
def get_joined_groups(self) -> List[str]:
|
|
351
|
+
"""Lista de grupos a los que está unido."""
|
|
352
|
+
return list(self._joined_groups)
|
|
353
|
+
|
|
354
|
+
def upload_and_send_image(self, group_id: str, image_data: bytes,
|
|
355
|
+
filename: str = "image.jpg", caption: str = "") -> str:
|
|
356
|
+
"""Sube una imagen y la envía al grupo en un solo paso."""
|
|
357
|
+
url = self.client.upload_file(image_data, FileType.PICTURE, file_name=filename)
|
|
358
|
+
width, height = util.get_image_dimensions(image_data)
|
|
359
|
+
thumbnail = util.generate_blurhash(width, height)
|
|
360
|
+
|
|
361
|
+
return self.send_image(
|
|
362
|
+
group_id, url, filename, len(image_data),
|
|
363
|
+
width, height, thumbnail, caption
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
def upload_and_send_file(self, group_id: str, file_data: bytes,
|
|
367
|
+
filename: str, caption: str = "") -> str:
|
|
368
|
+
"""Sube un archivo y lo envía al grupo en un solo paso."""
|
|
369
|
+
url = self.client.upload_file(file_data, FileType.FILE, file_name=filename)
|
|
370
|
+
return self.send_file(group_id, url, filename, len(file_data), caption)
|