Actualizar backend/main.py

This commit is contained in:
2026-10-07 12:04:57 -05:00
parent c0c19d8d40
commit d233b38186

View File

@@ -169,8 +169,7 @@ def get_plantillas():
def post_plantillas_enviar(payload: dict = Body(...)):
"""Envía una plantilla de WhatsApp (Chatwoot) a una lista de números.
payload: {nombre, categoria, idioma, texto, telefonos:[...]}.
Se envía a CUALQUIER país. NO se crean contactos nuevos: si el número no
existe en Chatwoot, se salta y se reporta en 'no_encontrados'."""
Solo se envía a números peruanos con código 51 (formato 51XXXXXXXXX)."""
import os, requests, time
CHATWOOT_URL = os.getenv("CHATWOOT_URL", "https://gestor.escueladerefrigeracion.edu.pe")
TOKEN = os.getenv("CHATWOOT_TOKEN", "4anazHvZnvKLtup8biu5Zuoh")
@@ -192,7 +191,8 @@ def post_plantillas_enviar(payload: dict = Body(...)):
for x in ("+", " ", "-", "(", ")"): s = s.replace(x, "")
return s
# Se envía a CUALQUIER país. Solo se exige que sea un número (dígitos, min 8).
# Se envía a CUALQUIER país. Solo se exige que sea un número (dígitos).
# NO se crean contactos nuevos: si no existe en Chatwoot, se salta y se reporta.
validos, invalidos = [], []
for t in telefonos_raw:
s = normaliza(t)
@@ -245,7 +245,8 @@ def post_plantillas_enviar(payload: dict = Body(...)):
fallidos.append(tel)
time.sleep(1)
# "fallo_envio" = existían pero falló el envío (excluye los no_encontrados)
# "fallidos" incluye los que no existen (no_encontrados) + los que sí existían
# pero falló el envío. Se separan para poder avisar cuáles no están en la base.
fallo_envio = [t for t in fallidos if t not in set(no_encontrados)]
return {"status": "ok", "enviados": enviados,
"fallidos": fallidos, "invalidos": invalidos,
@@ -291,6 +292,248 @@ def post_plantillas_base(payload: dict = Body(...)):
raise HTTPException(status_code=500, detail=str(e))
# ── SUBIR BASE: etiquetas, asesores y envío masivo con verificación ────────
def _cw_cfg():
import os
return {
"URL": os.getenv("CHATWOOT_URL", "https://gestor.escueladerefrigeracion.edu.pe"),
"TOKEN": os.getenv("CHATWOOT_TOKEN", "4anazHvZnvKLtup8biu5Zuoh"),
"ACCOUNT_ID": os.getenv("CHATWOOT_ACCOUNT_ID", "1"),
"INBOX_ID": int(os.getenv("CHATWOOT_INBOX_ID", "8")),
}
@app.get("/api/subir-base/etiquetas")
def get_subir_base_etiquetas():
"""Lista las etiquetas (labels) de Chatwoot, EN VIVO (sin caché)."""
import requests
c = _cw_cfg()
h = {"api_access_token": c["TOKEN"], "Content-Type": "application/json"}
try:
r = requests.get(f"{c['URL']}/api/v1/accounts/{c['ACCOUNT_ID']}/labels", headers=h, timeout=15)
r.raise_for_status()
data = r.json().get("payload", r.json())
etiquetas = [x.get("title") for x in (data or []) if x.get("title")]
return {"etiquetas": sorted(etiquetas)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/subir-base/asesores")
def get_subir_base_asesores():
"""Lista los asesores (agents) de Chatwoot con su id y nombre, EN VIVO."""
import requests
c = _cw_cfg()
h = {"api_access_token": c["TOKEN"], "Content-Type": "application/json"}
try:
r = requests.get(f"{c['URL']}/api/v1/accounts/{c['ACCOUNT_ID']}/agents", headers=h, timeout=15)
r.raise_for_status()
ags = [{"id": a.get("id"), "nombre": a.get("name") or a.get("email")}
for a in (r.json() or []) if a.get("id")]
ags.sort(key=lambda x: str(x["nombre"]).upper())
return {"asesores": ags}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/subir-base/enviar")
def post_subir_base_enviar(payload: dict = Body(...)):
"""Sube una base de números: crea contacto (o usa el existente), le asigna
asesor y etiquetas, le envía la plantilla, y tras unos segundos verifica el
estado. Si WhatsApp rechazó el número (error 131009 → 'Error al enviar'), BORRA
la conversación y el contacto. Los que se enviaron bien se quedan con todo.
payload: {nombre, categoria, idioma, texto, telefonos:[], etiquetas:[],
asesor_id (int o null para SIN ASIGNACIÓN)}."""
import requests, time
c = _cw_cfg()
URL, ACC, INBOX = c["URL"], c["ACCOUNT_ID"], c["INBOX_ID"]
h = {"api_access_token": c["TOKEN"], "Content-Type": "application/json"}
def ok(s): return s in (200, 201)
nombre = payload.get("nombre", "")
categoria = payload.get("categoria", "MARKETING")
idioma = payload.get("idioma", "es_PE")
texto = payload.get("texto", "")
etiquetas = [str(x).strip() for x in (payload.get("etiquetas") or []) if str(x).strip()]
asesor_id = payload.get("asesor_id") # None/"" = SIN ASIGNACIÓN
tels_raw = payload.get("telefonos", []) or []
def norm(v):
s = str(v or "").strip()
if s.endswith(".0"): s = s[:-2]
for x in ("+", " ", "-", "(", ")"): s = s.replace(x, "")
return s
validos, invalidos = [], []
for t in tels_raw:
s = norm(t)
if s.isdigit() and len(s) >= 8:
validos.append(s)
else:
invalidos.append(str(t))
enviados, borrados, fallo = [], [], []
seguimiento = [] # [{tel, contact_id, conv_id, msg_id, creado_contacto}]
for tel in validos:
tel_p = "+" + tel
# 1) Buscar contacto; si no existe, crearlo
creado = False
contact_id = None
try:
rb = requests.get(f"{URL}/api/v1/accounts/{ACC}/contacts/search",
headers=h, params={"q": tel_p}, timeout=20)
pay = rb.json().get("payload", []) if ok(rb.status_code) else []
if pay:
contact_id = pay[0]["id"]
except Exception:
pass
if not contact_id:
try:
# 1) Crear el contacto (sin inbox todavía).
rc = requests.post(f"{URL}/api/v1/accounts/{ACC}/contacts", headers=h,
json={"name": "Contacto " + tel, "phone_number": tel_p}, timeout=20)
jc = rc.json()
contact_id = (jc.get("payload", {}).get("contact", {}) or {}).get("id") or jc.get("id")
creado = True
print(f"[subir-base] {tel}: POST contacto status={rc.status_code} contact_id={contact_id}")
except Exception as e:
print(f"[subir-base] {tel}: error creando contacto: {e}")
fallo.append(tel); continue
if not contact_id:
fallo.append(tel); continue
# Vincular el contacto al inbox con source_id = número (igual que el SQL que
# inserta en contact_inboxes). Así WhatsApp reconoce el destino. Idempotente.
try:
requests.post(f"{URL}/api/v1/accounts/{ACC}/contacts/{contact_id}/contact_inboxes",
headers=h, json={"inbox_id": INBOX, "source_id": tel}, timeout=20)
except Exception as e:
print(f"[subir-base] {tel}: error vinculando inbox: {e}")
# 2) Conversación (usar la del inbox o crear)
conv_id = None
try:
rconv = requests.get(f"{URL}/api/v1/accounts/{ACC}/contacts/{contact_id}/conversations",
headers=h, timeout=20)
for cv in rconv.json().get("payload", []):
if cv.get("inbox_id") == INBOX:
conv_id = cv["id"]; break
except Exception:
pass
if not conv_id:
try:
# source_id = número de teléfono (así WhatsApp reconoce el destino).
# Usar el contact_id como source_id rompe el envío (131009).
rn = requests.post(f"{URL}/api/v1/accounts/{ACC}/conversations", headers=h,
json={"source_id": tel, "inbox_id": INBOX,
"contact_id": contact_id}, timeout=20)
conv_id = rn.json().get("id") if ok(rn.status_code) else None
print(f"[subir-base] {tel}: POST conv status={rn.status_code} conv_id={conv_id}")
except Exception as e:
print(f"[subir-base] {tel}: error creando conversacion: {e}")
if not conv_id:
if creado:
try: requests.delete(f"{URL}/api/v1/accounts/{ACC}/contacts/{contact_id}", headers=h, timeout=15)
except Exception: pass
fallo.append(tel); continue
# 3) Asignar asesor (si no es SIN ASIGNACIÓN) — DIRECTO en Postgres, para NO
# generar el evento "Asignado a ..." (que dispararía el panel de alertas).
if asesor_id:
try:
import services as _svc
_conn = _svc.get_dm().pg_conn() if hasattr(_svc, "get_dm") else None
except Exception:
_conn = None
if _conn is None:
# fallback: usar psycopg2 directo con los mismos env que el data_manager
try:
import psycopg2, os as _os
_conn = psycopg2.connect(
host=_os.getenv("PG_HOST", "191.98.134.81"),
dbname=_os.getenv("PG_DB", _os.getenv("PG_DATABASE", "chatwoot_production")),
user=_os.getenv("PG_USER", "postgres"),
password=_os.getenv("PG_PASS", _os.getenv("PG_PASSWORD", "")),
port=_os.getenv("PG_PORT", "5432"))
except Exception as e:
_conn = None
print(f"[subir-base] {tel}: sin conexion Postgres para asignar: {e}")
if _conn is not None:
try:
_cur = _conn.cursor()
_cur.execute("UPDATE conversations SET assignee_id=%s, updated_at=NOW() WHERE id=%s",
(int(asesor_id), int(conv_id)))
_conn.commit()
_cur.close(); _conn.close()
except Exception as e:
print(f"[subir-base] {tel}: error asignando en Postgres: {e}")
try: _conn.close()
except Exception: pass
# 4) Etiquetas (todas las elegidas)
if etiquetas:
try:
requests.post(f"{URL}/api/v1/accounts/{ACC}/conversations/{conv_id}/labels",
headers=h, json={"labels": etiquetas}, timeout=15)
except Exception:
pass
# 5) Enviar plantilla
msg_id = None
try:
body = {"content": texto, "private": False,
"template_params": {"name": nombre, "category": categoria,
"language": idioma, "processed_params": {}}}
rm = requests.post(f"{URL}/api/v1/accounts/{ACC}/conversations/{conv_id}/messages",
headers=h, json=body, timeout=30)
if ok(rm.status_code):
msg_id = rm.json().get("id")
print(f"[subir-base] {tel}: POST msg status={rm.status_code} msg_id={msg_id}")
except Exception as e:
print(f"[subir-base] {tel}: error enviando plantilla: {e}")
seguimiento.append({"tel": tel, "contact_id": contact_id, "conv_id": conv_id,
"msg_id": msg_id, "creado": creado})
time.sleep(0.4)
# 6) Esperar a que WhatsApp marque los estados (131009 tarda unos segundos)
time.sleep(22) # dar tiempo a WhatsApp a marcar el estado del mensaje
# 7) Revisar cada mensaje: SOLO borrar si está explícitamente "failed".
# Si no se encuentra el mensaje o el estado no es failed → NO se borra (se queda).
for s in seguimiento:
fallo_wa = False
encontrado = False
try:
rmsgs = requests.get(f"{URL}/api/v1/accounts/{ACC}/conversations/{s['conv_id']}/messages",
headers=h, timeout=20)
for m in rmsgs.json().get("payload", []):
if s["msg_id"] and m.get("id") == s["msg_id"]:
encontrado = True
st = str(m.get("status", "")).lower()
print(f"[subir-base] {s['tel']}: estado mensaje = '{st}'")
if st == "failed":
fallo_wa = True
break
if not encontrado:
print(f"[subir-base] {s['tel']}: mensaje NO encontrado (msg_id={s['msg_id']})")
except Exception as e:
print(f"[subir-base] {s['tel']}: error leyendo estado: {e}")
# Solo se borra si WhatsApp lo marcó como failed de verdad
if fallo_wa:
# Borrar conversación y contacto (número no existe)
try: requests.delete(f"{URL}/api/v1/accounts/{ACC}/conversations/{s['conv_id']}", headers=h, timeout=15)
except Exception: pass
try: requests.delete(f"{URL}/api/v1/accounts/{ACC}/contacts/{s['contact_id']}", headers=h, timeout=15)
except Exception: pass
borrados.append(s["tel"])
else:
enviados.append(s["tel"])
return {"status": "ok", "enviados": enviados, "borrados": borrados,
"fallo": fallo, "invalidos": invalidos, "total": len(tels_raw)}
@app.get("/api/roas")
def get_roas(
ano: str = Query("TODOS"),
@@ -476,4 +719,3 @@ def debug_cursos(ano: str = "2026", mes: str = "1"):
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8001, reload=False)