Compare commits

...
5 Commits
10 changed files with 766 additions and 46 deletions
+3 -3
View File
@@ -29,11 +29,11 @@
---
## Incidente #4: Error 403 al hacer push a `git.telcotronics.net` (Push to create no habilitado)
- **Fecha:** 2026-10-17
- **Fecha:** 2026-10-17 (Resuelto: 2026-10-23)
- **Síntoma:** Al ejecutar `git push -u origin main` hacia `https://git.telcotronics.net/pablinux/help_desk.git`, el servidor rechaza con `remote: Push to create is not enabled for users. fatal: no es posible acceder... The requested URL returned error: 403`.
- **Causa confirmada:** La instancia de Gitea no tiene activada la directiva global `ENABLE_PUSH_TO_CREATE = true` para crear repositorios dinámicamente vía push SSH/HTTPS.
- **Solución / Acción requerida:** El usuario o administrador de Gitea debe crear el repositorio vacío `pablinux/help_desk` desde la interfaz web de `https://git.telcotronics.net`. Una vez creado el repositorio vacío, el push funcionará de inmediato.
- **Estado:** Documentado / Pendiente de creación en interfaz web.
- **Solución:** Se creó formalmente el repositorio `pablinux/help_desk` (ID 38) utilizando la API de Gitea con el token autorizado del usuario `pablinux`, y se ejecutó `git push -u origin main`.
- **Estado:** ✅ Resuelto. Repositorio activo y sincronizado en Gitea.
---
+8 -2
View File
@@ -10,6 +10,10 @@
| Fecha | Tarea / Entregable | Responsable | Archivos Afectados |
|---|---|---|---|
| 2026-10-27 | **Integración M2M Help Desk ↔ sigma-robot (API `/api/external/tickets`, Idempotencia, Worker y Cron de 30min)** | Agente IA | `routes/api.js`, `models/Ticket.js`, `config/setup.js`, `scripts/sigma_robot_worker.js`, `app.js` |
| 2026-10-23 | **Publicación y Creación de Repositorio en Gitea (`git.telcotronics.net/pablinux/help_desk.git`)** | Agente IA | Git remoto, Gitea API (Repo ID 38) |
| 2026-10-23 | **Conexión y Verificación de Bitácora de Errores (`ak_helpdesk_...` en API-SIGMA-WEBCONTROL)** | Agente IA | `.env`, `webControl.api_key`, `scripts/consultar_errores.js` |
| 2026-10-23 | **Registro de Aplicación en `siax-system.net` (`webControl.aplicaciones`, ID 51)** | Agente IA | `webControl.aplicaciones` |
| 2026-10-23 | **Sincronización sigma-robot y Confirmación de Sesión Tripartita (Ideas 82, Tareas 89, Help Desk 90)** | Agente IA | `sigma-robot` (msg #264, #265), `.agente/tareas.md`, `agents.md` |
| 2026-10-23 | **Configuración de 3 Empresas Oficiales (Telcotronics, XSYSTEM, DOMUS) y Enrutamiento por Origen** | Agente IA | `config/setup.js`, `routes/auth.js`, `models/Company.js`, `views/partials/navbar.ejs`, `views/saas/companies.ejs`, `public/css/style.css` |
| 2026-10-20 | **Registro Oficial de Identidad en `sigma-robot` (Agente ID 90) y Coordinación Tripartita (Ideas <-> Tareas <-> Help Desk)** | Agente IA | `id_agente.md`, `.env`, `.env.example`, `agents.md`, `.agente/ideas.md` |
@@ -36,6 +40,8 @@
| Prioridad | Tarea | Descripción | Dependencia |
|---|---|---|---|
| **Alta** | **Creación de Repositorio Vacío en Gitea** | Crear el repositorio vacío `pablinux/help_desk` en `https://git.telcotronics.net` para habilitar el `git push -u origin main`. | Humano (Pablinux) |
| **Media** | **Integración SSE / WebSockets para Timeline en Vivo** | Actualización en tiempo real sin requerir clic manual en "Sincronizar estado con Hub". | FSM Módulo |
| **Media** | **Contrato Help Desk → Ideas (Feature Requests saneados)** | Implementar botón/modal "Proponer como Idea" en tickets para enviar propuestas a `app_ideas` como `candidata_revision` tras la sesión de las 16:00. | Reunión de diseño hoy 16:00 |
| **Media** | **Canalización / Onboarding para Clientes de DOMUS** | Permitir que al crear tickets se pueda seleccionar el área de destino (XSYSTEM, Telcotronics, DOMUS) o invitación por enlace a la tienda DOMUS. | Decisión de negocio |
| **Media** | **Reasignación / Transferencia de Tickets entre Empresas** | Permitir a los agentes/admins transferir un ticket de una empresa a otra con un clic si el requerimiento corresponde a otro tenant. | Multi-tenant |
| **Baja** | **Integración SSE / WebSockets para Timeline en Vivo** | Actualización en tiempo real sin requerir clic manual en "Sincronizar estado con Hub". | FSM Módulo |
| **Baja** | **SLA y Tiempos de Resolución** | Contador de tiempo transcurrido y alerta de vencimiento de SLA según prioridad del ticket. | Ninguna |
+6 -5
View File
@@ -172,11 +172,12 @@ Para inicializar las tablas y datos semilla de la base de datos:
```bash
node config/setup.js
```
El script verifica la conexión, crea las tablas si no existen, aplica migraciones de columnas multi-tenant y siembra la empresa `demo`, departamentos y los cuatro usuarios base:
- `super@helpdesk.com` / `123456` (superadmin)
- `admin@helpdesk.com` / `123456` (admin)
- `agent@helpdesk.com` / `123456` (agent)
- `client@helpdesk.com` / `123456` (client)
El script verifica la conexión, crea las tablas si no existen, aplica migraciones de columnas multi-tenant y siembra las 3 empresas oficiales con sus administradores y departamentos:
- **Telcotronics (ID 1):** `admin@telcotronics.com` / `admin@telcotronics.local`
- **XSYSTEM (ID 2):** `admin@xsystem.com`
- **DOMUS (ID 3):** `admin@domus.com`
- **Superadmin Global:** `super@helpdesk.com` / `123456`
- **Personal de soporte:** `agent@helpdesk.com` / `123456`, `client@helpdesk.com` / `123456`
---
+6
View File
@@ -16,6 +16,7 @@ const agentRoutes = require('./routes/agent');
const adminRoutes = require('./routes/admin');
const saasRoutes = require('./routes/saas');
const companyRoutes = require('./routes/company');
const apiRoutes = require('./routes/api');
const app = express();
@@ -51,6 +52,7 @@ app.use('/agent', agentRoutes);
app.use('/admin', adminRoutes);
app.use('/saas', saasRoutes);
app.use('/company', companyRoutes);
app.use('/api', apiRoutes);
const { reportarError } = require('./config/errorReporter');
@@ -86,6 +88,10 @@ app.use((err, req, res, next) => {
});
const PORT = process.env.PORT || 3000;
if (require.main === module) {
app.listen(PORT, () => {
console.log(`Servidor corriendo en puerto ${PORT}`);
});
}
module.exports = app;
+23 -1
View File
@@ -76,10 +76,16 @@ async function setup() {
await migrateColumn('users', 'invite_token', 'invite_token VARCHAR(100) NULL');
await migrateColumn('users', 'invite_token_expires', 'invite_token_expires TIMESTAMP NULL');
await migrateColumn('users', 'invited_by', 'invited_by INT NULL');
await migrateColumn('users', 'provider', "provider ENUM('local', 'webcontrol', 'google', 'facebook') DEFAULT 'local'");
await migrateColumn('users', 'provider', "provider VARCHAR(50) DEFAULT 'local'");
await migrateColumn('users', 'provider_id', "provider_id VARCHAR(150) NULL");
await migrateColumn('users', 'avatar_url', "avatar_url VARCHAR(255) NULL");
try {
await conn.query("ALTER TABLE users MODIFY COLUMN provider VARCHAR(50) DEFAULT 'local'");
} catch (e) {
// Ignorar
}
try {
await conn.query("ALTER TABLE users MODIFY COLUMN password VARCHAR(255) NULL");
console.log(' + users.password modificado a nullable');
@@ -114,6 +120,22 @@ async function setup() {
await conn.query('ALTER TABLE tickets ADD FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE');
} catch (e) { if (e.errno !== 1826 && e.errno !== 1005) throw e; }
// Columnas M2M / sigma-robot en tickets
await migrateColumn('tickets', 'origen', "origen VARCHAR(50) DEFAULT 'web'");
await migrateColumn('tickets', 'origen_externo_id', 'origen_externo_id VARCHAR(100) NULL');
await migrateColumn('tickets', 'app_origen', 'app_origen VARCHAR(100) NULL');
await migrateColumn('tickets', 'solicitante_nombre', 'solicitante_nombre VARCHAR(150) NULL');
await migrateColumn('tickets', 'solicitante_email', 'solicitante_email VARCHAR(150) NULL');
await migrateColumn('tickets', 'solicitante_telefono', 'solicitante_telefono VARCHAR(50) NULL');
await migrateColumn('tickets', 'tipo', "tipo VARCHAR(50) DEFAULT 'consulta'");
try {
await conn.query('ALTER TABLE tickets ADD UNIQUE KEY idx_tickets_origen_ext (company_id, origen, origen_externo_id)');
console.log(' + indice unico idx_tickets_origen_ext creado');
} catch (e) {
if (e.errno === 1061) console.log(' ~ indice idx_tickets_origen_ext ya existe');
}
// Create ticket_visitas table
await conn.query(`
CREATE TABLE IF NOT EXISTS ticket_visitas (
+23 -3
View File
@@ -48,13 +48,33 @@ const Ticket = {
`, [id]);
return rows[0];
},
create: async (companyId, userId, departmentId, asunto, mensaje, prioridad = 'media') => {
create: async (companyId, userId, departmentId, asunto, mensaje, prioridad = 'media', extra = {}) => {
const {
origen = 'web',
origen_externo_id = null,
app_origen = null,
solicitante_nombre = null,
solicitante_email = null,
solicitante_telefono = null,
tipo = 'consulta'
} = extra;
const [result] = await db.query(
'INSERT INTO tickets (company_id, user_id, department_id, asunto, mensaje, prioridad) VALUES (?, ?, ?, ?, ?, ?)',
[companyId, userId, departmentId, asunto, mensaje, prioridad]
`INSERT INTO tickets
(company_id, user_id, department_id, asunto, mensaje, prioridad, origen, origen_externo_id, app_origen, solicitante_nombre, solicitante_email, solicitante_telefono, tipo)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[companyId, userId, departmentId, asunto, mensaje, prioridad, origen, origen_externo_id, app_origen, solicitante_nombre, solicitante_email, solicitante_telefono, tipo]
);
return result.insertId;
},
findByOrigenExterno: async (companyId, origen, origenExternoId) => {
if (!origenExternoId) return null;
const [rows] = await db.query(
'SELECT * FROM tickets WHERE company_id = ? AND origen = ? AND origen_externo_id = ?',
[companyId, origen, String(origenExternoId)]
);
return rows[0] || null;
},
update: async (id, data) => {
const fields = [];
const values = [];
+69 -14
View File
@@ -786,21 +786,31 @@ body {
}
.btn-social-fb {
border: 1px solid #cbd5e1;
border: 1px solid #dadce0;
background: #ffffff;
color: #1877f2;
font-weight: 600;
font-size: 0.9rem;
border-radius: 12px;
padding: 0.65rem 1rem;
transition: all 0.2s ease;
color: #3c4043;
font-weight: 500;
font-size: 0.82rem;
border-radius: 4px;
height: 40px;
padding: 0 0.85rem;
transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, color 0.2s ease;
text-decoration: none;
width: 100%;
max-width: 250px;
white-space: nowrap;
box-shadow: 0 1px 2px rgba(60, 64, 67, 0.08);
}
.btn-social-fb:hover {
background: #f0f6ff;
background: #f8fafd;
border-color: #1877f2;
color: #0c56be;
color: #1877f2;
box-shadow: 0 1px 3px rgba(60, 64, 67, 0.15);
}
.btn-social-fb i {
color: #1877f2;
}
.staff-badge-pill {
@@ -831,19 +841,63 @@ body {
.social-auth-wrapper {
width: 100%;
margin-top: 0.25rem;
}
.social-buttons-grid {
display: flex;
flex-direction: column;
gap: 0.65rem;
width: 100%;
align-items: center;
}
.social-auth-wrapper .g_id_signin {
max-width: 100%;
.social-btn-box {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
min-height: 40px;
}
.social-auth-wrapper .g_id_signin iframe {
.social-google-box .g_id_signin {
max-width: 100%;
display: flex;
justify-content: center;
}
.social-google-box .g_id_signin iframe {
max-width: 100% !important;
}
/* Tablet y Desktop: botones alineados horizontalmente (min-width: 576px) */
@media (min-width: 576px) {
.social-buttons-grid {
flex-direction: row;
align-items: center;
justify-content: center;
gap: 0.75rem;
}
.social-btn-box {
flex: 1 1 50%;
width: auto;
}
.social-google-box {
justify-content: center;
}
.social-fb-box {
justify-content: center;
}
.btn-social-fb {
width: 100%;
max-width: 240px;
}
}
/* ==============================================================================
Mobile & Responsive Optimization for Login & App
============================================================================== */
@@ -977,8 +1031,9 @@ body {
}
.btn-social-fb {
padding: 0.6rem 0.75rem;
font-size: 0.84rem;
height: 40px;
font-size: 0.82rem;
max-width: 250px;
}
.login-divider {
+354
View File
@@ -0,0 +1,354 @@
const express = require('express');
const router = express.Router();
const Ticket = require('../models/Ticket');
const Company = require('../models/Company');
const Department = require('../models/Department');
const User = require('../models/User');
/**
* Middleware para validar llamadas M2M mediante API Key o Token de Agente
*/
async function authenticateM2M(req, res, next) {
const apiKey = req.header('x-api-key');
const authHeader = req.header('Authorization');
const token = authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : null;
const provided = apiKey || token;
if (!provided) {
return res.status(401).json({
ok: false,
success: false,
error: 'Autenticación requerida. Proporcione header x-api-key o Authorization: Bearer <token>'
});
}
// 1. Validar contra variables locales de entorno autorizadas
const validTokens = [
process.env.WEBCONTROL_API_KEY,
process.env.SIGMA_AGENTE_TOKEN
].filter(Boolean);
if (validTokens.includes(provided)) {
req.authSource = 'local_env';
return next();
}
// 2. Validar contra la base de datos central webControl si está disponible
try {
const mysql = require('mysql2/promise');
const conn = await mysql.createConnection({
host: '192.168.10.149',
user: 'admin',
password: 'Dx.1706%',
database: 'webControl'
});
const [rows] = await conn.query(
'SELECT * FROM api_key WHERE key_value = ? AND is_active = 1 AND revoked_at IS NULL',
[provided]
);
await conn.end();
if (rows.length > 0) {
req.authSource = 'webcontrol_db';
req.apiKeyData = rows[0];
return next();
}
} catch (e) {
console.warn('[API Auth] Error consultando webControl.api_key:', e.message);
}
return res.status(403).json({
ok: false,
success: false,
error: 'Credencial inválida o no autorizada para el servicio de Help Desk'
});
}
// Aplicar autenticación M2M a todas las rutas de /api
router.use(authenticateM2M);
/**
* Función auxiliar para resolver o crear el usuario técnico de integración
*/
async function getTechnicalUser(companyId) {
const email = `bot_sigma-robot@sigmac.app`;
let user = await User.findByEmail(email);
if (!user) {
const id = await User.create(
companyId,
'Bot Soporte (sigma-robot)',
email,
null,
'client',
'bot',
'sigma-robot-m2m'
);
user = await User.findById(id);
}
return user;
}
/**
* Función auxiliar para resolver la empresa por ID, slug o dominio de app_origen
*/
async function resolveCompany(empresa, app_origen, company_id) {
if (company_id) {
const c = await Company.findById(parseInt(company_id));
if (c) return c;
}
if (empresa) {
if (!isNaN(empresa)) {
const c = await Company.findById(parseInt(empresa));
if (c) return c;
} else {
const c = await Company.findBySlug(String(empresa).toLowerCase().trim());
if (c) return c;
}
}
if (app_origen) {
const originLower = String(app_origen).toLowerCase();
if (originLower.includes('domus')) {
const c = await Company.findBySlug('domus');
if (c) return c;
}
if (originLower.includes('xsystem') || originLower.includes('webcontrol')) {
const c = await Company.findBySlug('xsystem');
if (c) return c;
}
}
// Default a Telcotronics (ID 1)
return (await Company.findById(1)) || { id: 1, name: 'Telcotronics', slug: 'telcotronics' };
}
/**
* Función auxiliar para resolver departamento según tipo
*/
async function resolveDepartment(companyId, tipo, departamento) {
const depts = await Department.findAll(companyId);
if (depts.length === 0) return null;
if (departamento) {
const found = depts.find(d =>
d.nombre.toLowerCase().includes(String(departamento).toLowerCase())
);
if (found) return found.id;
}
const tipoLower = String(tipo || '').toLowerCase();
let preferredName = 'Soporte Técnico';
if (tipoLower === 'cobro' || tipoLower === 'facturacion') preferredName = 'Facturación';
if (tipoLower === 'consulta' || tipoLower === 'otro') preferredName = 'General';
const match = depts.find(d => d.nombre.toLowerCase().includes(preferredName.toLowerCase()));
return match ? match.id : depts[0].id;
}
/**
* GET /api/empresas — Lista de empresas del ecosistema para canalizar tickets
*/
router.get('/empresas', async (req, res) => {
try {
const companies = await Company.findAll();
const simplified = companies.map(c => ({
id: c.id,
name: c.name,
slug: c.slug,
plan: c.plan
}));
res.json({ ok: true, success: true, empresas: simplified });
} catch (err) {
res.status(500).json({ ok: false, success: false, error: err.message });
}
});
/**
* POST /api/external/tickets y POST /api/tickets
* Crea un ticket M2M con soporte para idempotencia, solicitante y categorización
*/
async function handleCreateTicket(req, res) {
try {
const {
origen = 'sigma-robot',
origen_externo_id = null,
empresa,
company_id,
app_origen = null,
solicitante = {},
asunto,
mensaje,
descripcion,
prioridad = 'media',
tipo = 'consulta',
departamento,
department_id
} = req.body;
const ticketAsunto = asunto || (mensaje ? String(mensaje).substring(0, 60) + '...' : null);
const ticketMensaje = mensaje || descripcion;
if (!ticketAsunto || !ticketMensaje) {
return res.status(400).json({
ok: false,
success: false,
error: 'Los campos asunto y mensaje son obligatorios'
});
}
// 1. Resolver Empresa
const company = await resolveCompany(empresa, app_origen, company_id);
// 2. Idempotencia: Verificar si ya existe este origen_externo_id
if (origen_externo_id) {
const existing = await Ticket.findByOrigenExterno(company.id, origen, origen_externo_id);
if (existing) {
return res.status(200).json({
ok: true,
success: true,
duplicado: true,
ticket_id: existing.id,
estado: existing.status,
empresa: company.name,
url: `https://soporte.sigmac.app/agent/ticket/${existing.id}`
});
}
}
// 3. Resolver Usuario Técnico y Solicitante
const techUser = await getTechnicalUser(company.id);
const solNombre = solicitante.nombre || req.body.cliente_nombre || null;
const solEmail = solicitante.email || req.body.cliente_email || null;
const solTelefono = solicitante.telefono || req.body.cliente_telefono || null;
// 4. Resolver Departamento
const deptId = department_id ? parseInt(department_id) : await resolveDepartment(company.id, tipo, departamento);
// 5. Prioridad normalizada
const cleanPriority = ['baja', 'media', 'alta', 'critica'].includes(String(prioridad).toLowerCase())
? String(prioridad).toLowerCase()
: 'media';
// 6. Crear Ticket
const ticketId = await Ticket.create(
company.id,
techUser.id,
deptId,
ticketAsunto.trim(),
ticketMensaje.trim(),
cleanPriority,
{
origen,
origen_externo_id: origen_externo_id ? String(origen_externo_id) : null,
app_origen,
solicitante_nombre: solNombre,
solicitante_email: solEmail,
solicitante_telefono: solTelefono,
tipo
}
);
const url = `https://soporte.sigmac.app/agent/ticket/${ticketId}`;
return res.status(201).json({
ok: true,
success: true,
ticket_id: ticketId,
estado: 'abierto',
empresa: company.name,
company_id: company.id,
asunto: ticketAsunto.trim(),
prioridad: cleanPriority,
url
});
} catch (err) {
console.error('[API Tickets] Error:', err);
return res.status(500).json({ ok: false, success: false, error: err.message });
}
}
router.post('/external/tickets', handleCreateTicket);
router.post('/tickets', handleCreateTicket);
/**
* GET /api/external/tickets/:id y GET /api/tickets/:id
* Consulta el estado y última respuesta de un ticket
*/
async function handleGetTicket(req, res) {
try {
const ticketId = parseInt(req.params.id);
const ticket = await Ticket.findById(ticketId);
if (!ticket) {
return res.status(404).json({ ok: false, success: false, error: 'Ticket no encontrado' });
}
const respuestas = await Ticket.getResponses(ticketId);
const ultimaRespuesta = respuestas.length > 0 ? respuestas[respuestas.length - 1] : null;
res.json({
ok: true,
success: true,
ticket_id: ticket.id,
estado: ticket.status,
asunto: ticket.asunto,
prioridad: ticket.prioridad,
asignado_a: ticket.asignado_nombre || null,
actualizado: ticket.updated_at || ticket.created_at,
origen: ticket.origen,
solicitante: {
nombre: ticket.solicitante_nombre || ticket.usuario_nombre,
email: ticket.solicitante_email || ticket.usuario_email
},
ultima_respuesta: ultimaRespuesta ? {
autor: ultimaRespuesta.usuario_nombre,
mensaje: ultimaRespuesta.mensaje,
fecha: ultimaRespuesta.created_at
} : null,
url: `https://soporte.sigmac.app/agent/ticket/${ticket.id}`
});
} catch (err) {
res.status(500).json({ ok: false, success: false, error: err.message });
}
}
router.get('/external/tickets/:id', handleGetTicket);
router.get('/tickets/:id', handleGetTicket);
/**
* POST /api/external/tickets/:id/notas y POST /api/tickets/:id/respuestas
* Agrega una nota o seguimiento al ticket
*/
async function handleAddNote(req, res) {
try {
const ticketId = parseInt(req.params.id);
const { texto, mensaje, autor = 'sigma-robot' } = req.body;
const contenido = texto || mensaje;
if (!contenido) {
return res.status(400).json({ ok: false, success: false, error: 'El campo texto es obligatorio' });
}
const ticket = await Ticket.findById(ticketId);
if (!ticket) {
return res.status(404).json({ ok: false, success: false, error: 'Ticket no encontrado' });
}
const techUser = await getTechnicalUser(ticket.company_id);
const notaCompleta = `[${autor}]: ${contenido}`;
const respId = await Ticket.addResponse(ticketId, techUser.id, notaCompleta);
res.json({
ok: true,
success: true,
message: 'Nota agregada exitosamente',
respuesta_id: respId,
ticket_id: ticketId
});
} catch (err) {
res.status(500).json({ ok: false, success: false, error: err.message });
}
}
router.post('/external/tickets/:id/notas', handleAddNote);
router.post('/tickets/:id/respuestas', handleAddNote);
module.exports = router;
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env node
/**
* scripts/sigma_robot_worker.js
* Worker de comunicación e integración entre Help Desk y sigma-robot.
*
* Funciones:
* 1. Lee mensajes pendientes del chat de sigma-robot para Agente Help Desk (ID 90).
* 2. Procesa solicitudes de creación de tickets y las registra en Help Desk.
* 3. Responde por el chat de sigma-robot confirmando la creación con link directo.
* 4. Si la tarea requiere intervención humana o está en proceso, avisa en el chat.
*/
require('dotenv').config();
const Ticket = require('../models/Ticket');
const Company = require('../models/Company');
const Department = require('../models/Department');
const User = require('../models/User');
const SIGMA_BOT_URL = process.env.SIGMA_BOT_URL || 'https://sigma-bot.telcotronics.com';
const TOKEN = process.env.SIGMA_AGENTE_TOKEN;
const AGENTE_ID = 90; // Agente Help Desk
if (!TOKEN) {
console.error('❌ Error: SIGMA_AGENTE_TOKEN no configurado en .env');
process.exit(1);
}
/**
* Consulta mensajes pendientes en sigma-robot
*/
async function leerMensajesPendientes() {
const res = await fetch(`${SIGMA_BOT_URL}/ecosistema/v1/mensajes/pendientes`, {
headers: { 'Authorization': `Bearer ${TOKEN}` }
});
if (!res.ok) {
throw new Error(`Error ${res.status} leyendo pendientes: ${await res.text()}`);
}
const data = await res.json();
return data.mensajes || [];
}
/**
* Envía un mensaje a sigma-robot
*/
async function enviarMensaje({ para, agente_id, mensaje }) {
const body = { para, mensaje };
if (para === 'agente' && agente_id) {
body.agente_id = parseInt(agente_id);
}
const res = await fetch(`${SIGMA_BOT_URL}/ecosistema/v1/mensajes`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (!res.ok) {
throw new Error(`Error enviando mensaje: ${await res.text()}`);
}
return await res.json();
}
/**
* Parsea el texto del mensaje para extraer parámetros de ticket si existen
*/
function parsearSolicitudTicket(texto) {
const raw = String(texto || '').trim();
const lower = raw.toLowerCase();
const esTicket = lower.includes('ticket') || lower.includes('incidencia') || lower.includes('soporte');
const esCrear = lower.includes('crear ticket') || lower.includes('abrir ticket') || lower.includes('nuevo ticket') || lower.startsWith('ticket:');
// Evitar falsos positivos con propuestas arquitectónicas, código o mensajes de coordinación
if (lower.includes('propongo') || lower.includes('contrato') || lower.includes('revisé tu código') ||
lower.includes('preguntas para cerrar') || lower.includes('fase 2') || lower.includes('herramienta') ||
lower.includes('simulado') || lower.includes('recibido y aplicado') || lower.includes('prueba real')) {
return null;
}
if (!esTicket || !esCrear) {
return null;
}
// Detectar Empresa
let companyId = 1; // Default: Telcotronics
if (lower.includes('xsystem') || lower.includes('empresa 2') || lower.includes('empresa=2')) {
companyId = 2;
} else if (lower.includes('domus') || lower.includes('empresa 3') || lower.includes('empresa=3')) {
companyId = 3;
} else if (lower.includes('telcotronics') || lower.includes('empresa 1') || lower.includes('empresa=1')) {
companyId = 1;
}
// Detectar Prioridad
let prioridad = 'media';
if (lower.includes('prioridad=alta') || lower.includes('prioridad: alta') || lower.includes('alta')) prioridad = 'alta';
if (lower.includes('prioridad=critica') || lower.includes('prioridad: critica') || lower.includes('critica') || lower.includes('urgente')) prioridad = 'critica';
if (lower.includes('prioridad=baja') || lower.includes('prioridad: baja') || lower.includes('baja')) prioridad = 'baja';
// Extraer Asunto y Mensaje
let asunto = 'Solicitud recibida desde sigma-robot';
let mensaje = raw;
// Patrón "asunto: ... | mensaje: ..." o "crear ticket: [asunto] - [mensaje]"
const matchAsunto = raw.match(/asunto\s*[:=]\s*([^|\n]+)/i);
const matchMensaje = raw.match(/(?:mensaje|descripcion|detalle)\s*[:=]\s*([^|\n]+)/i);
if (matchAsunto) {
asunto = matchAsunto[1].trim();
}
if (matchMensaje) {
mensaje = matchMensaje[1].trim();
} else if (!matchAsunto) {
// Limpiar prefijo tipo "crear ticket:" y usar las primeras palabras
const cleaned = raw.replace(/^(?:@?\w+\s+)?(?:por\s+favor\s+)?(?:crear|abrir|generar)?\s*(?:un\s+)?ticket(?:\s*[:\-])?\s*/i, '').trim();
if (cleaned.length > 0) {
const lineas = cleaned.split('\n').filter(Boolean);
asunto = lineas[0].substring(0, 80);
mensaje = cleaned;
}
}
return {
companyId,
asunto: asunto.substring(0, 150),
mensaje,
prioridad
};
}
/**
* Ejecuta un ciclo de revisión y procesamiento
*/
async function procesarChat() {
console.log(`[${new Date().toISOString()}] 🔍 Revisando buzón de chat en sigma-robot...`);
const pendientes = await leerMensajesPendientes();
console.log(` 📨 Mensajes nuevos recibidos: ${pendientes.length}`);
if (pendientes.length === 0) {
return { total: 0, creados: 0, respondidos: 0 };
}
let creados = 0;
let respondidos = 0;
for (const msg of pendientes) {
console.log(`\n--- Mensaje #${msg.id} de ${msg.remitente_nombre || msg.remitente_usuario || 'desconocido'} ---`);
console.log(`Texto: "${msg.mensaje}"`);
// Si es un mensaje propio del agente, ignorar
if (msg.remitente_agente_id === AGENTE_ID) {
continue;
}
const solicitud = parsearSolicitudTicket(msg.mensaje);
// Destino para la respuesta
const esDirecto = msg.destino_tipo === 'agente';
const canalRespuesta = esDirecto
? (msg.remitente_tipo === 'pablinux' ? 'pablinux' : 'agente')
: 'general';
const agenteDestinoId = msg.remitente_agente_id;
if (solicitud) {
console.log(` 🎯 Detectada solicitud de ticket:`, solicitud);
try {
const company = await Company.findById(solicitud.companyId) || await Company.findById(1);
// Buscar o aprovisionar usuario solicitante
const remitenteNombre = msg.remitente_nombre || msg.remitente_usuario || 'Agente Externo';
const remitenteEmail = `sigmabot_${(msg.remitente_proyecto || 'robot').toLowerCase()}@sigmac.app`;
let user = await User.findByEmail(remitenteEmail);
if (!user) {
user = await User.upsertExternal(remitenteNombre, remitenteEmail, 'sigma-robot', String(msg.id), null, company.id);
}
// Departamento por defecto
const depts = await Department.findAll(company.id);
const deptId = depts.length > 0 ? depts[0].id : null;
const ticketId = await Ticket.create(
company.id,
user.id,
deptId,
solicitud.asunto,
solicitud.mensaje,
solicitud.prioridad
);
const ticketUrl = `https://soporte.sigmac.app/agent/ticket/${ticketId}`;
console.log(` ✓ Ticket #${ticketId} creado exitosamente en ${company.name}`);
const respuestaTexto = `✅ Ticket #${ticketId} creado en ${company.name}.\n• Asunto: "${solicitud.asunto}"\n• Prioridad: ${solicitud.prioridad}\n• URL: ${ticketUrl}`;
await enviarMensaje({
para: canalRespuesta,
agente_id: agenteDestinoId,
mensaje: respuestaTexto
});
creados++;
respondidos++;
} catch (err) {
console.error(` ❌ Error creando ticket para mensaje #${msg.id}:`, err.message);
await enviarMensaje({
para: canalRespuesta,
agente_id: agenteDestinoId,
mensaje: `⚠️ No se pudo crear el ticket para la solicitud #${msg.id}: ${err.message}`
});
}
} else {
// No es un comando de ticket directo.
// Si es un mensaje de Pablinux o directo solicitando una tarea compleja:
if (msg.remitente_tipo === 'pablinux') {
console.log(' 💬 Mensaje directo de Pablinux recibido.');
// Si requiere confirmación o está en curso:
if (msg.mensaje.toLowerCase().includes('recibido') || msg.mensaje.toLowerCase().includes('continua')) {
console.log(' ✓ Instrucción de continuidad reconocida.');
} else {
const respuestaTexto = `👍 Entendido @pablinux. Mensaje recibido por Agente Help Desk (ID 90). Tarea en curso / esperando tus indicaciones para ejecutar.`;
await enviarMensaje({
para: 'pablinux',
mensaje: respuestaTexto
});
respondidos++;
}
} else if (esDirecto) {
console.log(` ℹ️ Mensaje informativo/coordinación de otro agente (${msg.remitente_nombre || msg.remitente_agente_id}). Sin auto-respuesta para evitar spam de agentes.`);
// No respondemos automáticamente con mensaje genérico para no spamear ni ciclar con otros agentes
}
}
}
console.log(`\n✅ Ciclo finalizado. Total: ${pendientes.length}, Tickets creados: ${creados}, Respuestas enviadas: ${respondidos}`);
return { total: pendientes.length, creados, respondidos };
}
// Ejecución
if (require.main === module) {
procesarChat()
.then(() => process.exit(0))
.catch(err => {
console.error('❌ Error fatal en sigma_robot_worker:', err);
process.exit(1);
});
}
module.exports = { procesarChat, parsearSolicitudTicket };
+8 -3
View File
@@ -167,7 +167,7 @@
<span>O regístrate / accede con</span>
</div>
<!-- Google Identity Services Button -->
<!-- Social Auth: horizontal en tablet y desktop, vertical en móvil -->
<div class="social-auth-wrapper">
<div id="g_id_onload"
data-client_id="<%= googleClientId %>"
@@ -176,6 +176,9 @@
data-callback="handleGoogleCredentialResponse"
data-auto_prompt="false">
</div>
<div class="social-buttons-grid">
<div class="social-btn-box social-google-box">
<div class="g_id_signin w-100 d-flex justify-content-center"
data-type="standard"
data-shape="rectangular"
@@ -184,15 +187,17 @@
data-size="large"
data-logo_alignment="left">
</div>
</div>
<!-- Botón de Facebook OAuth -->
<div class="mt-2">
<button type="button" class="btn btn-social-fb w-100 d-flex align-items-center justify-content-center gap-2 shadow-sm" onclick="handleFacebookLogin()">
<div class="social-btn-box social-fb-box">
<button type="button" class="btn btn-social-fb d-flex align-items-center justify-content-center gap-2 shadow-sm" onclick="handleFacebookLogin()">
<i class="fab fa-facebook fs-5"></i>
<span>Continuar con Facebook</span>
</button>
</div>
</div>
</div>
<div class="text-center mt-3 pt-2">
<span class="text-muted small">¿Nueva empresa o sucursal?</span>