Commit inicial: Sales Bot - Sistema de Automatización de Ventas

- Stack completo con Mattermost, NocoDB y Sales Bot
- Procesamiento OCR de tickets con Tesseract
- Sistema de comisiones por tubos de tinte
- Comandos slash /metas y /ranking
- Documentación completa del proyecto

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-18 02:41:53 +00:00
commit 5d9cbd4812
21 changed files with 4625 additions and 0 deletions

47
sales-bot/.env.example Normal file
View File

@@ -0,0 +1,47 @@
# ============================================================================
# SALES BOT - VARIABLES DE ENTORNO (EJEMPLO)
# ============================================================================
#
# Copiar este archivo a .env y configurar los valores
#
# === MATTERMOST ===
# URL de tu instancia de Mattermost
MATTERMOST_URL=http://localhost:8065
# Token del bot salesbot (obtener en Integraciones > Bot Accounts)
MATTERMOST_BOT_TOKEN=tu_token_aqui
# Nombre del equipo en Mattermost
MATTERMOST_TEAM_NAME=sales
# Secret del webhook (obtener en Integraciones > Outgoing Webhooks)
MATTERMOST_WEBHOOK_SECRET=tu_secret_aqui
# Incoming webhook para responder en el canal
MATTERMOST_WEBHOOK_URL=http://localhost:8065/hooks/tu_hook_id
# === NOCODB ===
# URL de tu instancia de NocoDB
NOCODB_URL=http://localhost:8080
# Token de API de NocoDB (obtener en Settings > API Tokens)
NOCODB_TOKEN=tu_token_aqui
# IDs de tablas en NocoDB (obtener de la URL al abrir cada tabla)
NOCODB_TABLE_VENDEDORES=tu_table_id
NOCODB_TABLE_VENTAS=tu_table_id
NOCODB_TABLE_VENTAS_DETALLE=tu_table_id
NOCODB_TABLE_METAS=tu_table_id
# === FLASK ===
FLASK_PORT=5000
FLASK_DEBUG=False
# === LOGGING ===
LOG_LEVEL=INFO
LOG_FILE=/app/logs/sales-bot.log
# === ZONA HORARIA ===
# México: -6, Cancún: -5, España: +1
TZ_OFFSET=-6

69
sales-bot/Dockerfile Normal file
View File

@@ -0,0 +1,69 @@
# ============================================================================
# SALES BOT - Dockerfile
# ============================================================================
#
# Imagen base con Python 3.12 y Tesseract OCR
#
FROM python:3.12-slim
# Metadata
LABEL maintainer="sales-bot-team"
LABEL description="Sales Bot - Sistema de ventas con Mattermost y OCR"
LABEL version="1.0.0"
# Variables de entorno
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
DEBIAN_FRONTEND=noninteractive
# Instalar dependencias del sistema
RUN apt-get update && apt-get install -y --no-install-recommends \
# Tesseract OCR
tesseract-ocr \
tesseract-ocr-eng \
tesseract-ocr-spa \
libtesseract-dev \
# Dependencias de procesamiento de imágenes
libsm6 \
libxext6 \
libxrender-dev \
libgomp1 \
libglib2.0-0 \
libgl1 \
# Utilidades
curl \
&& rm -rf /var/lib/apt/lists/*
# Crear usuario no-root
RUN useradd -m -u 1000 -s /bin/bash salesbot
# Establecer directorio de trabajo
WORKDIR /app
# Copiar requirements primero (para aprovechar cache de Docker)
COPY requirements.txt .
# Instalar dependencias Python
RUN pip install --no-cache-dir -r requirements.txt
# Copiar código de la aplicación
COPY --chown=salesbot:salesbot . .
# Crear directorio de logs
RUN mkdir -p /app/logs && chown -R salesbot:salesbot /app/logs
# Cambiar a usuario no-root
USER salesbot
# Exponer puerto
EXPOSE 5000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:5000/health || exit 1
# Comando de inicio
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "1", "--timeout", "60", "--access-logfile", "-", "--error-logfile", "-", "app:app"]

199
sales-bot/README.md Normal file
View File

@@ -0,0 +1,199 @@
# Sales Bot - Aplicación Principal
Bot de automatización de ventas para Mattermost con procesamiento OCR de tickets.
## Arquitectura
```
┌─────────────────────────────────────────────────────────────────┐
│ Sales Bot │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Flask │ │ WebSocket │ │ OCR Processor │ │
│ │ (app.py) │ │ Listener │ │ │ │
│ └──────┬──────┘ └──────┬───────┘ └────────┬─────────┘ │
│ │ │ │ │
│ └──────────────────┼──────────────────────┘ │
│ │ │
│ ┌───────┴───────┐ │
│ │ Handlers │ │
│ │ (handlers.py) │ │
│ └───────┬───────┘ │
│ │ │
│ ┌──────────────────┼──────────────────┐ │
│ │ │ │ │
│ ┌──────┴──────┐ ┌─────┴──────┐ ┌─────┴──────┐ │
│ │ Mattermost │ │ NocoDB │ │ Utils │ │
│ │ Client │ │ Client │ │ │ │
│ └─────────────┘ └────────────┘ └────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
## Módulos
### app.py
Aplicación Flask principal con los siguientes endpoints:
- `GET /health` - Health check
- `POST /webhook/mattermost` - Recibe webhooks de Mattermost
- `POST /webhook/nocodb` - Recibe webhooks de NocoDB
- `POST /comando/metas` - Comando slash /metas
- `POST /comando/ranking` - Comando slash /ranking
### handlers.py
Manejadores de eventos de ventas:
- `handle_venta_message()` - Procesa mensajes de venta
- `generar_reporte_diario()` - Genera reportes diarios
### mattermost_client.py
Cliente para la API de Mattermost:
```python
client = MattermostClient(url, token)
client.test_connection()
client.post_message(channel_id, message)
client.add_reaction(post_id, emoji)
client.get_file(file_id)
```
### nocodb_client.py
Cliente para la API de NocoDB:
```python
client = NocoDBClient(url, token)
client.crear_vendedor(username, nombre, email)
client.registrar_venta(vendedor, monto, cliente, imagen)
client.get_ventas_dia(vendedor, fecha)
client.get_ranking_vendedores(mes)
```
### ocr_processor.py
Procesador OCR para tickets:
```python
processor = OCRProcessor()
resultado = processor.procesar_imagen(imagen_bytes)
# Retorna: monto, fecha, productos, tubos_detectados
```
### websocket_listener.py
Listener para eventos en tiempo real de Mattermost:
```python
listener = MattermostWebsocketListener(url, token, callback)
listener.start() # Inicia en thread separado
```
### utils.py
Funciones de utilidad:
```python
extraer_monto(texto) # "@monto 1500" → 1500.0
extraer_cliente(texto) # "@cliente Juan" → "Juan"
extraer_tubos(texto) # "@tubos 5" → 5
formatear_moneda(1500) # → "$1,500.00"
```
## Instalación con Docker
```bash
docker compose up -d
```
## Instalación Manual
```bash
# Instalar Tesseract
apt-get install tesseract-ocr tesseract-ocr-eng tesseract-ocr-spa
# Instalar dependencias Python
pip install -r requirements.txt
# Ejecutar
python app.py
```
## Variables de Entorno
| Variable | Descripción | Ejemplo |
|----------|-------------|---------|
| MATTERMOST_URL | URL de Mattermost | http://192.168.10.204:8065 |
| MATTERMOST_BOT_TOKEN | Token del bot | xxx |
| MATTERMOST_TEAM_NAME | Nombre del team | sales |
| MATTERMOST_WEBHOOK_SECRET | Secret del webhook | xxx |
| NOCODB_URL | URL de NocoDB | http://192.168.10.204:8080 |
| NOCODB_TOKEN | Token de API | xxx |
| NOCODB_TABLE_* | IDs de tablas | xxx |
| FLASK_PORT | Puerto de Flask | 5000 |
| LOG_LEVEL | Nivel de logging | INFO |
| TZ_OFFSET | Offset de zona horaria | -6 |
## Formato de Mensajes de Venta
El bot reconoce varios formatos:
```
# Formato con @
venta @monto 1500 @cliente Juan @tubos 5
# Formato con etiquetas
venta monto: 1500 cliente: Juan
# Formato natural
venta $1,500 a Juan
```
## Procesamiento OCR
El procesador OCR detecta automáticamente:
- Monto total
- Fecha del ticket
- Lista de productos
- Cantidad de tubos de tinte
### Marcas de Tinte Reconocidas
- Alfaparf Evolution
- Wella Koleston
- Loreal
- Matrix
- Schwarzkopf
- Revlon
- Igora
- Majirel
## Sistema de Comisiones
```python
META_DIARIA_TUBOS = 3
COMISION_POR_TUBO = 10 # $10 MXN
def calcular_comision(tubos_vendidos):
if tubos_vendidos > META_DIARIA_TUBOS:
return (tubos_vendidos - META_DIARIA_TUBOS) * COMISION_POR_TUBO
return 0
```
## Logs
Los logs se escriben en `/app/logs/sales-bot.log` con formato:
```
2024-01-15 10:30:45 INFO [app] Venta registrada: $1,500.00 - Juan - vendedor1
```
## Health Check
```bash
curl http://localhost:5000/health
```
Respuesta:
```json
{
"status": "healthy",
"timestamp": "2024-01-15T10:30:45",
"version": "1.0.0"
}
```

724
sales-bot/app.py Normal file
View File

@@ -0,0 +1,724 @@
from flask import Flask, request, jsonify, render_template_string
import os
import logging
from dotenv import load_dotenv
from datetime import datetime
import json
# Cargar variables de entorno
load_dotenv()
# Importar módulos personalizados
from mattermost_client import MattermostClient
from nocodb_client import NocoDBClient
from handlers import handle_venta_message, generar_reporte_diario
from utils import validar_token_outgoing
from websocket_listener import MattermostWebsocketListener
# Configurar logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
# Inicializar Flask
app = Flask(__name__)
# Inicializar clientes
mattermost = MattermostClient(
url=os.getenv('MATTERMOST_URL'),
token=os.getenv('MATTERMOST_BOT_TOKEN')
)
nocodb = NocoDBClient(
url=os.getenv('NOCODB_URL'),
token=os.getenv('NOCODB_TOKEN')
)
# Inicializar websocket listener
ws_listener = MattermostWebsocketListener(mattermost, nocodb, handle_venta_message)
ws_listener.start()
logger.info("Websocket listener iniciado para escuchar mensajes de Mattermost")
@app.route('/health', methods=['GET'])
def health_check():
"""Endpoint para verificar que el bot está funcionando"""
return jsonify({
'status': 'healthy',
'timestamp': datetime.now().isoformat(),
'version': '1.0.0'
}), 200
@app.route('/webhook/mattermost', methods=['POST'])
def mattermost_webhook():
"""
Recibe webhooks salientes de Mattermost cuando hay mensajes en el canal de ventas
"""
try:
data = request.json
logger.info(f"Webhook recibido: {json.dumps(data, indent=2)}")
# Validar token
token = data.get('token')
if not validar_token_outgoing(token):
logger.warning(f"Token inválido: {token}")
return jsonify({'error': 'Token inválido'}), 403
# Ignorar mensajes del propio bot
if data.get('user_name') == 'sales-bot':
return jsonify({'status': 'ignored', 'reason': 'bot message'}), 200
# Obtener información del mensaje
channel_name = data.get('channel_name')
user_name = data.get('user_name')
text = data.get('text', '').strip()
logger.info(f"Procesando mensaje de {user_name} en #{channel_name}: {text}")
# Verificar si es un mensaje de venta
palabras_clave = ['venta', 'vendi', 'vendí', 'cliente', 'ticket']
es_venta = any(palabra in text.lower() for palabra in palabras_clave)
if es_venta or data.get('file_ids'):
respuesta = handle_venta_message(data, mattermost, nocodb)
return jsonify(respuesta), 200
return jsonify({'status': 'ok', 'message': 'Mensaje procesado'}), 200
except Exception as e:
logger.error(f"Error procesando webhook: {str(e)}", exc_info=True)
return jsonify({'error': 'Error interno del servidor'}), 500
@app.route('/webhook/nocodb', methods=['POST'])
def nocodb_webhook():
"""
Recibe webhooks de NocoDB cuando se insertan/actualizan datos
"""
try:
data = request.json
logger.info(f"Webhook NocoDB recibido: {json.dumps(data, indent=2)}")
# Aquí puedes agregar lógica para notificar en Mattermost
# cuando se actualicen datos en NocoDB
return jsonify({'status': 'ok'}), 200
except Exception as e:
logger.error(f"Error procesando webhook NocoDB: {str(e)}", exc_info=True)
return jsonify({'error': 'Error interno del servidor'}), 500
@app.route('/comando/metas', methods=['POST'])
def comando_metas():
"""
Endpoint para el comando slash /metas en Mattermost
"""
try:
data = request.form.to_dict()
logger.info(f"Comando /metas recibido de {data.get('user_name')}")
# Validar token
token = data.get('token')
expected_tokens = [
os.getenv('MATTERMOST_SLASH_TOKEN_METAS'),
os.getenv('MATTERMOST_OUTGOING_TOKEN')
]
if token not in expected_tokens:
return jsonify({'text': 'Token inválido'}), 403
user_name = data.get('user_name')
# Obtener meta del vendedor
meta = nocodb.get_meta_vendedor(user_name)
if not meta:
mensaje = (
f"@{user_name} Aún no tienes ventas registradas este mes.\n"
"¡Empieza a vender y registra tus ventas!"
)
else:
porcentaje = meta.get('porcentaje_completado', 0)
total_vendido = meta.get('total_vendido', 0)
meta_establecida = meta.get('meta_establecida', 0)
ventas_count = meta.get('ventas_realizadas', 0)
falta = meta_establecida - total_vendido
# Barra de progreso visual
barra_length = 20
completado = int((porcentaje / 100) * barra_length)
barra = '' * completado + '' * (barra_length - completado)
mensaje = (
f"📊 **Reporte de {user_name}**\n\n"
f"`{barra}` {porcentaje:.1f}%\n\n"
f"**Total vendido:** ${total_vendido:,.2f} MXN\n"
f"**Meta mensual:** ${meta_establecida:,.2f} MXN\n"
f"**Falta:** ${falta:,.2f} MXN\n"
f"**Ventas realizadas:** {ventas_count}\n"
)
if porcentaje >= 100:
mensaje += "\n🎉 **¡Felicidades! Meta completada**"
elif porcentaje >= 75:
mensaje += f"\n🔥 **¡Casi llegas! Solo faltan ${falta:,.2f}**"
return jsonify({
'response_type': 'ephemeral',
'text': mensaje
}), 200
except Exception as e:
logger.error(f"Error procesando comando /metas: {str(e)}", exc_info=True)
return jsonify({
'text': f'❌ Error procesando comando: {str(e)}'
}), 500
@app.route('/comando/ranking', methods=['POST'])
def comando_ranking():
"""
Endpoint para el comando slash /ranking en Mattermost
"""
try:
data = request.form.to_dict()
logger.info(f"Comando /ranking recibido de {data.get('user_name')}")
# Validar token
token = data.get('token')
expected_tokens = [
os.getenv('MATTERMOST_SLASH_TOKEN_RANKING'),
os.getenv('MATTERMOST_OUTGOING_TOKEN')
]
if token not in expected_tokens:
return jsonify({'text': 'Token inválido'}), 403
# Obtener ranking
ranking = nocodb.get_ranking_vendedores()
if not ranking:
mensaje = "No hay datos de ventas este mes."
else:
mensaje = "🏆 **Ranking de Vendedores - Mes Actual**\n\n"
for i, vendedor in enumerate(ranking[:10], 1):
username = vendedor.get('vendedor_username')
total = vendedor.get('total_vendido', 0)
porcentaje = vendedor.get('porcentaje_completado', 0)
ventas = vendedor.get('ventas_realizadas', 0)
# Medallas para top 3
if i == 1:
emoji = '🥇'
elif i == 2:
emoji = '🥈'
elif i == 3:
emoji = '🥉'
else:
emoji = f'{i}.'
mensaje += (
f"{emoji} **@{username}**\n"
f" └ ${total:,.2f} MXN ({porcentaje:.1f}%) - {ventas} ventas\n"
)
return jsonify({
'response_type': 'in_channel',
'text': mensaje
}), 200
except Exception as e:
logger.error(f"Error procesando comando /ranking: {str(e)}", exc_info=True)
return jsonify({
'text': f'❌ Error procesando comando: {str(e)}'
}), 500
@app.route('/comando/ayuda', methods=['POST'])
def comando_ayuda():
"""
Endpoint para el comando slash /ayuda en Mattermost
"""
try:
data = request.form.to_dict()
logger.info(f"Comando /ayuda recibido de {data.get('user_name')}")
# Validar token
token = data.get('token')
expected_tokens = [
os.getenv('MATTERMOST_SLASH_TOKEN_AYUDA'),
os.getenv('MATTERMOST_OUTGOING_TOKEN')
]
if token not in expected_tokens:
return jsonify({'text': 'Token inválido'}), 403
mensaje = (
"🤖 **Bot de Ventas - Guía de Uso**\n\n"
"**Para registrar una venta:**\n"
"• `venta @monto 1500 @cliente Juan Pérez`\n"
"• `vendí $1500 a María García`\n"
"• También puedes adjuntar foto del ticket\n\n"
"**Comandos disponibles:**\n"
"• `/metas` - Ver tu progreso del mes\n"
"• `/ranking` - Ver ranking de vendedores\n"
"• `/ayuda` - Mostrar esta ayuda\n\n"
"**Ejemplos de registro de ventas:**\n"
"✅ `venta @monto 2500 @cliente Empresa ABC`\n"
"✅ `vendí $1,200.50 a cliente Pedro`\n"
"✅ `venta @monto 5000 @cliente Tienda XYZ`\n\n"
"**Consejos:**\n"
"• Registra tus ventas inmediatamente después de cerrarlas\n"
"• Incluye el nombre del cliente para mejor seguimiento\n"
"• Revisa tu progreso regularmente con `/metas`\n"
"• Compite sanamente con tus compañeros en el `/ranking`\n\n"
"¡Sigue adelante! 💪"
)
return jsonify({
'response_type': 'ephemeral',
'text': mensaje
}), 200
except Exception as e:
logger.error(f"Error procesando comando /ayuda: {str(e)}", exc_info=True)
return jsonify({
'text': f'❌ Error procesando comando: {str(e)}'
}), 500
@app.route('/reporte/diario', methods=['POST'])
def reporte_diario_manual():
"""
Endpoint para generar reporte diario manualmente
"""
try:
resultado = generar_reporte_diario(mattermost, nocodb)
return jsonify(resultado), 200
except Exception as e:
logger.error(f"Error generando reporte: {str(e)}", exc_info=True)
return jsonify({'error': str(e)}), 500
@app.route('/test/mattermost', methods=['GET'])
def test_mattermost():
"""Test de conexión con Mattermost"""
try:
resultado = mattermost.test_connection()
return jsonify(resultado), 200
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/test/nocodb', methods=['GET'])
def test_nocodb():
"""Test de conexión con NocoDB"""
try:
resultado = nocodb.test_connection()
return jsonify(resultado), 200
except Exception as e:
return jsonify({'error': str(e)}), 500
# ============== DASHBOARD ==============
@app.route('/api/dashboard/resumen', methods=['GET'])
def api_dashboard_resumen():
"""API: Resumen general del día y mes"""
try:
from datetime import datetime, timedelta, timezone
TZ_MEXICO = timezone(timedelta(hours=-6))
hoy = datetime.now(TZ_MEXICO).strftime('%Y-%m-%d')
mes = datetime.now(TZ_MEXICO).strftime('%Y-%m')
# Ventas del día
ventas_hoy = nocodb.get_ventas_dia()
total_hoy = sum(float(v.get('monto', 0)) for v in ventas_hoy)
# Ventas del mes
ventas_mes = nocodb.get_ventas_mes()
total_mes = sum(float(v.get('monto', 0)) for v in ventas_mes)
# Contar vendedores activos hoy
vendedores_hoy = set(v.get('vendedor_username') for v in ventas_hoy)
return jsonify({
'fecha': hoy,
'mes': mes,
'ventas_hoy': len(ventas_hoy),
'monto_hoy': total_hoy,
'ventas_mes': len(ventas_mes),
'monto_mes': total_mes,
'vendedores_activos_hoy': len(vendedores_hoy)
}), 200
except Exception as e:
logger.error(f"Error en API resumen: {str(e)}")
return jsonify({'error': str(e)}), 500
@app.route('/api/dashboard/ranking', methods=['GET'])
def api_dashboard_ranking():
"""API: Ranking de vendedores del mes"""
try:
ranking = nocodb.get_ranking_vendedores()
return jsonify(ranking), 200
except Exception as e:
logger.error(f"Error en API ranking: {str(e)}")
return jsonify({'error': str(e)}), 500
@app.route('/api/dashboard/ventas-recientes', methods=['GET'])
def api_dashboard_ventas_recientes():
"""API: Últimas ventas registradas con nombres de vendedores"""
try:
import requests
ventas = nocodb.get_ventas_dia()
# Obtener lista de vendedores para mapear usernames a nombres
vendedores_response = requests.get(
f"{nocodb.url}/api/v2/tables/{nocodb.table_vendedores}/records",
headers=nocodb.headers,
params={'limit': 100},
timeout=10
)
vendedores_response.raise_for_status()
vendedores = vendedores_response.json().get('list', [])
# Crear mapa de username -> nombre_completo
nombres_map = {v.get('username'): v.get('nombre_completo', v.get('username')) for v in vendedores}
# Agregar nombre_completo a cada venta
for venta in ventas:
username = venta.get('vendedor_username', '')
venta['nombre_completo'] = nombres_map.get(username, username)
# Ordenar por fecha descendente y tomar las últimas 20
ventas_ordenadas = sorted(ventas, key=lambda x: x.get('fecha_venta', ''), reverse=True)[:20]
return jsonify(ventas_ordenadas), 200
except Exception as e:
logger.error(f"Error en API ventas recientes: {str(e)}")
return jsonify({'error': str(e)}), 500
@app.route('/api/dashboard/metas', methods=['GET'])
def api_dashboard_metas():
"""API: Estado de metas de todos los vendedores"""
try:
import requests
response = requests.get(
f"{nocodb.url}/api/v2/tables/{nocodb.table_metas}/records",
headers=nocodb.headers,
params={'limit': 100},
timeout=10
)
response.raise_for_status()
metas = response.json().get('list', [])
return jsonify(metas), 200
except Exception as e:
logger.error(f"Error en API metas: {str(e)}")
return jsonify({'error': str(e)}), 500
@app.route('/dashboard')
def dashboard():
"""Dashboard principal de ventas"""
html = '''
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sales Bot - Dashboard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
color: #fff;
}
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
h1 { font-size: 28px; font-weight: 600; }
h1 span { color: #00d4ff; }
.fecha { color: #888; font-size: 14px; }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: rgba(255,255,255,0.05);
border-radius: 16px;
padding: 24px;
border: 1px solid rgba(255,255,255,0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 40px rgba(0,212,255,0.1);
}
.stat-card .label { color: #888; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
.stat-card .value { font-size: 32px; font-weight: 700; color: #00d4ff; }
.stat-card .subvalue { font-size: 14px; color: #666; margin-top: 4px; }
.stat-card.green .value { color: #00ff88; }
.stat-card.orange .value { color: #ffaa00; }
.stat-card.purple .value { color: #aa00ff; }
.main-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
@media (max-width: 900px) { .main-grid { grid-template-columns: 1fr; } }
.panel {
background: rgba(255,255,255,0.05);
border-radius: 16px;
padding: 24px;
border: 1px solid rgba(255,255,255,0.1);
}
.panel h2 {
font-size: 18px;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.panel h2 .icon { font-size: 24px; }
.ranking-list { list-style: none; }
.ranking-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.ranking-item:last-child { border-bottom: none; }
.ranking-position {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14px;
margin-right: 12px;
}
.ranking-position.gold { background: linear-gradient(135deg, #ffd700, #ffaa00); color: #000; }
.ranking-position.silver { background: linear-gradient(135deg, #c0c0c0, #888); color: #000; }
.ranking-position.bronze { background: linear-gradient(135deg, #cd7f32, #8b4513); color: #fff; }
.ranking-position.default { background: rgba(255,255,255,0.1); color: #888; }
.ranking-info { flex: 1; }
.ranking-name { font-weight: 600; margin-bottom: 2px; }
.ranking-stats { font-size: 12px; color: #888; }
.ranking-value { text-align: right; }
.ranking-tubos { font-size: 24px; font-weight: 700; color: #00d4ff; }
.ranking-comision { font-size: 12px; color: #00ff88; }
.ventas-list { max-height: 400px; overflow-y: auto; }
.venta-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
margin-bottom: 8px;
background: rgba(255,255,255,0.03);
border-radius: 8px;
}
.venta-info .vendedor { font-weight: 600; color: #00d4ff; }
.venta-info .cliente { font-size: 12px; color: #888; }
.venta-monto { font-size: 18px; font-weight: 700; color: #00ff88; }
.refresh-btn {
background: rgba(0,212,255,0.2);
border: 1px solid #00d4ff;
color: #00d4ff;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.refresh-btn:hover { background: rgba(0,212,255,0.3); }
.loading { text-align: center; padding: 40px; color: #888; }
.meta-progress {
height: 8px;
background: rgba(255,255,255,0.1);
border-radius: 4px;
overflow: hidden;
margin-top: 8px;
}
.meta-progress-bar {
height: 100%;
background: linear-gradient(90deg, #00d4ff, #00ff88);
border-radius: 4px;
transition: width 0.5s;
}
</style>
</head>
<body>
<div class="container">
<header>
<div>
<h1><span>Sales</span> Bot Dashboard</h1>
<p class="fecha" id="fecha-actual"></p>
</div>
<button class="refresh-btn" onclick="cargarDatos()">🔄 Actualizar</button>
</header>
<div class="stats-grid">
<div class="stat-card">
<div class="label">Ventas Hoy</div>
<div class="value" id="ventas-hoy">-</div>
<div class="subvalue" id="monto-hoy">$0.00</div>
</div>
<div class="stat-card green">
<div class="label">Ventas del Mes</div>
<div class="value" id="ventas-mes">-</div>
<div class="subvalue" id="monto-mes">$0.00</div>
</div>
<div class="stat-card orange">
<div class="label">Vendedores Activos Hoy</div>
<div class="value" id="vendedores-activos">-</div>
</div>
<div class="stat-card purple">
<div class="label">Meta Diaria</div>
<div class="value">3</div>
<div class="subvalue">tubos por vendedor</div>
</div>
</div>
<div class="main-grid">
<div class="panel">
<h2><span class="icon">🏆</span> Ranking del Mes (Tubos)</h2>
<ul class="ranking-list" id="ranking-list">
<li class="loading">Cargando...</li>
</ul>
</div>
<div class="panel">
<h2><span class="icon">📋</span> Ventas Recientes</h2>
<div class="ventas-list" id="ventas-list">
<div class="loading">Cargando...</div>
</div>
</div>
</div>
</div>
<script>
function formatMoney(amount) {
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(amount);
}
function formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' });
}
async function cargarResumen() {
try {
const res = await fetch('/api/dashboard/resumen');
const data = await res.json();
document.getElementById('ventas-hoy').textContent = data.ventas_hoy || 0;
document.getElementById('monto-hoy').textContent = formatMoney(data.monto_hoy || 0);
document.getElementById('ventas-mes').textContent = data.ventas_mes || 0;
document.getElementById('monto-mes').textContent = formatMoney(data.monto_mes || 0);
document.getElementById('vendedores-activos').textContent = data.vendedores_activos_hoy || 0;
document.getElementById('fecha-actual').textContent = new Date().toLocaleDateString('es-MX', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
});
} catch (e) {
console.error('Error cargando resumen:', e);
}
}
async function cargarRanking() {
try {
const res = await fetch('/api/dashboard/ranking');
const data = await res.json();
const lista = document.getElementById('ranking-list');
if (!data || data.length === 0) {
lista.innerHTML = '<li class="loading">No hay datos de ventas</li>';
return;
}
lista.innerHTML = data.slice(0, 10).map((v, i) => {
const posClass = i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : 'default';
const tubos = v.tubos_totales || 0;
const comision = v.comision_total || 0;
const ventas = v.cantidad_ventas || 0;
const nombre = v.nombre_completo || v.vendedor_username || v.vendedor;
const username = v.vendedor_username || v.vendedor;
return `
<li class="ranking-item">
<div class="ranking-position ${posClass}">${i + 1}</div>
<div class="ranking-info">
<div class="ranking-name">${nombre}</div>
<div class="ranking-stats">@${username} • ${ventas} ventas • ${v.dias_activos || 0} días activos</div>
</div>
<div class="ranking-value">
<div class="ranking-tubos">${tubos} 🧪</div>
${comision > 0 ? `<div class="ranking-comision">+${formatMoney(comision)}</div>` : ''}
</div>
</li>
`;
}).join('');
} catch (e) {
console.error('Error cargando ranking:', e);
}
}
async function cargarVentasRecientes() {
try {
const res = await fetch('/api/dashboard/ventas-recientes');
const data = await res.json();
const lista = document.getElementById('ventas-list');
if (!data || data.length === 0) {
lista.innerHTML = '<div class="loading">No hay ventas hoy</div>';
return;
}
lista.innerHTML = data.map(v => {
const nombre = v.nombre_completo || v.vendedor_username;
return `
<div class="venta-item">
<div class="venta-info">
<div class="vendedor">${nombre}</div>
<div class="cliente">${v.cliente || 'Sin cliente'} • ${formatDate(v.fecha_venta)}</div>
</div>
<div class="venta-monto">${formatMoney(v.monto || 0)}</div>
</div>
`}).join('');
} catch (e) {
console.error('Error cargando ventas:', e);
}
}
function cargarDatos() {
cargarResumen();
cargarRanking();
cargarVentasRecientes();
}
// Cargar datos al inicio
cargarDatos();
// Actualizar cada 30 segundos
setInterval(cargarDatos, 30000);
</script>
</body>
</html>
'''
return render_template_string(html)
if __name__ == '__main__':
port = int(os.getenv('FLASK_PORT', 5000))
host = os.getenv('FLASK_HOST', '0.0.0.0')
debug = os.getenv('DEBUG', 'False').lower() == 'true'
logger.info(f"Iniciando Sales Bot en {host}:{port}")
app.run(host=host, port=port, debug=debug)

85
sales-bot/compose.yaml Normal file
View File

@@ -0,0 +1,85 @@
version: '3.8'
# ============================================================================
# SALES BOT - Docker Compose
# ============================================================================
#
# Servicio: Sales Bot (Bot de ventas con OCR)
# Requiere: Mattermost y NocoDB corriendo
# Puerto: 5000
#
# Uso:
# 1. Editar archivo .env con tus credenciales
# 2. docker-compose build
# 3. docker-compose up -d
# Acceder: http://your-server-ip:5000/health
#
services:
sales-bot:
build:
context: .
dockerfile: Dockerfile
container_name: sales-bot
restart: unless-stopped
ports:
- "5000:5000"
environment:
# === MATTERMOST ===
MATTERMOST_URL: ${MATTERMOST_URL:-http://host.docker.internal:8065}
MATTERMOST_BOT_TOKEN: ${MATTERMOST_BOT_TOKEN}
MATTERMOST_TEAM_NAME: ${MATTERMOST_TEAM_NAME:-sales}
MATTERMOST_WEBHOOK_SECRET: ${MATTERMOST_WEBHOOK_SECRET}
MATTERMOST_WEBHOOK_URL: ${MATTERMOST_WEBHOOK_URL}
# === NOCODB ===
NOCODB_URL: ${NOCODB_URL:-http://host.docker.internal:8080}
NOCODB_TOKEN: ${NOCODB_TOKEN}
NOCODB_TABLE_VENDEDORES: ${NOCODB_TABLE_VENDEDORES}
NOCODB_TABLE_VENTAS: ${NOCODB_TABLE_VENTAS}
NOCODB_TABLE_VENTAS_DETALLE: ${NOCODB_TABLE_VENTAS_DETALLE}
NOCODB_TABLE_METAS: ${NOCODB_TABLE_METAS}
# === FLASK ===
FLASK_PORT: 5000
FLASK_DEBUG: "False"
# === LOGGING ===
LOG_LEVEL: INFO
LOG_FILE: /app/logs/sales-bot.log
# === ZONA HORARIA ===
TZ: America/Mexico_City
TZ_OFFSET: "-6"
volumes:
# Montar logs para persistencia
- ./logs:/app/logs
# Si quieres desarrollo en caliente, descomentar:
# - ./app.py:/app/app.py
# - ./nocodb_client.py:/app/nocodb_client.py
# - ./mattermost_client.py:/app/mattermost_client.py
# - ./ocr_processor.py:/app/ocr_processor.py
# - ./handlers.py:/app/handlers.py
networks:
- sales-bot-network
# Extra hosts para conectar con servicios del host
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
networks:
sales-bot-network:
driver: bridge
volumes:
logs:

376
sales-bot/handlers.py Normal file
View File

@@ -0,0 +1,376 @@
import logging
import re
from datetime import datetime
from utils import extraer_monto, extraer_cliente, formatear_moneda, extraer_tubos
from ocr_processor import OCRProcessor
import os
logger = logging.getLogger(__name__)
def handle_venta_message(data, mattermost, nocodb):
"""
Maneja mensajes de venta en Mattermost
NUEVO: Sistema de comisiones por tubos vendidos
"""
try:
user_name = data.get('user_name')
text = data.get('text', '').strip()
channel_name = data.get('channel_name')
post_id = data.get('post_id')
file_ids = data.get('file_ids', '')
if file_ids and isinstance(file_ids, str):
file_ids = [f.strip() for f in file_ids.split(',') if f.strip()]
elif not file_ids:
file_ids = []
logger.info(f"Procesando venta de {user_name}: {text}, archivos: {file_ids}")
# Extraer información del texto
monto = extraer_monto(text)
cliente = extraer_cliente(text)
tubos_manual = extraer_tubos(text) # NUEVO: tubos manuales
# Procesar imágenes adjuntas
imagen_url = None
ocr_info = ""
productos_ocr = []
if file_ids:
logger.info(f"Procesando {len(file_ids)} archivos adjuntos")
file_id = file_ids[0]
imagen_data = mattermost.get_file(file_id)
file_info = mattermost.get_file_info(file_id)
if file_info and imagen_data:
filename = file_info.get('name', 'ticket.jpg')
file_size = file_info.get('size', 0)
bot_token = os.getenv('MATTERMOST_BOT_TOKEN')
mattermost_url = os.getenv('MATTERMOST_URL')
imagen_url = f"{mattermost_url}/api/v4/files/{file_id}?access_token={bot_token}"
logger.info(f"Archivo adjunto: {filename}, tamaño: {file_size} bytes")
# Procesar con OCR
try:
ocr = OCRProcessor()
resultado_ocr = ocr.procesar_ticket(imagen_data)
if resultado_ocr:
monto_ocr = resultado_ocr.get('monto_detectado')
fecha_ocr = resultado_ocr.get('fecha_detectada')
productos_ocr = resultado_ocr.get('productos', [])
if not monto and monto_ocr:
monto = monto_ocr
logger.info(f"Usando monto detectado por OCR: ${monto}")
ocr_info += f"\n💡 Monto detectado: ${monto:,.2f}"
elif monto and monto_ocr:
es_valido, mensaje = ocr.validar_monto_con_ocr(monto, monto_ocr, tolerancia=0.05)
ocr_info += f"\n{mensaje}"
if not es_valido:
logger.warning(mensaje)
if fecha_ocr:
ocr_info += f"\n📅 Fecha: {fecha_ocr}"
if productos_ocr:
# NUEVO: Contar tubos de tinte
tubos_tinte = sum(
p['cantidad'] for p in productos_ocr
if 'tinte' in p['marca'].lower() or 'tinte' in p['producto'].lower()
or 'cromatique' in p['marca'].lower()
)
ocr_info += f"\n🧪 Tubos de tinte: {tubos_tinte}"
ocr_info += f"\n📦 Total productos: {len(productos_ocr)}"
logger.info(f"Tubos de tinte detectados: {tubos_tinte}")
except Exception as ocr_error:
logger.error(f"Error en OCR: {str(ocr_error)}")
ocr_info = "\n⚠️ No se pudo leer el ticket"
productos_ocr = []
logger.info(f"URL de imagen: {imagen_url}")
if not monto:
mensaje = (
f"@{user_name} Necesito el monto de la venta.\n"
"**Formatos válidos:**\n"
"• `venta @monto 1500 @cliente Juan Pérez`\n"
"• `vendí $1500 a Juan Pérez`\n"
"• Adjunta foto del ticket"
)
mattermost.post_message_webhook(mensaje, username='Sales Bot', icon_emoji=':moneybag:')
return {'text': mensaje}
if not cliente:
cliente = "Cliente sin nombre"
# Verificar/crear vendedor
vendedor = nocodb.get_vendedor(user_name)
if not vendedor:
user_info = mattermost.get_user_by_username(user_name)
email = user_info.get('email', f"{user_name}@consultoria-as.com") if user_info else f"{user_name}@consultoria-as.com"
nombre = user_info.get('first_name', user_name) if user_info else user_name
vendedor = nocodb.crear_vendedor(
username=user_name,
nombre_completo=nombre,
email=email,
meta_diaria_tubos=3 # NUEVO: Meta de 3 tubos diarios
)
if vendedor:
mensaje_bienvenida = (
f"👋 ¡Bienvenido @{user_name}!\n"
f"**Sistema de comisiones:**\n"
f"• Meta diaria: {nocodb.META_DIARIA_TUBOS} tubos de tinte\n"
f"• Comisión: ${nocodb.COMISION_POR_TUBO:.0f} por tubo después del {nocodb.META_DIARIA_TUBOS}º\n"
f"¡Empieza a registrar tus ventas!"
)
mattermost.post_message_webhook(mensaje_bienvenida, username='Sales Bot', icon_emoji=':wave:')
# Registrar venta
venta = nocodb.registrar_venta(
vendedor_username=user_name,
monto=monto,
cliente=cliente,
descripcion=text,
mensaje_id=post_id,
canal=channel_name,
imagen_url=imagen_url
)
if venta:
venta_id = venta.get('Id')
# Guardar productos detectados por OCR
if productos_ocr:
productos_guardados = nocodb.guardar_productos_venta(venta_id, productos_ocr)
if productos_guardados:
logger.info(f"Guardados {len(productos_guardados)} productos para venta {venta_id}")
# NUEVO: Guardar tubos manuales si se especificaron
elif tubos_manual and tubos_manual > 0:
productos_manuales = [{
'producto': 'Tinte (registro manual)',
'marca': 'Manual',
'cantidad': tubos_manual,
'precio_unitario': monto / tubos_manual if tubos_manual > 0 else 0,
'importe': monto
}]
productos_guardados = nocodb.guardar_productos_venta(venta_id, productos_manuales)
if productos_guardados:
logger.info(f"Guardados {tubos_manual} tubos manuales para venta {venta_id}")
# NUEVO: Actualizar tabla de metas
try:
nocodb.actualizar_meta_vendedor(user_name)
logger.info(f"Metas actualizadas para {user_name}")
except Exception as meta_error:
logger.error(f"Error actualizando metas: {str(meta_error)}")
# Reacción de éxito
if post_id:
mattermost.add_reaction(post_id, 'white_check_mark')
# NUEVO: Obtener estadísticas del día
stats_dia = nocodb.get_estadisticas_vendedor_dia(user_name)
# Construir mensaje
mensaje_confirmacion = (
f"✅ **Venta registrada**\n\n"
f"**Vendedor:** @{user_name}\n"
f"**Monto:** {formatear_moneda(monto)}\n"
f"**Cliente:** {cliente}\n"
)
if imagen_url:
mensaje_confirmacion += f"📸 **Ticket:** Guardado{ocr_info}\n"
# NUEVO: Mostrar estadísticas de tubos y comisiones
if stats_dia:
tubos_hoy = stats_dia.get('tubos_vendidos', 0)
comision_hoy = stats_dia.get('comision', 0)
meta = stats_dia.get('meta_diaria', 3)
tubos_comisionables = stats_dia.get('tubos_comisionables', 0)
# Determinar emoji según progreso
if tubos_hoy >= meta * 2:
emoji = '🔥'
mensaje_extra = '¡Increíble día!'
elif tubos_hoy >= meta:
emoji = ''
mensaje_extra = '¡Meta cumplida!'
elif tubos_hoy >= meta - 1:
emoji = '💪'
mensaje_extra = '¡Casi llegas!'
else:
emoji = '📊'
mensaje_extra = '¡Sigue así!'
mensaje_confirmacion += (
f"\n**Resumen del día:** {emoji}\n"
f"• Tubos vendidos hoy: {tubos_hoy} 🧪\n"
f"• Meta diaria: {meta} tubos\n"
)
if tubos_hoy > meta:
mensaje_confirmacion += (
f"• Tubos con comisión: {tubos_comisionables}\n"
f"• Comisión ganada hoy: {formatear_moneda(comision_hoy)} 💰\n"
)
else:
faltan = meta - tubos_hoy
mensaje_confirmacion += f"• Faltan {faltan} tubos para comisión\n"
mensaje_confirmacion += f"{mensaje_extra}"
# Enviar confirmación
mattermost.post_message_webhook(
mensaje_confirmacion,
username='Sales Bot',
icon_emoji=':moneybag:'
)
return {'text': mensaje_confirmacion}
else:
mensaje_error = f"❌ Error al registrar la venta. Intenta de nuevo."
mattermost.post_message_webhook(mensaje_error, username='Sales Bot', icon_emoji=':x:')
return {'text': mensaje_error}
except Exception as e:
logger.error(f"Error en handle_venta_message: {str(e)}", exc_info=True)
mensaje_error = f"❌ Error: {str(e)}"
return {'text': mensaje_error}
def generar_reporte_diario(mattermost, nocodb):
"""
Genera reporte diario de ventas y comisiones
NUEVO: Muestra tubos vendidos y comisiones ganadas
"""
try:
import os
hoy = datetime.now().strftime('%Y-%m-%d')
mes_actual = datetime.now().strftime('%Y-%m')
# Obtener todas las ventas del día
ventas_hoy = nocodb.get_ventas_dia()
# Agrupar por vendedor
vendedores_hoy = {}
for venta in ventas_hoy:
vendedor = venta.get('vendedor_username')
if vendedor not in vendedores_hoy:
vendedores_hoy[vendedor] = []
vendedores_hoy[vendedor].append(venta)
# Calcular estadísticas por vendedor
stats_vendedores = []
for vendedor in vendedores_hoy.keys():
stats = nocodb.get_estadisticas_vendedor_dia(vendedor, hoy)
if stats:
stats_vendedores.append(stats)
# Ordenar por tubos vendidos
stats_vendedores.sort(key=lambda x: x.get('tubos_vendidos', 0), reverse=True)
# Calcular totales
total_tubos_dia = sum(s.get('tubos_vendidos', 0) for s in stats_vendedores)
total_comisiones = sum(s.get('comision', 0) for s in stats_vendedores)
total_monto = sum(s.get('monto_total_dia', 0) for s in stats_vendedores)
# Construir mensaje
mensaje = (
f"📊 **Reporte Diario - {datetime.now().strftime('%d/%m/%Y')}**\n\n"
f"**Resumen del día:**\n"
f"• Tubos vendidos: {total_tubos_dia} 🧪\n"
f"• Comisiones pagadas: {formatear_moneda(total_comisiones)} 💰\n"
f"• Monto total: {formatear_moneda(total_monto)}\n"
f"• Ventas: {len(ventas_hoy)}\n\n"
)
if stats_vendedores:
mensaje += "**Top Vendedores del Día:**\n"
for i, stats in enumerate(stats_vendedores[:5], 1):
vendedor = stats.get('vendedor')
tubos = stats.get('tubos_vendidos', 0)
comision = stats.get('comision', 0)
emoji = '🥇' if i == 1 else '🥈' if i == 2 else '🥉' if i == 3 else '🏅'
if comision > 0:
mensaje += f"{emoji} @{vendedor} - {tubos} tubos ({formatear_moneda(comision)} comisión)\n"
else:
mensaje += f"{emoji} @{vendedor} - {tubos} tubos\n"
# Obtener canal de reportes
team_name = os.getenv('MATTERMOST_TEAM_NAME')
channel_reportes = os.getenv('MATTERMOST_CHANNEL_REPORTES')
canal = mattermost.get_channel_by_name(team_name, channel_reportes)
if canal:
mattermost.post_message(canal['id'], mensaje)
logger.info("Reporte diario generado")
return {'status': 'success', 'message': 'Reporte generado'}
else:
logger.warning(f"Canal {channel_reportes} no encontrado")
return {'status': 'error', 'message': 'Canal no encontrado'}
except Exception as e:
logger.error(f"Error generando reporte diario: {str(e)}", exc_info=True)
return {'status': 'error', 'message': str(e)}
def comando_estadisticas(user_name, mattermost, nocodb):
"""
Muestra estadísticas personales del vendedor
Comando: /stats o /estadisticas
"""
try:
# Estadísticas del día
stats_hoy = nocodb.get_estadisticas_vendedor_dia(user_name)
# Estadísticas del mes
stats_mes = nocodb.get_estadisticas_vendedor_mes(user_name)
if not stats_hoy and not stats_mes:
mensaje = f"@{user_name} Aún no tienes ventas registradas."
return mensaje
mensaje = f"📈 **Estadísticas de @{user_name}**\n\n"
# Hoy
if stats_hoy:
mensaje += (
f"**Hoy ({datetime.now().strftime('%d/%m')})**\n"
f"• Tubos: {stats_hoy.get('tubos_vendidos', 0)} 🧪\n"
f"• Comisión: {formatear_moneda(stats_hoy.get('comision', 0))}\n"
f"• Monto: {formatear_moneda(stats_hoy.get('monto_total_dia', 0))}\n"
f"• Ventas: {stats_hoy.get('cantidad_ventas', 0)}\n\n"
)
# Mes
if stats_mes:
mensaje += (
f"**Este mes**\n"
f"• Tubos totales: {stats_mes.get('tubos_totales', 0)} 🧪\n"
f"• Comisión total: {formatear_moneda(stats_mes.get('comision_total', 0))} 💰\n"
f"• Monto total: {formatear_moneda(stats_mes.get('monto_total', 0))}\n"
f"• Ventas: {stats_mes.get('cantidad_ventas', 0)}\n"
f"• Días activos: {stats_mes.get('dias_activos', 0)}\n"
f"• Días con meta: {stats_mes.get('dias_meta_cumplida', 0)}\n"
f"• Promedio/día: {stats_mes.get('promedio_tubos_dia', 0):.1f} tubos\n"
)
mattermost.post_message_webhook(mensaje, username='Sales Bot', icon_emoji=':bar_chart:')
return mensaje
except Exception as e:
logger.error(f"Error en comando_estadisticas: {str(e)}", exc_info=True)
return f"❌ Error obteniendo estadísticas"

View File

@@ -0,0 +1,201 @@
import requests
import logging
import os
logger = logging.getLogger(__name__)
class MattermostClient:
def __init__(self, url, token):
self.url = url.rstrip('/')
self.token = token
self.api_url = f"{self.url}/api/v4"
self.headers = {
'Authorization': f'Bearer {self.token}',
'Content-Type': 'application/json'
}
self.webhook_url = os.getenv('MATTERMOST_WEBHOOK_URL')
def test_connection(self):
"""Prueba la conexión con Mattermost"""
try:
response = requests.get(
f"{self.api_url}/users/me",
headers=self.headers,
timeout=10
)
response.raise_for_status()
user = response.json()
logger.info(f"Conexión exitosa con Mattermost. Bot: {user.get('username')}")
return {
'status': 'success',
'bot_username': user.get('username'),
'bot_id': user.get('id')
}
except Exception as e:
logger.error(f"Error conectando con Mattermost: {str(e)}")
return {'status': 'error', 'message': str(e)}
def get_channel_by_name(self, team_name, channel_name):
"""Obtiene información de un canal por nombre"""
try:
# Primero obtener el team
response = requests.get(
f"{self.api_url}/teams/name/{team_name}",
headers=self.headers,
timeout=10
)
response.raise_for_status()
team = response.json()
team_id = team['id']
# Luego obtener el canal
response = requests.get(
f"{self.api_url}/teams/{team_id}/channels/name/{channel_name}",
headers=self.headers,
timeout=10
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Error obteniendo canal {channel_name}: {str(e)}")
return None
def post_message(self, channel_id, message, props=None):
"""Publica un mensaje en un canal"""
try:
payload = {
'channel_id': channel_id,
'message': message
}
if props:
payload['props'] = props
response = requests.post(
f"{self.api_url}/posts",
headers=self.headers,
json=payload,
timeout=10
)
response.raise_for_status()
logger.info(f"Mensaje publicado en canal {channel_id}")
return response.json()
except Exception as e:
logger.error(f"Error publicando mensaje: {str(e)}")
return None
def post_message_webhook(self, message, username=None, icon_emoji=None):
"""Publica un mensaje usando webhook incoming"""
try:
payload = {'text': message}
if username:
payload['username'] = username
if icon_emoji:
payload['icon_emoji'] = icon_emoji
response = requests.post(
self.webhook_url,
json=payload,
timeout=10
)
response.raise_for_status()
logger.info("Mensaje publicado via webhook")
return True
except Exception as e:
logger.error(f"Error publicando via webhook: {str(e)}")
return False
def get_file(self, file_id):
"""Descarga un archivo de Mattermost"""
try:
response = requests.get(
f"{self.api_url}/files/{file_id}",
headers=self.headers,
timeout=30
)
response.raise_for_status()
return response.content
except Exception as e:
logger.error(f"Error descargando archivo {file_id}: {str(e)}")
return None
def get_file_info(self, file_id):
"""Obtiene información de un archivo"""
try:
response = requests.get(
f"{self.api_url}/files/{file_id}/info",
headers=self.headers,
timeout=10
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Error obteniendo info de archivo {file_id}: {str(e)}")
return None
def upload_file(self, channel_id, file_content, filename):
"""Sube un archivo a Mattermost"""
try:
files = {
'files': (filename, file_content)
}
data = {
'channel_id': channel_id
}
headers = {
'Authorization': f'Bearer {self.token}'
}
response = requests.post(
f"{self.api_url}/files",
headers=headers,
files=files,
data=data,
timeout=30
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Error subiendo archivo: {str(e)}")
return None
def add_reaction(self, post_id, emoji_name):
"""Agrega una reacción a un post"""
try:
# Obtener el user_id del bot
me = requests.get(
f"{self.api_url}/users/me",
headers=self.headers,
timeout=10
).json()
payload = {
'user_id': me['id'],
'post_id': post_id,
'emoji_name': emoji_name
}
response = requests.post(
f"{self.api_url}/reactions",
headers=self.headers,
json=payload,
timeout=10
)
response.raise_for_status()
return True
except Exception as e:
logger.error(f"Error agregando reacción: {str(e)}")
return False
def get_user_by_username(self, username):
"""Obtiene información de un usuario por username"""
try:
response = requests.get(
f"{self.api_url}/users/username/{username}",
headers=self.headers,
timeout=10
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Error obteniendo usuario {username}: {str(e)}")
return None

611
sales-bot/nocodb_client.py Normal file
View File

@@ -0,0 +1,611 @@
import requests
import logging
import os
from datetime import datetime, date, timezone, timedelta
logger = logging.getLogger(__name__)
# Zona horaria de México (UTC-6)
TZ_MEXICO = timezone(timedelta(hours=-6))
class NocoDBClient:
def __init__(self, url, token):
self.url = url.rstrip('/')
self.token = token
self.headers = {
'xc-token': self.token,
'Content-Type': 'application/json'
}
# IDs de tablas desde variables de entorno
self.table_vendedores = os.getenv('NOCODB_TABLE_VENDEDORES')
self.table_ventas = os.getenv('NOCODB_TABLE_VENTAS')
self.table_metas = os.getenv('NOCODB_TABLE_METAS')
self.table_detalle = os.getenv('NOCODB_TABLE_VENTAS_DETALLE')
# NUEVA CONFIGURACIÓN DE COMISIONES
self.META_DIARIA_TUBOS = 3 # Meta: 3 tubos diarios
self.COMISION_POR_TUBO = 10.0 # $10 por tubo después del 3ro
def test_connection(self):
"""Prueba la conexión con NocoDB"""
try:
response = requests.get(
f"{self.url}/api/v2/meta/bases",
headers=self.headers,
timeout=10
)
response.raise_for_status()
bases = response.json()
logger.info(f"Conexión exitosa con NocoDB. Bases: {len(bases.get('list', []))}")
return {
'status': 'success',
'bases_count': len(bases.get('list', []))
}
except Exception as e:
logger.error(f"Error conectando con NocoDB: {str(e)}")
return {'status': 'error', 'message': str(e)}
def get_vendedor(self, username):
"""Obtiene información de un vendedor por username"""
try:
response = requests.get(
f"{self.url}/api/v2/tables/{self.table_vendedores}/records",
headers=self.headers,
params={'limit': 100},
timeout=10
)
response.raise_for_status()
vendedores = response.json().get('list', [])
for vendedor in vendedores:
if vendedor.get('username') == username:
return vendedor
return None
except Exception as e:
logger.error(f"Error obteniendo vendedor {username}: {str(e)}")
return None
def crear_vendedor(self, username, nombre_completo, email, meta_diaria_tubos=3):
"""Crea un nuevo vendedor con meta diaria de tubos"""
try:
payload = {
'username': username,
'nombre_completo': nombre_completo,
'email': email,
'meta_diaria_tubos': meta_diaria_tubos, # Nueva: meta en tubos por día
'activo': True,
'fecha_registro': datetime.now(TZ_MEXICO).isoformat()
}
response = requests.post(
f"{self.url}/api/v2/tables/{self.table_vendedores}/records",
headers=self.headers,
json=payload,
timeout=10
)
response.raise_for_status()
logger.info(f"Vendedor {username} creado con meta diaria de {meta_diaria_tubos} tubos")
return response.json()
except Exception as e:
logger.error(f"Error creando vendedor: {str(e)}")
return None
def registrar_venta(self, vendedor_username, monto, cliente, producto=None,
descripcion=None, mensaje_id=None, canal=None, imagen_url=None):
"""Registra una nueva venta con URL de imagen opcional"""
try:
payload = {
'vendedor_username': vendedor_username,
'monto': float(monto),
'cliente': cliente,
'fecha_venta': datetime.now(TZ_MEXICO).isoformat(),
'estado': 'confirmada',
'mensaje_id': mensaje_id,
'canal': canal
}
if producto:
payload['producto'] = producto
if descripcion:
payload['descripcion'] = descripcion
if imagen_url:
filename = imagen_url.split('/')[-1].split('?')[0] if '/' in imagen_url else 'ticket.jpg'
payload['imagen_ticket'] = [
{
"url": imagen_url,
"title": filename,
"mimetype": "image/jpeg"
}
]
logger.info(f"Guardando imagen con URL: {imagen_url[:50]}...")
logger.info(f"Registrando venta - Monto: ${monto}, Cliente: {cliente}")
response = requests.post(
f"{self.url}/api/v2/tables/{self.table_ventas}/records",
headers=self.headers,
json=payload,
timeout=30
)
response.raise_for_status()
venta = response.json()
logger.info(f"Venta registrada: ${monto} - {cliente} - vendedor: {vendedor_username}")
# NUEVO: No actualizar meta mensual, ahora se calcula al consultar
return venta
except Exception as e:
logger.error(f"Error registrando venta: {str(e)}", exc_info=True)
if 'response' in locals():
logger.error(f"Response: {response.text}")
return None
def get_ventas_dia(self, vendedor_username=None, fecha=None):
"""Obtiene ventas de un día específico (convierte UTC a hora México)"""
try:
if fecha is None:
fecha = datetime.now(TZ_MEXICO).strftime('%Y-%m-%d')
elif isinstance(fecha, date):
fecha = fecha.strftime('%Y-%m-%d')
# Obtener todas las ventas y filtrar
response = requests.get(
f"{self.url}/api/v2/tables/{self.table_ventas}/records",
headers=self.headers,
params={'limit': 1000},
timeout=10
)
response.raise_for_status()
todas_ventas = response.json().get('list', [])
# Filtrar por día (convertir UTC a México)
ventas_filtradas = []
for venta in todas_ventas:
fecha_venta_str = venta.get('fecha_venta', '')
vendedor = venta.get('vendedor_username', '')
# Convertir fecha UTC a México
try:
# Parsear fecha UTC de NocoDB
if '+' in fecha_venta_str:
fecha_venta_utc = datetime.fromisoformat(fecha_venta_str.replace('+00:00', '+0000'))
else:
fecha_venta_utc = datetime.fromisoformat(fecha_venta_str)
# Convertir a hora de México
fecha_venta_mexico = fecha_venta_utc.astimezone(TZ_MEXICO)
fecha_venta_local = fecha_venta_mexico.strftime('%Y-%m-%d')
except:
fecha_venta_local = fecha_venta_str[:10] if fecha_venta_str else ''
fecha_match = fecha_venta_local == fecha
if vendedor_username:
vendedor_match = vendedor == vendedor_username
else:
vendedor_match = True
if fecha_match and vendedor_match:
ventas_filtradas.append(venta)
logger.info(f"Ventas del día {fecha} para {vendedor_username or 'todos'}: {len(ventas_filtradas)}")
return ventas_filtradas
except Exception as e:
logger.error(f"Error obteniendo ventas del día: {str(e)}")
return []
def get_ventas_mes(self, vendedor_username=None, mes=None):
"""Obtiene ventas del mes actual o especificado"""
try:
if mes is None:
mes = datetime.now(TZ_MEXICO).strftime('%Y-%m')
response = requests.get(
f"{self.url}/api/v2/tables/{self.table_ventas}/records",
headers=self.headers,
params={'limit': 1000},
timeout=10
)
response.raise_for_status()
todas_ventas = response.json().get('list', [])
ventas_filtradas = []
for venta in todas_ventas:
fecha_venta = venta.get('fecha_venta', '')
vendedor = venta.get('vendedor_username', '')
fecha_match = fecha_venta.startswith(mes)
if vendedor_username:
vendedor_match = vendedor == vendedor_username
else:
vendedor_match = True
if fecha_match and vendedor_match:
ventas_filtradas.append(venta)
logger.info(f"Ventas del mes {mes}: {len(ventas_filtradas)}")
return ventas_filtradas
except Exception as e:
logger.error(f"Error obteniendo ventas del mes: {str(e)}")
return []
def contar_tubos_vendidos_dia(self, vendedor_username, fecha=None):
"""
Cuenta los tubos de tinte vendidos en un día específico
Busca en la tabla de detalle de ventas usando el campo numérico venta_id_num
"""
try:
if fecha is None:
fecha = datetime.now(TZ_MEXICO).strftime('%Y-%m-%d')
elif isinstance(fecha, date):
fecha = fecha.strftime('%Y-%m-%d')
logger.info(f"Contando tubos para {vendedor_username} del día {fecha}")
# Obtener ventas del día
ventas_dia = self.get_ventas_dia(vendedor_username, fecha)
if not ventas_dia:
logger.info(f"No hay ventas para {vendedor_username} el {fecha}")
return 0
# Obtener IDs de las ventas
venta_ids = [v.get('Id') for v in ventas_dia if v.get('Id')]
logger.info(f"IDs de ventas a buscar: {venta_ids}")
if not venta_ids:
return 0
# Obtener todos los detalles de productos
response = requests.get(
f"{self.url}/api/v2/tables/{self.table_detalle}/records",
headers=self.headers,
params={'limit': 1000},
timeout=10
)
response.raise_for_status()
todos_detalles = response.json().get('list', [])
logger.info(f"Total detalles en tabla: {len(todos_detalles)}")
# Contar tubos de tinte (productos que sean tintes)
tubos_vendidos = 0
detalles_encontrados = 0
for detalle in todos_detalles:
# Usar el campo numérico venta_id_num para verificar la relación
venta_id_num = detalle.get('venta_id_num')
if venta_id_num is None:
continue
# Verificar si este detalle pertenece a alguna de las ventas del día
if int(venta_id_num) in venta_ids:
detalles_encontrados += 1
# Detectar si es un tubo de tinte
producto = str(detalle.get('producto', '')).lower()
marca = str(detalle.get('marca', '')).lower()
nombre_completo = f"{marca} {producto}".lower()
logger.info(f" Analizando: {marca} {producto} (venta_id_num={venta_id_num})")
# Si contiene "tinte" o "cromatique" o es registro manual, contar
if 'tinte' in nombre_completo or 'cromatique' in nombre_completo or 'manual' in marca:
cantidad = int(detalle.get('cantidad', 0))
tubos_vendidos += cantidad
logger.info(f" ✓ Tubo detectado: {cantidad}x {marca} {producto}")
logger.info(f"Detalles encontrados: {detalles_encontrados}")
logger.info(f"Total tubos vendidos por {vendedor_username} el {fecha}: {tubos_vendidos}")
return tubos_vendidos
except Exception as e:
logger.error(f"Error contando tubos: {str(e)}", exc_info=True)
return 0
def calcular_comision_dia(self, vendedor_username, fecha=None):
"""
Calcula la comisión del día para un vendedor
Fórmula: $10 por cada tubo vendido después del 3ro
"""
try:
tubos_vendidos = self.contar_tubos_vendidos_dia(vendedor_username, fecha)
if tubos_vendidos <= self.META_DIARIA_TUBOS:
comision = 0.0
tubos_comisionables = 0
else:
tubos_comisionables = tubos_vendidos - self.META_DIARIA_TUBOS
comision = tubos_comisionables * self.COMISION_POR_TUBO
logger.info(
f"Comisión {vendedor_username}: "
f"{tubos_vendidos} tubos vendidos, "
f"{tubos_comisionables} comisionables = ${comision:.2f}"
)
return {
'tubos_vendidos': tubos_vendidos,
'tubos_comisionables': tubos_comisionables,
'comision': comision,
'meta_diaria': self.META_DIARIA_TUBOS,
'comision_por_tubo': self.COMISION_POR_TUBO
}
except Exception as e:
logger.error(f"Error calculando comisión: {str(e)}")
return {
'tubos_vendidos': 0,
'tubos_comisionables': 0,
'comision': 0.0,
'meta_diaria': self.META_DIARIA_TUBOS,
'comision_por_tubo': self.COMISION_POR_TUBO
}
def get_estadisticas_vendedor_dia(self, vendedor_username, fecha=None):
"""
Obtiene estadísticas completas del vendedor para un día
"""
try:
if fecha is None:
fecha = datetime.now(TZ_MEXICO).strftime('%Y-%m-%d')
# Contar tubos y calcular comisión
resultado = self.calcular_comision_dia(vendedor_username, fecha)
# Obtener monto total vendido
ventas = self.get_ventas_dia(vendedor_username, fecha)
monto_total = sum(float(v.get('monto', 0)) for v in ventas)
estadisticas = {
**resultado,
'monto_total_dia': monto_total,
'cantidad_ventas': len(ventas),
'fecha': fecha,
'vendedor': vendedor_username
}
return estadisticas
except Exception as e:
logger.error(f"Error obteniendo estadísticas: {str(e)}")
return None
def get_estadisticas_vendedor_mes(self, vendedor_username, mes=None):
"""
Obtiene estadísticas del mes completo
Calcula comisiones acumuladas día por día
"""
try:
if mes is None:
mes = datetime.now(TZ_MEXICO).strftime('%Y-%m')
# Obtener ventas del mes
ventas_mes = self.get_ventas_mes(vendedor_username, mes)
# Agrupar por día (convertir UTC a México)
dias = {}
for venta in ventas_mes:
fecha_venta = venta.get('fecha_venta', '')
if fecha_venta:
# Convertir fecha UTC a México
try:
fecha_utc = datetime.fromisoformat(fecha_venta.replace('+00:00', '+00:00').replace('Z', '+00:00'))
if fecha_utc.tzinfo is None:
fecha_utc = fecha_utc.replace(tzinfo=timezone.utc)
fecha_mexico = fecha_utc.astimezone(TZ_MEXICO)
dia = fecha_mexico.strftime('%Y-%m-%d')
except:
dia = fecha_venta[:10] # Fallback
if dia not in dias:
dias[dia] = []
dias[dia].append(venta)
# Calcular comisiones día por día
comision_total_mes = 0.0
tubos_totales_mes = 0
dias_meta_cumplida = 0
for dia in sorted(dias.keys()):
stats_dia = self.get_estadisticas_vendedor_dia(vendedor_username, dia)
if stats_dia:
comision_total_mes += stats_dia['comision']
tubos_totales_mes += stats_dia['tubos_vendidos']
if stats_dia['tubos_vendidos'] >= self.META_DIARIA_TUBOS:
dias_meta_cumplida += 1
monto_total_mes = sum(float(v.get('monto', 0)) for v in ventas_mes)
return {
'mes': mes,
'vendedor': vendedor_username,
'tubos_totales': tubos_totales_mes,
'comision_total': comision_total_mes,
'monto_total': monto_total_mes,
'cantidad_ventas': len(ventas_mes),
'dias_activos': len(dias),
'dias_meta_cumplida': dias_meta_cumplida,
'promedio_tubos_dia': tubos_totales_mes / len(dias) if dias else 0,
'meta_diaria': self.META_DIARIA_TUBOS
}
except Exception as e:
logger.error(f"Error obteniendo estadísticas del mes: {str(e)}", exc_info=True)
return None
def get_ranking_vendedores(self, mes=None):
"""Obtiene ranking de vendedores por tubos vendidos en el mes"""
try:
if mes is None:
mes = datetime.now(TZ_MEXICO).strftime('%Y-%m')
# Obtener todos los vendedores
response = requests.get(
f"{self.url}/api/v2/tables/{self.table_vendedores}/records",
headers=self.headers,
params={'limit': 100},
timeout=10
)
response.raise_for_status()
vendedores = response.json().get('list', [])
# Calcular estadísticas para cada vendedor
ranking = []
for vendedor in vendedores:
if not vendedor.get('activo', True):
continue
username = vendedor.get('username')
if not username:
continue
stats = self.get_estadisticas_vendedor_mes(username, mes)
if stats:
ranking.append({
'vendedor_username': username,
'nombre_completo': vendedor.get('nombre_completo', username),
**stats
})
# Ordenar por tubos vendidos (descendente)
ranking_ordenado = sorted(
ranking,
key=lambda x: x.get('tubos_totales', 0),
reverse=True
)
return ranking_ordenado
except Exception as e:
logger.error(f"Error obteniendo ranking: {str(e)}")
return []
def guardar_productos_venta(self, venta_id, productos):
"""Guarda el detalle de productos de una venta usando campo numérico venta_id_num"""
try:
if not self.table_detalle:
logger.warning("No se configuró NOCODB_TABLE_VENTAS_DETALLE")
return None
productos_guardados = []
for producto in productos:
# Crear registro del producto con venta_id_num (campo numérico simple)
payload = {
'producto': producto.get('producto', ''),
'marca': producto.get('marca', 'Sin marca'),
'cantidad': producto.get('cantidad', 1),
'precio_unitario': float(producto.get('precio_unitario', 0)),
'importe': float(producto.get('importe', 0)),
'detectado_ocr': True,
'venta_id_num': int(venta_id) # Campo numérico simple en lugar de link
}
logger.info(f"Guardando producto con venta_id_num={venta_id}: {producto.get('marca')} {producto.get('producto')}")
response = requests.post(
f"{self.url}/api/v2/tables/{self.table_detalle}/records",
headers=self.headers,
json=payload,
timeout=10
)
response.raise_for_status()
detalle = response.json()
detalle_id = detalle.get('Id')
logger.info(f"Producto guardado ID={detalle_id}: {producto.get('marca')} {producto.get('producto')} -> venta_id_num={venta_id}")
productos_guardados.append(detalle)
logger.info(f"Total productos guardados: {len(productos_guardados)} para venta {venta_id}")
return productos_guardados
except Exception as e:
logger.error(f"Error guardando productos: {str(e)}", exc_info=True)
return None
def actualizar_meta_vendedor(self, vendedor_username):
"""Actualiza o crea el registro de metas del vendedor para el mes actual"""
try:
if not self.table_metas:
logger.warning("No se configuró NOCODB_TABLE_METAS")
return None
# Formato para búsqueda: 2026-01 (año-mes)
mes_busqueda = datetime.now(TZ_MEXICO).strftime('%Y-%m')
# Formato para guardar en BD: 2026-01-01 (campo Date requiere YYYY-MM-DD)
mes_fecha = datetime.now(TZ_MEXICO).strftime('%Y-%m-01')
# Obtener estadísticas del mes (ya convierte fechas UTC a México)
stats = self.get_estadisticas_vendedor_mes(vendedor_username, mes_busqueda)
if not stats:
logger.warning(f"No se pudieron obtener estadísticas para {vendedor_username}")
return None
tubos_vendidos = stats.get('tubos_totales', 0)
comision = stats.get('comision_total', 0)
dias_activos = stats.get('dias_activos', 0)
dias_cumplida = stats.get('dias_meta_cumplida', 0)
logger.info(f"Stats para metas: tubos={tubos_vendidos}, comision={comision}, dias={dias_activos}")
# Buscar registro existente del mes
response = requests.get(
f"{self.url}/api/v2/tables/{self.table_metas}/records",
headers=self.headers,
params={'limit': 1000},
timeout=10
)
response.raise_for_status()
registros = response.json().get('list', [])
registro_existente = None
for reg in registros:
if reg.get('vendedor_username') == vendedor_username:
fecha_reg = str(reg.get('mes', ''))[:7]
if fecha_reg == mes_busqueda:
registro_existente = reg
break
payload = {
'vendedor_username': vendedor_username,
'mes': mes_fecha,
'tubos_vendidos': tubos_vendidos,
'comision_ganada': comision,
'dias_activos': dias_activos,
'dias_meta_cumplida': dias_cumplida
}
if registro_existente:
# Actualizar registro existente
response = requests.patch(
f"{self.url}/api/v2/tables/{self.table_metas}/records",
headers=self.headers,
json=[{"Id": registro_existente['Id'], **payload}],
timeout=10
)
else:
# Crear nuevo registro
response = requests.post(
f"{self.url}/api/v2/tables/{self.table_metas}/records",
headers=self.headers,
json=payload,
timeout=10
)
response.raise_for_status()
logger.info(f"Meta actualizada para {vendedor_username}: {tubos_vendidos} tubos, ${comision} comisión")
return response.json()
except Exception as e:
logger.error(f"Error actualizando meta: {str(e)}", exc_info=True)
return None
def get_meta_vendedor(self, vendedor_username, mes=None):
"""Obtiene las estadísticas del vendedor para el mes"""
return self.get_estadisticas_vendedor_mes(vendedor_username, mes)

524
sales-bot/ocr_processor.py Normal file
View File

@@ -0,0 +1,524 @@
import logging
import re
from PIL import Image, ImageEnhance, ImageFilter, ImageOps
import pytesseract
import io
logger = logging.getLogger(__name__)
class OCRProcessor:
"""
OCR ultra-robusto para tickets tabulares
Maneja múltiples errores comunes del OCR
"""
def __init__(self):
"""Inicializa el procesador OCR"""
pytesseract.pytesseract.tesseract_cmd = '/usr/bin/tesseract'
self.configs = [
'--psm 6 -l eng',
'--psm 4 -l eng',
'--psm 3 -l eng',
]
def procesar_ticket(self, imagen_data):
"""Procesa una imagen de ticket y extrae información completa"""
try:
imagen_pil = Image.open(io.BytesIO(imagen_data))
mejor_resultado = None
max_productos = 0
for config in self.configs:
try:
imagen_procesada = self._preprocesar_imagen_para_tabla(imagen_pil.copy())
texto = pytesseract.image_to_string(imagen_procesada, config=config)
logger.info(f"Texto extraído con config '{config}':\n{texto}")
productos = self._extraer_productos_tabla(texto)
if len(productos) > max_productos:
max_productos = len(productos)
mejor_resultado = {
'texto_completo': texto,
'config_usada': config,
'productos': productos
}
except Exception as e:
logger.warning(f"Error con config '{config}': {str(e)}")
continue
if mejor_resultado is None:
imagen_procesada = self._preprocesar_imagen_para_tabla(imagen_pil)
texto = pytesseract.image_to_string(imagen_procesada, config=self.configs[0])
mejor_resultado = {
'texto_completo': texto,
'config_usada': self.configs[0],
'productos': []
}
texto = mejor_resultado['texto_completo']
mejor_resultado.update({
'monto_detectado': self._extraer_monto(texto),
'fecha_detectada': self._extraer_fecha(texto),
'subtotal': self._extraer_subtotal(texto),
'iva': self._extraer_iva(texto),
'tienda': self._extraer_tienda(texto),
'folio': self._extraer_folio(texto)
})
logger.info(f"Procesamiento completado: {len(mejor_resultado['productos'])} productos detectados")
return mejor_resultado
except Exception as e:
logger.error(f"Error procesando OCR: {str(e)}", exc_info=True)
return None
def _preprocesar_imagen_para_tabla(self, imagen):
"""Preprocesamiento optimizado para tickets tabulares"""
try:
if imagen.mode != 'L':
imagen = imagen.convert('L')
width, height = imagen.size
if width < 1500 or height < 1500:
scale = max(1500 / width, 1500 / height)
new_size = (int(width * scale), int(height * scale))
imagen = imagen.resize(new_size, Image.Resampling.LANCZOS)
enhancer = ImageEnhance.Contrast(imagen)
imagen = enhancer.enhance(3.0)
enhancer = ImageEnhance.Sharpness(imagen)
imagen = enhancer.enhance(2.5)
imagen = ImageOps.autocontrast(imagen, cutoff=1)
threshold = 140
imagen = imagen.point(lambda p: 255 if p > threshold else 0, mode='1')
imagen = imagen.convert('L')
return imagen
except Exception as e:
logger.error(f"Error en preprocesamiento: {str(e)}")
if imagen.mode != 'L':
imagen = imagen.convert('L')
return imagen
def _normalizar_precio(self, precio_str):
"""
Normaliza precios: "$50 00" → 50.00, "1,210 00" → 1210.00
"""
try:
precio_str = precio_str.replace('$', '').strip()
precio_str = precio_str.replace(',', '')
if ' ' in precio_str:
partes = precio_str.split()
if len(partes) == 2:
precio_str = partes[0] + '.' + partes[1]
return float(precio_str)
except:
return 0.0
def _extraer_productos_tabla(self, texto):
"""
Extrae productos con máxima robustez para errores de OCR
"""
productos = []
try:
lineas = texto.split('\n')
# Patrones ultra-robustos
patrones = [
# Patrón 0: NUEVO - Cuando los precios tienen punto decimal correcto
# "TINTE CROMATIQUE 6.7 4 $50.00 $200.00"
r'^(.+?)\s+(\d{1,3})\s+\$?\s*([\d,]+\.\d{2})\s+\$?\s*([\d,]+\.\d{2})\s*$',
# Patrón 1: Nombre Cantidad PU Importe (con espacios en precios)
# "TINTE CROMATIQUE 5.0 7 $50 00 $350 00"
r'^(.+?)\s+(\d{1,3})\s+\$?\s*([\d,]+\s\d{2})\s+\$?\s*([\d,]+\s\d{2})\s*$',
# Patrón 2: Nombre+Número Cantidad PU Importe
# "TINTE CROMATIQUE 67 4 $50 00 $200.00" (67 pegado)
r'^(.+?(?:\d{1,2}[\.\s]?\d{0,2}))\s+(\d{1,3})\s+\$?\s*([\d,]+[\s\.]?\d{0,2})\s+\$?\s*([\d,]+[\s\.]?\d{0,2})\s*$',
# Patrón 3: Errores OCR "IL |" o "IL l"
# "OXIDANTE 20 VOL IL | $120 00 $120 00"
r'^(.+?)\s+(?:IL|Il|il|lL)\s+(?:\||I|l|\d)\s+\$?\s*([\d,]+[\s\.]?\d{0,2})\s+\$?\s*([\d,]+[\s\.]?\d{0,2})\s*$',
]
palabras_ignorar = [
'producto', 'cantidad', 'p.u', 'importe', 'precio',
'total', 'subtotal', 'iva', 'fecha', 'folio', 'ticket',
'gracias', 'cambio', 'efectivo', 'tarjeta', 'rfc',
'direccion', 'tel', 'hora', 'vendedor', 'distribuidor'
]
for idx, linea in enumerate(lineas):
linea_original = linea
linea = linea.strip()
if len(linea) < 5:
continue
linea_lower = linea.lower()
if any(palabra in linea_lower for palabra in palabras_ignorar):
continue
if not any(char.isdigit() for char in linea):
continue
# Intentar extraer con cada patrón
for patron_idx, patron in enumerate(patrones):
match = re.search(patron, linea, re.IGNORECASE)
if match:
try:
grupos = match.groups()
producto = self._parsear_producto_robusto(grupos, patron_idx, linea_original)
if producto and self._validar_producto(producto):
# Evitar duplicados
if not any(p['linea_original'] == producto['linea_original'] for p in productos):
productos.append(producto)
logger.info(
f"✓ Producto {len(productos)} (patrón {patron_idx}): "
f"{producto['cantidad']}x {producto['marca']} {producto['producto']} "
f"@ ${producto['precio_unitario']} = ${producto['importe']}"
)
break
except Exception as e:
logger.debug(f"Error en línea '{linea}': {str(e)}")
continue
logger.info(f"Total productos detectados: {len(productos)}")
return productos
except Exception as e:
logger.error(f"Error extrayendo productos: {str(e)}", exc_info=True)
return []
def _parsear_producto_robusto(self, grupos, patron_idx, linea_original):
"""Parsea productos con máxima flexibilidad"""
try:
# Patrones 0, 1 y 2: formato normal con 4 campos
if patron_idx in [0, 1, 2] and len(grupos) >= 4:
nombre_completo = grupos[0].strip()
cantidad = int(grupos[1])
precio_unitario = self._normalizar_precio(grupos[2])
importe = self._normalizar_precio(grupos[3])
# Validar matemática (tolerancia amplia)
diferencia = abs((cantidad * precio_unitario) - importe)
if diferencia > 5.0:
logger.debug(f"Descartado por matemática: {cantidad} × ${precio_unitario} ≠ ${importe} (dif: ${diferencia})")
return None
# Patrón 3: errores OCR "IL |"
elif patron_idx == 3 and len(grupos) >= 3:
nombre_completo = grupos[0].strip()
cantidad = 1
precio_unitario = self._normalizar_precio(grupos[1])
importe = self._normalizar_precio(grupos[2])
# Para cantidad 1, los precios deben ser iguales
if abs(precio_unitario - importe) > 2.0:
return None
else:
return None
# Validaciones básicas
if not nombre_completo or len(nombre_completo) < 3:
return None
if cantidad <= 0 or cantidad > 999:
return None
if precio_unitario <= 0 or importe <= 0:
return None
if precio_unitario > 100000 or importe > 1000000:
return None
# Filtrar nombres que son solo números
if re.match(r'^[\d\s\$\.\,\-]+$', nombre_completo):
return None
# Separar marca y producto
marca, producto = self._separar_marca_producto(nombre_completo)
return {
'producto': producto,
'marca': marca,
'cantidad': cantidad,
'precio_unitario': round(precio_unitario, 2),
'importe': round(importe, 2),
'linea_original': linea_original.strip()
}
except Exception as e:
logger.debug(f"Error parseando: {str(e)}")
return None
def _validar_producto(self, producto):
"""Validación de producto"""
try:
if not all(key in producto for key in ['producto', 'marca', 'cantidad', 'importe']):
return False
if producto['cantidad'] <= 0 or producto['cantidad'] > 999:
return False
if producto['importe'] <= 0 or producto['importe'] > 1000000:
return False
if producto['precio_unitario'] <= 0 or producto['precio_unitario'] > 100000:
return False
nombre_limpio = producto['producto'].strip()
if len(nombre_limpio) < 2:
return False
if not any(c.isalpha() for c in nombre_limpio):
return False
return True
except Exception as e:
logger.debug(f"Error validando: {str(e)}")
return False
def _separar_marca_producto(self, nombre_completo):
"""Separa marca de producto"""
try:
nombre_completo = nombre_completo.strip()
# Marcas de productos de belleza
marcas_belleza = [
'tinte cromatique', 'cromatique', 'oxidante', 'decolorante',
'trat. capilar', 'trat capilar', 'tratamiento', 'prat. capilar',
'shampoo', 'acondicionador', 'mascarilla', 'ampolleta',
'serum', 'keratina', 'botox', 'alisado'
]
nombre_lower = nombre_completo.lower().strip()
# Buscar marcas conocidas
for marca in marcas_belleza:
if marca in nombre_lower:
inicio = nombre_lower.index(marca)
fin = inicio + len(marca)
marca_encontrada = nombre_completo[inicio:fin].strip()
producto = (nombre_completo[:inicio] + nombre_completo[fin:]).strip()
# Si el producto es solo un código numérico, usar nombre completo como producto
if not producto or len(producto) < 2:
return 'Genérico', nombre_completo
# Si el producto es solo números/punto (ej: "6.7"), usar nombre completo como producto
if re.match(r'^[\d\.]+$', producto):
return 'Genérico', nombre_completo
return marca_encontrada, producto
# Buscar patrones de código/número al final
patron_codigo = r'(.*?)\s+([\d\.]+)\s*$'
match = re.search(patron_codigo, nombre_completo)
if match:
# Para productos con código al final, usar todo como producto
# Ej: "TINTE CROMATIQUE 6.7" → marca: "Producto", producto: "TINTE CROMATIQUE 6.7"
return 'Producto', nombre_completo
# Heurística: primeras palabras como marca
palabras = nombre_completo.split()
if len(palabras) >= 3:
marca = ' '.join(palabras[:-1])
producto = palabras[-1]
# Si la última palabra es solo números, usar nombre completo
if re.match(r'^[\d\.]+$', producto):
return 'Producto', nombre_completo
return marca, producto
elif len(palabras) == 2:
return palabras[0], palabras[1]
else:
return 'Producto', nombre_completo
except Exception as e:
logger.error(f"Error separando marca/producto: {str(e)}")
return 'Producto', nombre_completo
def _extraer_tienda(self, texto):
"""Extrae nombre de tienda"""
try:
lineas = texto.split('\n')[:3]
for linea in lineas:
if re.search(r'S\.A\.|S\.A\. DE C\.V\.|DISTRIBUIDORA', linea, re.IGNORECASE):
tienda = linea.strip()
logger.info(f"Tienda: {tienda}")
return tienda
return None
except Exception as e:
logger.error(f"Error extrayendo tienda: {str(e)}")
return None
def _extraer_folio(self, texto):
"""Extrae folio"""
try:
patrones = [
r'ticket[:\s]*(\w+)',
r'folio[:\s]*(\w+)',
r'no\.\s*(\w+)',
]
for patron in patrones:
match = re.search(patron, texto, re.IGNORECASE)
if match:
folio = match.group(1)
logger.info(f"Folio: {folio}")
return folio
return None
except Exception as e:
logger.error(f"Error extrayendo folio: {str(e)}")
return None
def _extraer_monto(self, texto):
"""Extrae monto total"""
try:
patron_total = r'total\s*\$?\s*([\d,]+[\s\.]?\d{0,2})'
matches = re.finditer(patron_total, texto, re.IGNORECASE)
montos = []
for match in matches:
try:
monto = self._normalizar_precio(match.group(1))
if 1 <= monto <= 1000000:
montos.append(monto)
except:
continue
if montos:
monto_final = montos[-1]
logger.info(f"Total: ${monto_final:.2f}")
return monto_final
return None
except Exception as e:
logger.error(f"Error extrayendo monto: {str(e)}")
return None
def _extraer_subtotal(self, texto):
"""Extrae subtotal"""
try:
patron = r'subtotal\s*\$?\s*([\d,]+[\s\.]?\d{0,2})'
match = re.search(patron, texto, re.IGNORECASE)
if match:
subtotal = self._normalizar_precio(match.group(1))
logger.info(f"Subtotal: ${subtotal:.2f}")
return subtotal
return None
except Exception as e:
return None
def _extraer_iva(self, texto):
"""Extrae IVA"""
try:
patron = r'iva\s*\$?\s*([\d,]+[\s\.]?\d{0,2})'
match = re.search(patron, texto, re.IGNORECASE)
if match:
iva = self._normalizar_precio(match.group(1))
logger.info(f"IVA: ${iva:.2f}")
return iva
return None
except Exception as e:
return None
def _extraer_fecha(self, texto):
"""Extrae fecha"""
try:
patrones = [
r'fecha[:\s]*(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})',
r'(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})',
]
for patron in patrones:
match = re.search(patron, texto, re.IGNORECASE)
if match:
fecha = match.group(1)
logger.info(f"Fecha: {fecha}")
return fecha
return None
except Exception as e:
return None
def validar_monto_con_ocr(self, monto_usuario, monto_ocr, tolerancia=0.1):
"""Valida monto vs OCR"""
if monto_ocr is None:
return True, "No se pudo detectar monto en la imagen"
diferencia = abs(monto_usuario - monto_ocr)
porcentaje_diferencia = (diferencia / monto_ocr) * 100 if monto_ocr > 0 else 0
if porcentaje_diferencia <= (tolerancia * 100):
return True, f"✓ Monto verificado (OCR: ${monto_ocr:,.2f})"
else:
return False, f"⚠️ Discrepancia: Usuario ${monto_usuario:,.2f} vs Ticket ${monto_ocr:,.2f}"
def generar_reporte_deteccion(self, resultado_ocr):
"""Genera reporte legible"""
if not resultado_ocr:
return "No se pudo procesar el ticket"
reporte = []
reporte.append("=" * 50)
reporte.append("REPORTE DE DETECCIÓN OCR")
reporte.append("=" * 50)
if resultado_ocr.get('tienda'):
reporte.append(f"\n🏪 Tienda: {resultado_ocr['tienda']}")
if resultado_ocr.get('folio'):
reporte.append(f"📋 Folio: {resultado_ocr['folio']}")
if resultado_ocr.get('fecha_detectada'):
reporte.append(f"📅 Fecha: {resultado_ocr['fecha_detectada']}")
productos = resultado_ocr.get('productos', [])
if productos:
reporte.append(f"\n📦 PRODUCTOS DETECTADOS ({len(productos)}):")
reporte.append("-" * 50)
for i, prod in enumerate(productos, 1):
reporte.append(
f"{i}. {prod['cantidad']}x {prod['marca']} {prod['producto']}"
)
reporte.append(f" Precio unitario: ${prod['precio_unitario']:.2f}")
reporte.append(f" Importe: ${prod['importe']:.2f}")
reporte.append("")
if resultado_ocr.get('subtotal'):
reporte.append(f"Subtotal: ${resultado_ocr['subtotal']:.2f}")
if resultado_ocr.get('iva'):
reporte.append(f"IVA: ${resultado_ocr['iva']:.2f}")
if resultado_ocr.get('monto_detectado'):
reporte.append(f"\n💰 TOTAL: ${resultado_ocr['monto_detectado']:.2f}")
reporte.append("=" * 50)
return "\n".join(reporte)

View File

@@ -0,0 +1,28 @@
# Dependencias para el OCR mejorado
# OCR
pytesseract==0.3.10
# Procesamiento de imágenes
Pillow==10.2.0
opencv-python==4.9.0.80
numpy==1.26.3
# Web framework (si usas Flask)
Flask==3.0.0
gunicorn==21.2.0
# Cliente de Mattermost
mattermostdriver==7.3.2
# HTTP requests
requests==2.31.0
# Variables de entorno
python-dotenv==1.0.0
# Logging
coloredlogs==15.0.1
# Utilidades
python-dateutil==2.8.2

166
sales-bot/utils.py Normal file
View File

@@ -0,0 +1,166 @@
"""
Utilidades para el Sales Bot
"""
import re
import os
import logging
logger = logging.getLogger(__name__)
def validar_token_outgoing(token):
"""
Valida el token de webhooks salientes de Mattermost
"""
if not token:
return False
expected_tokens = [
os.getenv('MATTERMOST_WEBHOOK_SECRET'),
os.getenv('MATTERMOST_OUTGOING_TOKEN'),
]
return token in [t for t in expected_tokens if t]
def extraer_monto(texto):
"""
Extrae el monto de una venta del texto del mensaje.
Soporta formatos:
- @monto 1500
- $1,500.00
- $1500
- 1500 pesos
"""
if not texto:
return None
texto = texto.lower()
# Buscar formato @monto XXXX
patron_monto = r'@monto\s+\$?([\d,]+\.?\d*)'
match = re.search(patron_monto, texto)
if match:
monto_str = match.group(1).replace(',', '')
try:
return float(monto_str)
except ValueError:
pass
# Buscar formato $X,XXX.XX o $XXXX
patron_dinero = r'\$\s*([\d,]+\.?\d*)'
match = re.search(patron_dinero, texto)
if match:
monto_str = match.group(1).replace(',', '')
try:
return float(monto_str)
except ValueError:
pass
# Buscar formato XXXX pesos
patron_pesos = r'([\d,]+\.?\d*)\s*pesos'
match = re.search(patron_pesos, texto)
if match:
monto_str = match.group(1).replace(',', '')
try:
return float(monto_str)
except ValueError:
pass
return None
def extraer_cliente(texto):
"""
Extrae el nombre del cliente del texto del mensaje.
Soporta formatos:
- @cliente Juan Pérez
- cliente: Juan Pérez
- a Juan Pérez
"""
if not texto:
return None
# Buscar formato @cliente NOMBRE
patron_cliente = r'@cliente\s+([^\n@$]+)'
match = re.search(patron_cliente, texto, re.IGNORECASE)
if match:
cliente = match.group(1).strip()
# Limpiar palabras clave que no son parte del nombre
cliente = re.sub(r'\s*@monto.*$', '', cliente, flags=re.IGNORECASE)
return cliente.strip() if cliente.strip() else None
# Buscar formato "cliente: NOMBRE" o "cliente NOMBRE"
patron_cliente2 = r'cliente[:\s]+([^\n@$]+)'
match = re.search(patron_cliente2, texto, re.IGNORECASE)
if match:
cliente = match.group(1).strip()
cliente = re.sub(r'\s*@monto.*$', '', cliente, flags=re.IGNORECASE)
return cliente.strip() if cliente.strip() else None
# Buscar formato "a NOMBRE" (después de un monto)
patron_a = r'\$[\d,\.]+\s+a\s+([^\n@$]+)'
match = re.search(patron_a, texto, re.IGNORECASE)
if match:
cliente = match.group(1).strip()
return cliente.strip() if cliente.strip() else None
return None
def formatear_moneda(valor, simbolo='$', decimales=2):
"""
Formatea un número como moneda.
Ejemplo: 1500.5 -> $1,500.50
"""
if valor is None:
return f"{simbolo}0.00"
try:
valor = float(valor)
return f"{simbolo}{valor:,.{decimales}f}"
except (ValueError, TypeError):
return f"{simbolo}0.00"
def extraer_tubos(texto):
"""
Extrae la cantidad de tubos del texto del mensaje.
Soporta formatos:
- @tubos 5
- tubos: 5
- 5 tubos
"""
if not texto:
return None
texto = texto.lower()
# Buscar formato @tubos XXXX
patron_tubos = r'@tubos\s+(\d+)'
match = re.search(patron_tubos, texto)
if match:
try:
return int(match.group(1))
except ValueError:
pass
# Buscar formato "tubos: X" o "tubos X"
patron_tubos2 = r'tubos[:\s]+(\d+)'
match = re.search(patron_tubos2, texto)
if match:
try:
return int(match.group(1))
except ValueError:
pass
# Buscar formato "X tubos"
patron_tubos3 = r'(\d+)\s*tubos?'
match = re.search(patron_tubos3, texto)
if match:
try:
return int(match.group(1))
except ValueError:
pass
return None

View File

@@ -0,0 +1,175 @@
"""
Listener de Websocket para Mattermost
Escucha mensajes en tiempo real sin depender de outgoing webhooks
"""
import os
import json
import logging
import asyncio
import websockets
import requests
from threading import Thread
logger = logging.getLogger(__name__)
class MattermostWebsocketListener:
def __init__(self, mattermost_client, nocodb_client, handler_func):
self.url = os.getenv('MATTERMOST_URL', '').rstrip('/')
self.token = os.getenv('MATTERMOST_BOT_TOKEN')
self.mattermost = mattermost_client
self.nocodb = nocodb_client
self.handler_func = handler_func
self.ws_url = self.url.replace('http://', 'ws://').replace('https://', 'wss://') + '/api/v4/websocket'
self.bot_user_id = None
self.running = False
def get_bot_user_id(self):
"""Obtiene el ID del bot para ignorar sus propios mensajes"""
try:
response = requests.get(
f"{self.url}/api/v4/users/me",
headers={'Authorization': f'Bearer {self.token}'},
timeout=10
)
response.raise_for_status()
self.bot_user_id = response.json().get('id')
logger.info(f"Bot user ID: {self.bot_user_id}")
return self.bot_user_id
except Exception as e:
logger.error(f"Error obteniendo bot user ID: {e}")
return None
async def listen(self):
"""Escucha mensajes via websocket"""
self.get_bot_user_id()
while self.running:
try:
logger.info(f"Conectando a websocket: {self.ws_url}")
async with websockets.connect(self.ws_url) as ws:
# Autenticar
auth_msg = json.dumps({
"seq": 1,
"action": "authentication_challenge",
"data": {"token": self.token}
})
await ws.send(auth_msg)
logger.info("Autenticación enviada al websocket")
while self.running:
try:
message = await asyncio.wait_for(ws.recv(), timeout=30)
await self.process_message(message)
except asyncio.TimeoutError:
# Enviar ping para mantener conexión
await ws.ping()
except websockets.ConnectionClosed:
logger.warning("Conexión websocket cerrada, reconectando...")
break
except Exception as e:
logger.error(f"Error en websocket: {e}")
if self.running:
logger.info("Reconectando en 5 segundos...")
await asyncio.sleep(5)
async def process_message(self, raw_message):
"""Procesa un mensaje recibido del websocket"""
try:
data = json.loads(raw_message)
event = data.get('event')
# Solo procesar mensajes nuevos (posted)
if event != 'posted':
return
post_data = data.get('data', {})
post_str = post_data.get('post', '{}')
post = json.loads(post_str)
user_id = post.get('user_id')
message = post.get('message', '')
channel_id = post.get('channel_id')
file_ids = post.get('file_ids', [])
# Ignorar mensajes del propio bot
if user_id == self.bot_user_id:
return
# Ignorar mensajes que son respuestas del bot (contienen emojis de confirmación)
if '' in message or '**Venta registrada**' in message or 'Resumen del día' in message:
return
# Verificar si es un mensaje de venta (debe empezar con palabra clave)
message_lower = message.lower().strip()
palabras_clave = ['venta', 'vendi', 'vendí']
es_venta = any(message_lower.startswith(palabra) for palabra in palabras_clave)
if not es_venta and not file_ids:
return
logger.info(f"Mensaje de venta detectado: {message}")
# Obtener información del usuario
try:
user_response = requests.get(
f"{self.url}/api/v4/users/{user_id}",
headers={'Authorization': f'Bearer {self.token}'},
timeout=10
)
user_response.raise_for_status()
user = user_response.json()
username = user.get('username', 'unknown')
except:
username = 'unknown'
# Obtener nombre del canal
try:
channel_response = requests.get(
f"{self.url}/api/v4/channels/{channel_id}",
headers={'Authorization': f'Bearer {self.token}'},
timeout=10
)
channel_response.raise_for_status()
channel = channel_response.json()
channel_name = channel.get('name', 'unknown')
except:
channel_name = 'unknown'
# Construir data similar al webhook
webhook_data = {
'user_name': username,
'user_id': user_id,
'text': message,
'channel_id': channel_id,
'channel_name': channel_name,
'post_id': post.get('id'),
'file_ids': file_ids
}
# Llamar al handler (el handler ya publica via webhook)
self.handler_func(webhook_data, self.mattermost, self.nocodb)
except json.JSONDecodeError:
pass
except Exception as e:
logger.error(f"Error procesando mensaje websocket: {e}")
def start(self):
"""Inicia el listener en un thread separado"""
self.running = True
def run_loop():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(self.listen())
thread = Thread(target=run_loop, daemon=True)
thread.start()
logger.info("Websocket listener iniciado")
return thread
def stop(self):
"""Detiene el listener"""
self.running = False
logger.info("Websocket listener detenido")