@alvarobr/n8n-nodes-glpi-connect 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ Creado por **Álvaro Blanca Rodríguez**.
|
|
|
28
28
|
- **Detalle**: técnicos, grupos, seguimientos y tareas públicos, soluciones, aprobaciones y activos asociados.
|
|
29
29
|
- **Crear**: incidencia o solicitud con el usuario como solicitante y, opcionalmente, su equipo afectado. Antes de crearla avisa de posibles duplicados.
|
|
30
30
|
- **Añadir comentario**: seguimiento firmado con el nombre del usuario. En un ticket resuelto solicita su reapertura.
|
|
31
|
-
- **Cerrar o cancelar**: registra la solución
|
|
31
|
+
- **Cerrar o cancelar**: registra un seguimiento con la confirmación del usuario, asigna el ticket a la cuenta técnica de la credencial (sin quitar a los técnicos anteriores), añade la solución, lo cierra y vuelve a consultarlo para confirmar el estado final. El motivo puede ser «solucionado», «cancelado» o «acción automática completada».
|
|
32
32
|
- **Asociar activo**: relación nativa ticket-elemento de GLPI, solo con activos del usuario y sin duplicados.
|
|
33
33
|
|
|
34
34
|
### Activos
|
|
@@ -38,6 +38,8 @@ Creado por **Álvaro Blanca Rodríguez**.
|
|
|
38
38
|
### Otros
|
|
39
39
|
- **Usuario → Identificar por correo**: usuario de GLPI asociado a un correo.
|
|
40
40
|
- **Base de conocimiento → Buscar preguntas frecuentes**: artículos FAQ públicos.
|
|
41
|
+
- **Base de conocimiento → Índice de preguntas frecuentes**: títulos e ID de todas las FAQ públicas. Páselo al prompt del agente para que sepa qué artículos existen y no busque a ciegas.
|
|
42
|
+
- **Base de conocimiento → Leer artículo**: contenido completo de una FAQ pública por su ID.
|
|
41
43
|
- **Petición API → Petición personalizada**: cualquier ruta de `apirest.php` con la sesión ya gestionada. No comprueba permisos por usuario, así que **no debe conectarse a un agente de IA**.
|
|
42
44
|
|
|
43
45
|
## Seguridad
|
|
@@ -23,6 +23,7 @@ class GlpiClient {
|
|
|
23
23
|
this.config = config;
|
|
24
24
|
this.http = http;
|
|
25
25
|
this.sessionToken = null;
|
|
26
|
+
this.sessionUser = null;
|
|
26
27
|
const base = config.url.trim().replace(/\/+$/, '').replace(/\/apirest\.php$/, '');
|
|
27
28
|
this.api = `${base}/apirest.php`;
|
|
28
29
|
this.front = `${base}/front`;
|
|
@@ -59,6 +60,18 @@ class GlpiClient {
|
|
|
59
60
|
// La sesión caducará sola en GLPI
|
|
60
61
|
}
|
|
61
62
|
this.sessionToken = null;
|
|
63
|
+
this.sessionUser = null;
|
|
64
|
+
}
|
|
65
|
+
/** ID del usuario de GLPI con el que se ha iniciado sesión (la cuenta técnica del token). */
|
|
66
|
+
async sessionUserId() {
|
|
67
|
+
if (this.sessionUser)
|
|
68
|
+
return this.sessionUser;
|
|
69
|
+
const body = await this.get('getFullSession');
|
|
70
|
+
const id = Number(body?.session?.glpiID);
|
|
71
|
+
if (!Number.isSafeInteger(id) || id <= 0)
|
|
72
|
+
throw new GlpiError('GLPI no ha indicado el usuario de la sesión.', 200, 'NO_SESSION_USER');
|
|
73
|
+
this.sessionUser = id;
|
|
74
|
+
return id;
|
|
62
75
|
}
|
|
63
76
|
/** Petición que devuelve el estado y el cuerpo sin lanzar error (para decidir según la respuesta). */
|
|
64
77
|
async raw(method, path, options = {}) {
|
|
@@ -80,7 +80,7 @@ const properties = [
|
|
|
80
80
|
{ name: 'Detalle', value: 'detalle', action: 'Ver el detalle de un ticket del usuario', description: 'Técnicos, seguimientos, tareas, soluciones, aprobaciones y activos asociados' },
|
|
81
81
|
{ name: 'Crear', value: 'crear', action: 'Crear un ticket para el usuario', description: 'Crea una incidencia o solicitud, opcionalmente asociada a un activo del usuario' },
|
|
82
82
|
{ name: 'Añadir comentario', value: 'comentar', action: 'Añadir un comentario a un ticket del usuario', description: 'Añade un seguimiento; en un ticket resuelto solicita su reapertura' },
|
|
83
|
-
{ name: 'Cerrar o cancelar', value: 'cerrar', action: 'Cerrar o cancelar un ticket del usuario', description: 'Registra la
|
|
83
|
+
{ name: 'Cerrar o cancelar', value: 'cerrar', action: 'Cerrar o cancelar un ticket del usuario', description: 'Registra un seguimiento, asigna el ticket a la cuenta de la integración, lo cierra y comprueba el estado final' },
|
|
84
84
|
{ name: 'Asociar activo', value: 'asociarActivo', action: 'Asociar un activo del usuario a su ticket', description: 'Crea la relación nativa ticket-elemento de GLPI' },
|
|
85
85
|
],
|
|
86
86
|
default: 'listar',
|
|
@@ -112,7 +112,11 @@ const properties = [
|
|
|
112
112
|
type: 'options',
|
|
113
113
|
noDataExpression: true,
|
|
114
114
|
displayOptions: show(['conocimiento']),
|
|
115
|
-
options: [
|
|
115
|
+
options: [
|
|
116
|
+
{ name: 'Buscar preguntas frecuentes', value: 'buscar', action: 'Buscar en la base de conocimiento' },
|
|
117
|
+
{ name: 'Índice de preguntas frecuentes', value: 'indice', action: 'Listar los títulos de la base de conocimiento', description: 'Títulos e ID de las preguntas frecuentes públicas, para que la IA sepa qué hay antes de consultar' },
|
|
118
|
+
{ name: 'Leer artículo', value: 'leer', action: 'Leer un artículo de la base de conocimiento', description: 'Contenido completo de una pregunta frecuente por su ID' },
|
|
119
|
+
],
|
|
116
120
|
default: 'buscar',
|
|
117
121
|
},
|
|
118
122
|
{
|
|
@@ -262,6 +266,7 @@ const properties = [
|
|
|
262
266
|
options: [
|
|
263
267
|
{ name: 'Solucionado', value: 'solucionado' },
|
|
264
268
|
{ name: 'Cancelado (ya no se necesita)', value: 'cancelado' },
|
|
269
|
+
{ name: 'Acción automática completada', value: 'automatizado' },
|
|
265
270
|
],
|
|
266
271
|
default: 'solucionado',
|
|
267
272
|
displayOptions: show(['ticket'], ['cerrar']),
|
|
@@ -349,7 +354,15 @@ const properties = [
|
|
|
349
354
|
required: true,
|
|
350
355
|
default: '',
|
|
351
356
|
placeholder: 'vpn',
|
|
352
|
-
displayOptions: show(['conocimiento']),
|
|
357
|
+
displayOptions: show(['conocimiento'], ['buscar']),
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
displayName: 'ID del artículo',
|
|
361
|
+
name: 'articuloId',
|
|
362
|
+
type: 'number',
|
|
363
|
+
required: true,
|
|
364
|
+
default: 0,
|
|
365
|
+
displayOptions: show(['conocimiento'], ['leer']),
|
|
353
366
|
},
|
|
354
367
|
// --- Petición API ---------------------------------------------------
|
|
355
368
|
{
|
|
@@ -461,6 +474,10 @@ async function runOperation(ctx, client, users, i) {
|
|
|
461
474
|
const resource = ctx.getNodeParameter('resource', i);
|
|
462
475
|
const operation = ctx.getNodeParameter('operation', i);
|
|
463
476
|
if (resource === 'conocimiento') {
|
|
477
|
+
if (operation === 'indice')
|
|
478
|
+
return ops.faqIndex(client);
|
|
479
|
+
if (operation === 'leer')
|
|
480
|
+
return ops.readFaq(client, Number(ctx.getNodeParameter('articuloId', i)));
|
|
464
481
|
return ops.searchFaq(client, ctx.getNodeParameter('busqueda', i));
|
|
465
482
|
}
|
|
466
483
|
if (resource === 'api') {
|
|
@@ -21,6 +21,8 @@ exports.linkAsset = linkAsset;
|
|
|
21
21
|
exports.createTicket = createTicket;
|
|
22
22
|
exports.myAssets = myAssets;
|
|
23
23
|
exports.computerSheet = computerSheet;
|
|
24
|
+
exports.faqIndex = faqIndex;
|
|
25
|
+
exports.readFaq = readFaq;
|
|
24
26
|
exports.searchFaq = searchFaq;
|
|
25
27
|
exports.apiRequest = apiRequest;
|
|
26
28
|
const GlpiClient_1 = require("./GlpiClient");
|
|
@@ -341,6 +343,28 @@ async function addFollowup(client, user, ticketId, text, control) {
|
|
|
341
343
|
return { ok: false, ejecutado: false, error: 'GLPI no ha aceptado el comentario. No se ha modificado el ticket.', detalle: glpiDetail(error), ticket };
|
|
342
344
|
}
|
|
343
345
|
}
|
|
346
|
+
const CLOSE_TEXTS = {
|
|
347
|
+
solucionado: {
|
|
348
|
+
label: 'Cerrar como solucionado',
|
|
349
|
+
followup: 'El usuario ha confirmado la resolución de la incidencia.',
|
|
350
|
+
solution: 'El usuario indica que el problema se ha solucionado.',
|
|
351
|
+
},
|
|
352
|
+
cancelado: {
|
|
353
|
+
label: 'Cancelar',
|
|
354
|
+
followup: 'El usuario ha confirmado que ya no necesita esta solicitud y pide cancelarla.',
|
|
355
|
+
solution: 'Solicitud cancelada por el usuario: ya no la necesita.',
|
|
356
|
+
},
|
|
357
|
+
automatizado: {
|
|
358
|
+
label: 'Cerrar tras la acción automática',
|
|
359
|
+
followup: 'Acción ejecutada automáticamente por el asistente a petición del usuario.',
|
|
360
|
+
solution: 'Resuelto mediante una acción automática del asistente de soporte.',
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
/**
|
|
364
|
+
* Cierre completo: seguimiento, asignación a la cuenta técnica de la integración,
|
|
365
|
+
* solución, estado Cerrado y comprobación final en GLPI.
|
|
366
|
+
* Los técnicos, seguimientos y soluciones anteriores se conservan.
|
|
367
|
+
*/
|
|
344
368
|
async function closeTicket(client, user, ticketId, reason, text, control) {
|
|
345
369
|
const access = await loadEditableTicket(client, user, ticketId, true);
|
|
346
370
|
if (!isAccess(access))
|
|
@@ -348,34 +372,81 @@ async function closeTicket(client, user, ticketId, reason, text, control) {
|
|
|
348
372
|
const ticket = access.summary;
|
|
349
373
|
if (ticket.estado_id === 6)
|
|
350
374
|
return { ok: true, ejecutado: false, mensaje: `El ticket #${ticket.id} ya estaba cerrado.`, ticket };
|
|
375
|
+
const texts = CLOSE_TEXTS[reason] ?? CLOSE_TEXTS.solucionado;
|
|
351
376
|
const explanation = String(text ?? '').trim();
|
|
352
|
-
const
|
|
377
|
+
const extra = explanation ? `<p>${(0, utils_1.escapeHtml)(explanation)}</p>` : '';
|
|
353
378
|
const { execute, warning } = decide(control);
|
|
354
379
|
if (!execute) {
|
|
355
|
-
return preview(`${label} el ticket #${ticket.id} «${ticket.titulo}»${explanation ? ` indicando: "${explanation}"` : ''}`, warning, { ticket });
|
|
380
|
+
return preview(`${texts.label} el ticket #${ticket.id} «${ticket.titulo}»${explanation ? ` indicando: "${explanation}"` : ''}`, warning, { ticket });
|
|
381
|
+
}
|
|
382
|
+
// 1. Seguimiento con la confirmación del usuario
|
|
383
|
+
try {
|
|
384
|
+
await client.call('POST', 'ITILFollowup', {
|
|
385
|
+
body: { input: { itemtype: 'Ticket', items_id: ticket.id, is_private: 0, content: `<p>${texts.followup}</p>${extra}${signature(user)}` } },
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
return { ok: false, ejecutado: false, error: `GLPI no ha permitido registrar el seguimiento del ticket #${ticket.id}. No se ha modificado.`, detalle: glpiDetail(error), ticket };
|
|
390
|
+
}
|
|
391
|
+
// 2. Asignar a la cuenta técnica (se añade; los técnicos anteriores se mantienen)
|
|
392
|
+
const avisos = [];
|
|
393
|
+
try {
|
|
394
|
+
const agentId = await client.sessionUserId();
|
|
395
|
+
const actors = await client.get(`Ticket/${ticket.id}/Ticket_User`).catch(() => []);
|
|
396
|
+
const assigned = (Array.isArray(actors) ? actors : []).some((a) => a && Number(a.users_id) === agentId && Number(a.type) === 2);
|
|
397
|
+
if (!assigned)
|
|
398
|
+
await client.call('POST', 'Ticket_User', { body: { input: { tickets_id: ticket.id, users_id: agentId, type: 2 } } });
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
avisos.push(`No se ha podido asignar el ticket a la cuenta técnica: ${glpiDetail(error)}`);
|
|
356
402
|
}
|
|
357
|
-
//
|
|
403
|
+
// 3. Solución (GLPI la necesita para pasar a Resuelto) y 4. Cerrado
|
|
358
404
|
if (ticket.estado_id !== 5) {
|
|
359
|
-
const heading = reason === 'cancelado' ? 'Solicitud cancelada por el usuario: ya no la necesita.' : 'El usuario indica que el problema se ha solucionado.';
|
|
360
405
|
try {
|
|
361
406
|
await client.call('POST', 'ITILSolution', {
|
|
362
|
-
body: { input: { itemtype: 'Ticket', items_id: ticket.id, content: `<p>${
|
|
407
|
+
body: { input: { itemtype: 'Ticket', items_id: ticket.id, content: `<p>${texts.solution}</p>${extra}${signature(user)}` } },
|
|
363
408
|
});
|
|
364
409
|
}
|
|
365
410
|
catch (error) {
|
|
366
|
-
return
|
|
411
|
+
return (0, utils_1.compact)({
|
|
412
|
+
ok: false,
|
|
413
|
+
ejecutado: 'parcial',
|
|
414
|
+
error: `Se ha registrado el seguimiento, pero GLPI no ha permitido resolver el ticket #${ticket.id}, así que sigue abierto.`,
|
|
415
|
+
detalle: glpiDetail(error),
|
|
416
|
+
avisos: avisos.length ? avisos : undefined,
|
|
417
|
+
ticket,
|
|
418
|
+
});
|
|
367
419
|
}
|
|
368
420
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
421
|
+
await client.raw('PUT', `Ticket/${ticket.id}`, { body: { input: { id: ticket.id, status: 6 } } });
|
|
422
|
+
// 5. Comprobar en GLPI el estado final antes de dar el cierre por hecho
|
|
423
|
+
const check = await client.raw('GET', `Ticket/${ticket.id}`);
|
|
424
|
+
const finalStatus = check.statusCode < 300 && check.body && !Array.isArray(check.body) ? Number(check.body.status) : NaN;
|
|
425
|
+
const final = { ...ticket, estado: utils_1.TICKET_STATUS[finalStatus] ?? 'Desconocido', estado_id: finalStatus };
|
|
426
|
+
const notes = avisos.length ? avisos : undefined;
|
|
427
|
+
if (finalStatus === 6) {
|
|
428
|
+
return (0, utils_1.compact)({ ok: true, ejecutado: true, verificado: true, mensaje: `GLPI confirma que el ticket #${ticket.id} está cerrado.`, avisos: notes, ticket: final });
|
|
429
|
+
}
|
|
430
|
+
if (finalStatus === 5) {
|
|
431
|
+
return (0, utils_1.compact)({
|
|
432
|
+
ok: true,
|
|
433
|
+
ejecutado: 'parcial',
|
|
434
|
+
verificado: true,
|
|
435
|
+
mensaje: `El ticket #${ticket.id} ha quedado como Resuelto, pero GLPI no ha permitido cerrarlo directamente. Se cerrará automáticamente según la configuración de GLPI.`,
|
|
436
|
+
avisos: notes,
|
|
437
|
+
ticket: final,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
return (0, utils_1.compact)({
|
|
441
|
+
ok: false,
|
|
375
442
|
ejecutado: 'parcial',
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
443
|
+
verificado: Number.isFinite(finalStatus),
|
|
444
|
+
error: Number.isFinite(finalStatus)
|
|
445
|
+
? `GLPI indica que el ticket #${ticket.id} sigue en estado «${final.estado}». No se ha cerrado.`
|
|
446
|
+
: `No se ha podido comprobar en GLPI el estado final del ticket #${ticket.id}.`,
|
|
447
|
+
avisos: notes,
|
|
448
|
+
ticket: final,
|
|
449
|
+
});
|
|
379
450
|
}
|
|
380
451
|
/** Comprueba que un activo esté asignado al usuario. */
|
|
381
452
|
async function loadOwnAsset(client, user, itemtype, itemsId) {
|
|
@@ -638,6 +709,37 @@ async function computerSheet(client, user, computerId, search) {
|
|
|
638
709
|
// ----------------------------------------------------------------------
|
|
639
710
|
// Base de conocimiento
|
|
640
711
|
// ----------------------------------------------------------------------
|
|
712
|
+
const FAQ_INDEX_LIMIT = 200;
|
|
713
|
+
/** Solo las preguntas frecuentes se consideran públicas para el usuario. */
|
|
714
|
+
function isPublicFaq(article) {
|
|
715
|
+
return !!article && typeof article === 'object' && (article.is_faq === undefined || Number(article.is_faq) === 1);
|
|
716
|
+
}
|
|
717
|
+
/** Títulos de todas las preguntas frecuentes, para que la IA sepa qué existe antes de consultar. */
|
|
718
|
+
async function faqIndex(client) {
|
|
719
|
+
const response = await client.raw('GET', 'KnowbaseItem', { qs: { range: `0-${FAQ_INDEX_LIMIT - 1}`, sort: 'name', order: 'ASC' } });
|
|
720
|
+
if (response.statusCode >= 300 || !Array.isArray(response.body)) {
|
|
721
|
+
return { ok: false, error: 'No se ha podido consultar la base de conocimiento.' };
|
|
722
|
+
}
|
|
723
|
+
const articles = response.body.filter(isPublicFaq).map((a) => ({ id: Number(a.id), titulo: (0, utils_1.plainText)(a.name, 200) }));
|
|
724
|
+
return (0, utils_1.compact)({
|
|
725
|
+
ok: true,
|
|
726
|
+
total: articles.length,
|
|
727
|
+
articulos: articles,
|
|
728
|
+
aviso: response.body.length >= FAQ_INDEX_LIMIT ? `Solo se muestran los ${FAQ_INDEX_LIMIT} primeros artículos.` : undefined,
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
/** Contenido completo de una pregunta frecuente. */
|
|
732
|
+
async function readFaq(client, articleId) {
|
|
733
|
+
const id = Number(articleId);
|
|
734
|
+
if (!Number.isSafeInteger(id) || id <= 0)
|
|
735
|
+
return { ok: false, error: 'Falta el ID del artículo. Use el índice de la base de conocimiento.' };
|
|
736
|
+
const response = await client.raw('GET', `KnowbaseItem/${id}`);
|
|
737
|
+
if (response.statusCode >= 300 || !response.body || Array.isArray(response.body) || !isPublicFaq(response.body)) {
|
|
738
|
+
return { ok: false, error: `No existe una pregunta frecuente pública con el ID ${id}.` };
|
|
739
|
+
}
|
|
740
|
+
const a = response.body;
|
|
741
|
+
return { ok: true, articulo: { id, titulo: (0, utils_1.plainText)(a.name, 200), contenido: (0, utils_1.plainText)(a.answer, 8000), enlace: `${client.front}/helpdesk.faq.php?id=${id}` } };
|
|
742
|
+
}
|
|
641
743
|
async function searchFaq(client, text) {
|
|
642
744
|
const term = String(text ?? '').trim();
|
|
643
745
|
if (term.length < 2)
|
|
@@ -651,10 +753,9 @@ async function searchFaq(client, text) {
|
|
|
651
753
|
if (r.statusCode >= 300 || !Array.isArray(r.body))
|
|
652
754
|
continue;
|
|
653
755
|
for (const a of r.body) {
|
|
654
|
-
if (!a ||
|
|
756
|
+
if (!a || seen.has(a.id))
|
|
655
757
|
continue;
|
|
656
|
-
|
|
657
|
-
if (a.is_faq !== undefined && Number(a.is_faq) !== 1)
|
|
758
|
+
if (!isPublicFaq(a))
|
|
658
759
|
continue;
|
|
659
760
|
seen.add(a.id);
|
|
660
761
|
articles.push({ id: a.id, titulo: (0, utils_1.plainText)(a.name, 200), contenido: (0, utils_1.plainText)(a.answer, 1500), enlace: `${client.front}/helpdesk.faq.php?id=${a.id}` });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alvarobr/n8n-nodes-glpi-connect",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Nodo de n8n para GLPI: autoservicio IT seguro para flujos y agentes de IA. Tickets, inventario, software y base de conocimiento con sesión automática, permisos por usuario y confirmación de cambios.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"n8n-community-node-package",
|