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/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)