epok-toolkit 1.12.8__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.
@@ -0,0 +1,173 @@
1
+ """
2
+
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Dict, List
7
+ from django.conf import settings
8
+ import re
9
+
10
+
11
+ # ============================
12
+ # Config base
13
+ # ============================
14
+
15
+ TEMPLATES_SETTINGS = getattr(settings, "TEMPLATE_SETTINGS", {})
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Colors:
20
+ BACKGROUND: str = TEMPLATES_SETTINGS.get("colors", {}).get("background", "#f9fafb")
21
+ PRIMARY: str = TEMPLATES_SETTINGS.get("colors", {}).get("primary", "#4f46e5")
22
+ TEXT: str = TEMPLATES_SETTINGS.get("colors", {}).get("text", "#374151")
23
+ WHITE: str = TEMPLATES_SETTINGS.get("colors", {}).get("white", "#ffffff")
24
+
25
+ @dataclass(frozen=True)
26
+ class Company:
27
+ name: str = TEMPLATES_SETTINGS.get("company", {}).get("name", "Congrats")
28
+ email: str = TEMPLATES_SETTINGS.get("company", {}).get("email", "info@compania.com")
29
+ eslogan: str = TEMPLATES_SETTINGS.get("company", {}).get("eslogan", "Eslogan sin definir")
30
+ footer: str = TEMPLATES_SETTINGS.get("company", {}).get("footer", "¡Nos vemos pronto!<br><em>El equipo de Congrats 🥳</em>")
31
+
32
+
33
+
34
+ # ============================
35
+ # Grupper (envoltorio HTML)
36
+ # ============================
37
+
38
+ def wrap_html(content: str) -> str:
39
+ return f"""
40
+ <div style="font-family: Arial, Helvetica, sans-serif; background-color: {Colors.BACKGROUND}; padding: 24px;">
41
+ <table width="100%" cellpadding="0" cellspacing="0" style="max-width: 600px; margin: 0 auto; background: {Colors.WHITE}; border-radius: 8px; overflow: hidden;">
42
+ <tr>
43
+ <td style="background: {Colors.PRIMARY}; padding: 20px 24px; text-align: center;">
44
+ <h1 style="color: {Colors.WHITE}; margin: 0; font-size: 24px;">{Company.name} 🎉</h1>
45
+ </td>
46
+ </tr>
47
+ <tr>
48
+ <td style="padding: 32px 24px;">
49
+ {content}
50
+ </td>
51
+ </tr>
52
+ </table>
53
+ </div>
54
+ """
55
+
56
+
57
+ # ============================
58
+ # Tipos
59
+ # ============================
60
+
61
+ @dataclass(frozen=True)
62
+ class RenderedEmail:
63
+ subject: str
64
+ plain: str
65
+ html: str
66
+
67
+ @dataclass(frozen=True)
68
+ class EmailTemplate:
69
+ subject: str
70
+ plain_body: str
71
+ html_body: str
72
+ required_vars: List[str]
73
+
74
+ def render(self, context: Dict[str, str]) -> RenderedEmail:
75
+ missing = [v for v in self.required_vars if v not in context]
76
+ if missing:
77
+ raise ValueError(f"Faltan llaves en contexto: {missing}")
78
+ return RenderedEmail(
79
+ subject=self.subject.format(**context),
80
+ plain=self.plain_body.format(**context),
81
+ html=self.html_body.format(**context),
82
+ )
83
+
84
+
85
+ # ============================
86
+ # Registro dinámico
87
+ # ============================
88
+
89
+ class TemplateRegistry:
90
+ def __init__(self):
91
+ self._templates: Dict[str, EmailTemplate] = {}
92
+
93
+ def _html_to_plain(self, html: str) -> str:
94
+ return re.sub(r"<[^>]*>", "", html).strip()
95
+
96
+ def register_template(self, key: str, subject: str, html_body: str, required_vars: List[str], plain_body: str | None = None) -> None:
97
+ if key in self._templates:
98
+ raise ValueError(f"Template con clave '{key}' ya está registrado.")
99
+ plain = plain_body or self._html_to_plain(html_body)
100
+ wrapped_html = wrap_html(html_body)
101
+ self._templates[key] = EmailTemplate(
102
+ subject=subject,
103
+ plain_body=plain,
104
+ html_body=wrapped_html,
105
+ required_vars=required_vars
106
+ )
107
+
108
+ @property
109
+ def templates(self) -> Dict[str, EmailTemplate]:
110
+ return self._templates
111
+
112
+ registry = TemplateRegistry()
113
+
114
+ # ============================
115
+ # Registrar plantillas base usando el mismo builder
116
+ # ============================
117
+
118
+ registry.register_template(
119
+ key="password_reset",
120
+ subject=f"🔑 Restablecimiento de Contraseña – {Company.name}",
121
+ html_body=(
122
+ "<p style='font-size:16px;'>¡Hola <strong>{name}</strong>! 🎉</p>"
123
+ "<p style='font-size:16px;margin:24px 0;'>Haz clic en el botón para restablecer tu contraseña:</p>"
124
+ "<p style='text-align:center;margin:24px 0;'>"
125
+ f"<a href='{{reset_link}}' style='display:inline-block;padding:12px 24px;font-size:16px;color:{Colors.WHITE};background-color:{Colors.PRIMARY};text-decoration:none;border-radius:5px;'>🔒 Restablecer Contraseña 🎊</a>"
126
+ "</p>"
127
+ "<p style='font-size:16px;'>Si no funciona, copia este enlace:<br>"
128
+ "<span style='word-break:break-all;font-size:14px;'>{reset_link}</span></p>"
129
+ f"<p style='font-size:16px;margin-top:24px;'>{Company.footer}</p>"
130
+ ),
131
+ required_vars=["name", "reset_link"]
132
+ )
133
+
134
+ registry.register_template(
135
+ key="welcome",
136
+ subject=f"🎉 Bienvenido a {Company.name}, {{name}}",
137
+ html_body=(
138
+ "<p style='font-size:16px;'>¡Hola <strong>{name}</strong>! 🎈</p>"
139
+ "<p style='font-size:16px;margin:24px 0;'>Gracias por unirte a <strong>{Company.name}</strong>.</p>"
140
+ f"<p style='font-size:16px;margin-top:24px;'>{Company.footer}</p>"
141
+ ),
142
+ required_vars=["name"]
143
+ )
144
+
145
+ registry.register_template(
146
+ key="password_reset_success",
147
+ subject=f"🔑 Contraseña restablecida – {Company.name}",
148
+ html_body=(
149
+ "<p style='font-size:16px;'>¡Hola <strong>{name}</strong>! 🔑</p>"
150
+ "<p style='font-size:16px;margin:24px 0;'>Tu contraseña ha sido restablecida con éxito 🎉</p>"
151
+ f"<p style='font-size:16px;margin-top:24px;'>{Company.footer}</p>"
152
+ ),
153
+ required_vars=["name"]
154
+ )
155
+
156
+ registry.register_template(
157
+ key="test_app_running",
158
+ subject=f"🚀 Test OK – {Company.name}",
159
+ html_body=(
160
+ "<p style='font-size:16px;'>¡Hola <strong>{name}</strong>! 🚀</p>"
161
+ "<p style='font-size:16px;margin:24px 0;'>La prueba de funcionamiento pasó. Tu app está lista para rockear. 🎉</p>"
162
+ f"<p style='font-size:16px;margin-top:24px;'>{Company.footer}</p>"
163
+ ),
164
+ required_vars=["name"]
165
+ )
166
+
167
+ # ============================
168
+ # Catálogo final
169
+ # ============================
170
+
171
+ TEMPLATES: Dict[str, EmailTemplate] = {
172
+ **registry.templates,
173
+ }
@@ -0,0 +1 @@
1
+ from .whatsapp_instanced import send_text, send_media
@@ -0,0 +1,367 @@
1
+ import requests
2
+ from typing import Optional
3
+ from functools import wraps
4
+ from dataclasses import dataclass
5
+
6
+
7
+ def timeout_response(func):
8
+ @wraps(func)
9
+ def wrapper(*args, **kwargs):
10
+ try:
11
+ return func(*args, **kwargs)
12
+ except requests.Timeout:
13
+ print("La solicitud ha excedido el tiempo de espera.")
14
+ return HttpResponse(status_code=408, text="Timeout", json_data=None)
15
+ except requests.RequestException as e:
16
+ print(f"Error en la solicitud: {e}")
17
+ return HttpResponse(
18
+ status_code=500, text="Error", json_data={"error": str(e)}
19
+ )
20
+
21
+ return wrapper
22
+
23
+
24
+ def require_connection(method):
25
+ """
26
+ Decorador para métodos de WhatsappClient que necesitan una conexión activa.
27
+ Llama a `self.ensure_connected()` y solo ejecuta el método original si la
28
+ conexión se confirma; de lo contrario devuelve False.
29
+ """
30
+ from functools import wraps
31
+
32
+ @wraps(method)
33
+ def _wrapper(self, *args, **kwargs):
34
+ if not self.ensure_connected():
35
+ print("❌ No fue posible establecer conexión.")
36
+ return False
37
+ return method(self, *args, **kwargs)
38
+
39
+ return _wrapper
40
+
41
+
42
+ @dataclass
43
+ class HttpResponse:
44
+ status_code: int
45
+ text: str
46
+ json_data: Optional[dict] = None
47
+
48
+
49
+ class WhatsAppInstance:
50
+ def __init__(self, api_key: str, instance: str, server_url: str):
51
+ self.api_key = api_key
52
+ self.name_instance = instance
53
+ self.status = "disconnected"
54
+ self.server_url = server_url.rstrip("/")
55
+ self.headers = {"apikey": self.api_key, "Content-Type": "application/json"}
56
+
57
+ def create_instance(self) -> HttpResponse:
58
+ """Crea una nueva instancia de WhatsApp usando la API de Envole."""
59
+ url = f"{self.server_url}/instance/create"
60
+ payload = {
61
+ "instanceName": self.name_instance,
62
+ "integration": "WHATSAPP-BAILEYS",
63
+ "syncFullHistory": False,
64
+ }
65
+ response = requests.post(url, json=payload, headers=self.headers)
66
+ return HttpResponse(response.status_code, response.text, response.json())
67
+
68
+ def delete_instance(self) -> HttpResponse:
69
+ """Elimina una instancia de WhatsApp usando la API de Envole."""
70
+ url = f"{self.server_url}/instance/delete/{self.name_instance}"
71
+ response = requests.delete(url, headers=self.headers)
72
+ return HttpResponse(response.status_code, response.text)
73
+
74
+ def show_qr(self, qr_text: str) -> None:
75
+ """Genera un código QR a partir de `qr_text` y lo muestra con el visor por defecto."""
76
+ import qrcode
77
+
78
+ qr = qrcode.QRCode(border=2)
79
+ qr.add_data(qr_text)
80
+ qr.make(fit=True)
81
+ img = qr.make_image()
82
+ img.show()
83
+
84
+ def connect_instance_qr(self) -> None:
85
+ """Conecta una instancia de WhatsApp y muestra una imagen"""
86
+ url = f"{self.server_url}/instance/connect/{self.name_instance}"
87
+ response = requests.get(url, headers=self.headers)
88
+ codigo = response.json().get("code")
89
+ self.show_qr(codigo)
90
+
91
+ def mode_connecting(self):
92
+ """
93
+ Se intentará por 30 min el mantenter intentos de conexión a la instancia
94
+ generando un qr cada 10 segundos, si es exitoso se podra enviar un mensaje,
95
+ si después de eso no se conecta, se devolvera un error
96
+ """
97
+ pass
98
+
99
+
100
+ class WhatsAppSender:
101
+ def __init__(self, instance: WhatsAppInstance):
102
+ self.instance = instance.name_instance
103
+ self.server_url = instance.server_url
104
+ self.headers = instance.headers
105
+ self._instance_obj = instance
106
+ self.connected = True # estado de conexión conocido
107
+
108
+ def test_connection_status(self) -> bool:
109
+ cel_epok = "5214778966517"
110
+ print(f"Probando conexión enviando mensaje a {cel_epok}...")
111
+ ok = bool(self.send_text(cel_epok, "ping"))
112
+ self.connected = ok
113
+ return ok
114
+
115
+ @timeout_response
116
+ def get(self, endpoint: str, params: Optional[dict] = None) -> requests.Response:
117
+ url = f"{self.server_url}{endpoint}"
118
+ return requests.get(url, headers=self.headers, params=params)
119
+
120
+ def put(self, endpoint: str) -> requests.Response:
121
+ url = f"{self.server_url}{endpoint}"
122
+ return requests.put(url, headers=self.headers)
123
+
124
+ def post(self, endpoint: str, payload: dict):
125
+ url = f"{self.server_url}{endpoint}"
126
+ request = requests.post(url, json=payload, headers=self.headers, timeout=10)
127
+ # if timeout:
128
+ try:
129
+ return request
130
+ except requests.Timeout:
131
+ print("Request timed out")
132
+ return HttpResponse(status_code=408, text="Timeout", json_data=None)
133
+
134
+ def send_text(self, number: str, text: str, link_preview: bool = True, delay_ms: int = 0) -> str:
135
+ payload = {
136
+ "number": number,
137
+ "text": text,
138
+ "delay": delay_ms,
139
+ "linkPreview": link_preview,
140
+ }
141
+ print(f"Enviando mensaje a {number}: {text}")
142
+ resp = self.post(f"/message/sendText/{self.instance}", payload)
143
+
144
+ # Si la solicitud se convirtió en HttpResponse por timeout
145
+ status = resp.status_code if hasattr(resp, "status_code") else 0
146
+
147
+ if 200 <= status < 300:
148
+ self.connected = True
149
+ return resp.text
150
+
151
+ # Fallo: marcar desconexión y reportar
152
+ print(f"Error al enviar mensaje a {number}: {status} - {resp.text}")
153
+ self.connected = False
154
+ return False
155
+
156
+ def send_media(self, number: str, media_b64: str, filename: str, caption: str, mediatype: str = "document", mimetype: str = "application/pdf") -> str:
157
+ payload = {
158
+ "number": number,
159
+ "mediatype": mediatype,
160
+ "mimetype": mimetype,
161
+ "caption": caption,
162
+ "media": media_b64,
163
+ "fileName": filename,
164
+ "delay": 0,
165
+ "linkPreview": False,
166
+ "mentionsEveryOne": False,
167
+ }
168
+ resp = self.post(f"/message/sendMedia/{self.instance}", payload)
169
+ return resp.text
170
+
171
+ def send_sticker(self, number: str, sticker_b64: str, delay: int = 0, link_preview: bool = True, mentions_everyone: bool = True) -> str:
172
+ """Envía un sticker a un contacto específico."""
173
+ payload = {
174
+ "number": number,
175
+ "sticker": sticker_b64,
176
+ "delay": delay,
177
+ "linkPreview": link_preview,
178
+ "mentionsEveryOne": mentions_everyone,
179
+ }
180
+ resp = self.post(f"/message/sendSticker/{self.instance}", payload)
181
+ return resp.text
182
+
183
+ def send_location(self, number: str, name: str, address: str, latitude: float, longitude: float, delay: int = 0) -> str:
184
+ """Envía una ubicación a un contacto."""
185
+ payload = {
186
+ "number": number,
187
+ "name": name,
188
+ "address": address,
189
+ "latitude": latitude,
190
+ "longitude": longitude,
191
+ "delay": delay,
192
+ }
193
+ resp = self.post(f"/message/sendLocation/{self.instance}", payload)
194
+ return resp.text
195
+
196
+ def send_audio(self, number: str, audio_b64: str, delay: int = 0) -> str:
197
+ """Envía un audio en formato base64 a un contacto."""
198
+ payload = {
199
+ "audio": audio_b64,
200
+ "number": number,
201
+ "delay": delay,
202
+ }
203
+ resp = self.post(f"/message/sendWhatsAppAudio/{self.instance}", payload)
204
+ return resp.text
205
+
206
+ def connect(self, number: str) -> str:
207
+ querystring = {"number": number}
208
+ resp = self.get(f"/instance/connect/{self.instance}", params=querystring)
209
+ return resp.text
210
+
211
+ def set_webhook(self, webhook_url: str, enabled: bool = True, webhook_by_events: bool = True, webhook_base64: bool = True, events: Optional[list] = None) -> str:
212
+ """Configura el webhook para la instancia."""
213
+ if events is None:
214
+ events = ["SEND_MESSAGE"]
215
+ payload = {
216
+ "url": webhook_url,
217
+ "enabled": enabled,
218
+ "webhookByEvents": webhook_by_events,
219
+ "webhookBase64": webhook_base64,
220
+ "events": events,
221
+ }
222
+ resp = self.post(f"/webhook/set/{self.instance}", payload)
223
+ return resp.text
224
+
225
+ def fetch_groups(self, get_participants: bool = True) -> list:
226
+ """Obtiene todos los grupos y sus participantes."""
227
+ params = {"getParticipants": str(get_participants).lower()}
228
+ resp = self.get(f"/group/fetchAllGroups/{self.instance}", params=params)
229
+ if resp.status_code == 200:
230
+ return resp.json()
231
+ else:
232
+ raise Exception(
233
+ f"Error al obtener grupos: {resp.status_code} - {resp.text}"
234
+ )
235
+
236
+ @staticmethod
237
+ def fetch_instances(api_key: str, server_url: str) -> list:
238
+ """Obtiene todas las instancias disponibles en el servidor."""
239
+ url = f"{server_url}/instance/fetchInstances"
240
+ headers = {"apikey": api_key}
241
+ response = requests.get(url, headers=headers, verify=False)
242
+ # Puede ser una lista o dict, depende del backend
243
+ try:
244
+ return response.json()
245
+ except Exception:
246
+ return []
247
+
248
+ @staticmethod
249
+ def get_instance_info(api_key: str, instance_name: str, server_url: str):
250
+ """Busca la info de una instancia específica por nombre, robusto a diferentes formatos de respuesta."""
251
+ instances = WhatsAppSender.fetch_instances(api_key, server_url)
252
+
253
+ # Normalizar a lista para iterar
254
+ if isinstance(instances, dict):
255
+ instances = [instances]
256
+ # print(f"Buscando instancia: {instance_name} en {len(instances)} instancias disponibles.")
257
+ for item in instances:
258
+ data = (
259
+ item.get("instance")
260
+ if isinstance(item, dict) and "instance" in item
261
+ else item
262
+ )
263
+ # print(data)
264
+ if not isinstance(data, dict):
265
+ continue # Formato inesperado para us
266
+
267
+ if data.get("name") == instance_name:
268
+ return data
269
+ return {}
270
+
271
+
272
+
273
+ class WhatsappClient:
274
+ """
275
+ Cliente para interactuar con la API de WhatsApp.
276
+ """
277
+ def __init__(self, api_key: str, server_url: str, instance_name: str = "EPOK"):
278
+ self.instance = WhatsAppInstance(api_key, instance_name, server_url)
279
+ self.sender: Optional[WhatsAppSender] = None
280
+ self._auto_initialize_sender()
281
+
282
+ def _auto_initialize_sender(self):
283
+ """Solo asigna sender si la instancia está enlazada a WhatsApp."""
284
+ info = WhatsAppSender.get_instance_info(
285
+ self.instance.api_key, self.instance.name_instance, self.instance.server_url
286
+ )
287
+ if info.get("ownerJid"): # <- si tiene owner, significa que ya está enlazada
288
+ self.sender = WhatsAppSender(self.instance)
289
+
290
+ def ensure_connected(self, retries: int = 3, delay: int = 30) -> bool:
291
+ """
292
+ Garantiza que la instancia esté conectada.
293
+ Si aún no existe `self.sender`, intentará crearlo.
294
+ Si la prueba de conexión falla, muestra un QR y reintenta.
295
+ """
296
+ import time
297
+
298
+ # Si ya tenemos sender y está marcado como conectado, salimos rápido
299
+ if self.sender and getattr(self.sender, "connected", False):
300
+ return True
301
+
302
+ def _init_sender():
303
+ if self.sender is None:
304
+ # Intentar inicializar si la instancia ya está enlazada
305
+ info = WhatsAppSender.get_instance_info(
306
+ self.instance.api_key,
307
+ self.instance.name_instance,
308
+ self.instance.server_url,
309
+ )
310
+ if info.get("ownerJid"):
311
+ self.sender = WhatsAppSender(self.instance)
312
+
313
+ # Primer intento de inicializar el sender
314
+ _init_sender()
315
+
316
+ for attempt in range(1, retries + 1):
317
+ if self.sender and self.sender.test_connection_status():
318
+ return True
319
+
320
+ print(
321
+ f"[{attempt}/{retries}] Conexión no disponible, mostrando nuevo QR (espera {delay}s)…"
322
+ )
323
+ self.instance.connect_instance_qr() # muestra nuevo QR
324
+ time.sleep(delay)
325
+
326
+ # Reintentar inicializar sender después de mostrar QR
327
+ _init_sender()
328
+
329
+ print("❌ No fue posible establecer conexión después de varios intentos.")
330
+ return False
331
+
332
+ @require_connection
333
+ def send_text(self, number: str, text: str, link_preview: bool = True, delay_ms: int = 1000):
334
+ return self.sender.send_text(number, text, link_preview, delay_ms=delay_ms)
335
+
336
+ @require_connection
337
+ def send_media(self, number: str, media_b64: str, filename: str, caption: str, mediatype: str = "document", mimetype: str = "application/pdf"):
338
+ return self.sender.send_media(number, media_b64, filename, caption, mediatype, mimetype)
339
+
340
+ @require_connection
341
+ def send_sticker(self, number: str, sticker_b64: str, delay: int = 0, link_preview: bool = True, mentions_everyone: bool = True):
342
+ return self.sender.send_sticker(number, sticker_b64, delay, link_preview, mentions_everyone)
343
+
344
+ @require_connection
345
+ def send_location(self, number: str, name: str, address: str, latitude: float, longitude: float, delay: int = 0):
346
+ return self.sender.send_location(number, name, address, latitude, longitude, delay)
347
+
348
+ @require_connection
349
+ def send_audio(self, number: str, audio_b64: str, delay: int = 0):
350
+ return self.sender.send_audio(number, audio_b64, delay)
351
+
352
+ @require_connection
353
+ def connect_number(self, number: str):
354
+ return self.sender.connect(number)
355
+
356
+ @require_connection
357
+ def fetch_groups(self, get_participants: bool = True):
358
+ return self.sender.fetch_groups(get_participants)
359
+
360
+ def create_instance(self):
361
+ return self.instance.create_instance()
362
+
363
+ def delete_instance(self):
364
+ return self.instance.delete_instance()
365
+
366
+ def connect_instance_qr(self):
367
+ return self.instance.connect_instance_qr()
@@ -0,0 +1,34 @@
1
+ from colorstreak import Logger as log
2
+ from .whatsapp import WhatsappClient
3
+ from django.conf import settings
4
+ from celery import shared_task
5
+
6
+
7
+ API_KEY = settings.API_KEY
8
+ INSTANCE = settings.INSTANCE
9
+ SERVER_URL = settings.SERVER_URL
10
+
11
+ @shared_task
12
+ def send_whatsapp_message_async(number: str, message: str):
13
+ log.library(f"Enviando mensaje a {number}: '{message}'")
14
+ client = WhatsappClient(api_key=API_KEY, server_url=SERVER_URL, instance_name=INSTANCE)
15
+ return client.send_text(number, message)
16
+
17
+
18
+ @shared_task
19
+ def send_whatsapp_media_async(number: str, media_b64: str, filename: str, caption: str, mediatype: str = "document", mimetype: str = "application/pdf"):
20
+ log.library(f"Enviando media a {number}: '{filename}'")
21
+ client = WhatsappClient(api_key=API_KEY, server_url=SERVER_URL, instance_name=INSTANCE)
22
+ return client.send_media(number, media_b64, filename, caption, mediatype, mimetype)
23
+
24
+
25
+
26
+
27
+ def send_text(number: str, message: str):
28
+ log.library(f"Programando tarea para {number}")
29
+ send_whatsapp_message_async.delay(number, message)
30
+
31
+
32
+ def send_media(number: str, media_b64: str, filename: str, caption: str, mediatype: str = "document", mimetype: str = "application/pdf"):
33
+ log.library(f"Programando tarea para enviar media a {number}")
34
+ send_whatsapp_media_async.delay(number, media_b64, filename, caption, mediatype, mimetype)
@@ -0,0 +1 @@
1
+ from .ticket_pdf import TicketPDF
Binary file