@saulwade/swl-ses 1.3.8 → 1.4.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.
Files changed (128) hide show
  1. package/CLAUDE.md +12 -4
  2. package/README.md +1 -1
  3. package/bin/swl-mcp-server.js +187 -187
  4. package/bin/swl-webhook-server.js +198 -0
  5. package/comandos/swl/.evolved.json +22 -22
  6. package/comandos/swl/adoptar-proyecto.md +21 -1
  7. package/comandos/swl/claudemd.md +14 -1
  8. package/comandos/swl/contribuir.md +233 -233
  9. package/comandos/swl/exportar-vault.md +108 -0
  10. package/comandos/swl/nuevo-proyecto.md +24 -2
  11. package/gateway/adapters/base.js +109 -0
  12. package/gateway/adapters/discord.js +167 -0
  13. package/gateway/adapters/email.js +221 -0
  14. package/gateway/adapters/slack.js +192 -0
  15. package/gateway/adapters/telegram.js +183 -0
  16. package/gateway/adapters/webhook.js +113 -0
  17. package/gateway/adapters/whatsapp.js +214 -0
  18. package/gateway/agent-executor.js +322 -0
  19. package/gateway/command-relay.js +271 -0
  20. package/gateway/cron/jobs.js +263 -0
  21. package/gateway/cron/scheduler.js +322 -0
  22. package/gateway/cron/store.js +335 -0
  23. package/gateway/index.js +320 -0
  24. package/gateway/lib/event-channel.js +191 -0
  25. package/gateway/session.js +131 -0
  26. package/gateway/webhook-server.js +324 -0
  27. package/habilidades/backend-production-resilience/SKILL.md +288 -288
  28. package/habilidades/benchmark-memoria/SKILL.md +186 -186
  29. package/habilidades/build-errors-nextjs/SKILL.md +55 -1
  30. package/habilidades/diagrama-arquitectura/assets/template.html +276 -276
  31. package/habilidades/doubt-driven-review/SKILL.md +171 -171
  32. package/habilidades/doubt-driven-review/recursos/EXAMPLES.md +130 -130
  33. package/habilidades/eval-framework/SKILL.md +212 -212
  34. package/habilidades/extractor-de-aprendizajes/SKILL.md +20 -10
  35. package/habilidades/harness-claude-code/SKILL.md +299 -299
  36. package/habilidades/infra-github-actions/SKILL.md +166 -166
  37. package/habilidades/legacy-code-rescue/SKILL.md +267 -267
  38. package/habilidades/manejo-errores/.evolved.json +8 -8
  39. package/habilidades/meta-skills-estandar/recursos/convencion-examples.md +93 -93
  40. package/habilidades/meta-skills-estandar/recursos/skills-as-agents.md +163 -163
  41. package/habilidades/nextjs-testing/SKILL.md +89 -5
  42. package/habilidades/node-experto/SKILL.md +37 -1
  43. package/habilidades/patrones-python/SKILL.md +229 -229
  44. package/habilidades/patrones-python/recursos/patrones-avanzados.md +469 -469
  45. package/habilidades/planear-fase/SKILL.md +319 -319
  46. package/habilidades/react-experto/SKILL.md +45 -4
  47. package/habilidades/release-semver/.evolved.json +8 -8
  48. package/habilidades/tdd-workflow/SKILL.md +36 -4
  49. package/habilidades/testing-python/SKILL.md +340 -340
  50. package/hooks/claudemd-bloat-detector.js +161 -161
  51. package/hooks/inyeccion-contexto.js +8 -3
  52. package/hooks/lib/agent-routing.js +107 -107
  53. package/hooks/lib/auto-consolidator.js +335 -335
  54. package/hooks/lib/error-classifier.js +308 -308
  55. package/hooks/lib/merkle-audit.js +96 -96
  56. package/hooks/lib/provenance-tracker.js +191 -191
  57. package/hooks/lib/rate-limit-ip.js +177 -0
  58. package/hooks/lib/rate-limit-tracker.js +253 -253
  59. package/hooks/lib/resource-quota.js +122 -122
  60. package/hooks/lib/retry-jitter.js +165 -165
  61. package/hooks/lib/skill-auditor.js +588 -588
  62. package/hooks/lib/sync-status.js +228 -228
  63. package/hooks/lib/taint-tracker.js +107 -107
  64. package/hooks/lib/text-similarity.js +241 -241
  65. package/hooks/lib/toon-compressor.js +245 -245
  66. package/hooks/lib/webhook-dedup.js +184 -0
  67. package/hooks/lib/webhook-verify.js +123 -0
  68. package/hooks/proteccion-rutas.js +120 -15
  69. package/hooks/registro-turnos.js +209 -209
  70. package/hooks/sugerir-regenerar-inventario.js +170 -170
  71. package/hooks/validar-formato-post-subagente.js +140 -140
  72. package/hooks/validar-memoria-hook.js +218 -218
  73. package/instintos/prompt-appendices.yaml +57 -57
  74. package/manifiestos/agent-output-schemas.json +57 -57
  75. package/manifiestos/modulos.json +1 -0
  76. package/manifiestos/skills-lock.json +34 -34
  77. package/package.json +5 -3
  78. package/plantillas/auditor-veto-template.md +105 -105
  79. package/plantillas/github-workflows/README.md +47 -47
  80. package/plantillas/github-workflows/release-please.yml +44 -44
  81. package/plantillas/github-workflows/swl-ci.yml +107 -107
  82. package/plantillas/github-workflows/swl-security.yml +51 -51
  83. package/plugin.json +1 -1
  84. package/reglas/analisis-previo-tareas-grandes.md +172 -172
  85. package/reglas/arreglar-al-detectar.md +147 -147
  86. package/reglas/fragmentos-compartidos.md +152 -152
  87. package/reglas/harness-claude-code.md +213 -213
  88. package/reglas/usar-context7.md +226 -226
  89. package/reglas/usar-sistema-swl.md +251 -0
  90. package/schemas/diary-entry.schema.json +80 -80
  91. package/scripts/benchmark-memoria.js +167 -167
  92. package/scripts/comandos/skills.js +251 -2
  93. package/scripts/configurar-branch-protection.js +418 -418
  94. package/scripts/detectar-aprendizajes-duplicados.js +151 -151
  95. package/scripts/field-report.js +199 -199
  96. package/scripts/generar-checklists-consolidados.js +273 -273
  97. package/scripts/generar-inventario.js +420 -420
  98. package/scripts/generar-matriz-lenguajes.js +271 -271
  99. package/scripts/lib/artefactos-python.js +43 -43
  100. package/scripts/lib/benchmark-metrics.js +160 -160
  101. package/scripts/lib/budget-enforcer.js +252 -252
  102. package/scripts/lib/configurar-ci.js +380 -380
  103. package/scripts/lib/contadores-inventario.js +217 -217
  104. package/scripts/lib/detectar-stack-detallado.js +307 -307
  105. package/scripts/lib/diary-entry.js +234 -234
  106. package/scripts/lib/eval-metrics-store.js +218 -218
  107. package/scripts/lib/eval-quality.js +171 -171
  108. package/scripts/lib/eval-schemas.js +144 -144
  109. package/scripts/lib/eval-self-correct.js +106 -106
  110. package/scripts/lib/eval-validator.js +185 -185
  111. package/scripts/lib/jaccard-similarity.js +98 -98
  112. package/scripts/lib/longmemeval-runner.js +125 -125
  113. package/scripts/lib/npm-version.js +261 -261
  114. package/scripts/lib/paquetes-conocidos.js +50 -50
  115. package/scripts/lib/prompt-builder.js +264 -264
  116. package/scripts/lib/rrf-fusion.js +175 -175
  117. package/scripts/lib/scoring-instintos.js +277 -277
  118. package/scripts/lib/semantic-search.js +252 -252
  119. package/scripts/limpiar-artefactos-python.js +131 -131
  120. package/scripts/mcp-server/README.md +128 -128
  121. package/scripts/mcp-server/handlers.js +206 -206
  122. package/scripts/migrar-csv-a-array.js +168 -168
  123. package/scripts/migrar-fase-dominio.js +201 -201
  124. package/scripts/publicar.js +511 -511
  125. package/scripts/run-eval.js +141 -141
  126. package/scripts/validar-manifest.js +195 -195
  127. package/scripts/validar-userland-vacio.js +110 -110
  128. package/scripts/verificar-release.js +110 -0
@@ -55,6 +55,66 @@ documentado en `reglas/consultar-vault-primero.md § Workflow forzoso para
55
55
  escritura al vault`. El bloqueo por `proteccion-rutas.js` no es una falla a
56
56
  esquivar — es una señal de que el canal correcto es el MCP.
57
57
 
58
+ ### 0c — Verificar que el vault activo de Obsidian apunta a `Vault\SWL\` (CRÍTICO)
59
+
60
+ > Origen del paso: sesión 2026-05-13 v1.4.0. El MCP de Obsidian resuelve paths
61
+ > relativos contra el **vault que Obsidian tiene abierto**, no contra una
62
+ > ruta hardcodeada en el plugin. Si Obsidian abrió un vault distinto del
63
+ > esperado (ej. `F:\Google Drive\Developer\Obsidian\Vault\` raíz en lugar
64
+ > de `Vault\SWL\`), todas las escrituras del MCP irán al vault equivocado.
65
+ > El plugin **crea carpetas inexistentes** sobre la marcha, así que ni
66
+ > siquiera obtendrás error — los archivos terminan en una ubicación huérfana.
67
+
68
+ Verifica antes de cualquier llamada MCP:
69
+
70
+ ```bash
71
+ # Path canónico de la config global de Obsidian en Windows
72
+ APP="$APPDATA/obsidian/obsidian.json"
73
+ node -e "
74
+ const fs = require('fs');
75
+ const data = JSON.parse(fs.readFileSync(process.argv[1], 'utf8'));
76
+ const abierto = Object.values(data.vaults).find(v => v.open === true);
77
+ console.log('Vault abierto:', abierto ? abierto.path : 'NINGUNO');
78
+ console.log('Esperado: F:\\\\Google Drive\\\\Developer\\\\Obsidian\\\\Vault\\\\SWL');
79
+ console.log('OK:', abierto && abierto.path.endsWith('SWL') ? 'sí' : 'NO');
80
+ " "$APP"
81
+ ```
82
+
83
+ Resultado esperado:
84
+
85
+ ```
86
+ Vault abierto: F:\Google Drive\Developer\Obsidian\Vault\SWL
87
+ Esperado: F:\Google Drive\Developer\Obsidian\Vault\SWL
88
+ OK: sí
89
+ ```
90
+
91
+ Si `OK: NO` o `Vault abierto: NINGUNO`, **NO usar MCP** — caer al filesystem
92
+ para esta sesión y reportar al usuario que debe abrir el vault `SWL` en
93
+ Obsidian (File → Open vault → seleccionar `F:\Google Drive\Developer\Obsidian\Vault\SWL`).
94
+
95
+ ### 0d — Verificar que la apiKey del MCP coincide con la del plugin activo
96
+
97
+ > Origen: cuando el vault activo cambia, la apiKey del plugin Local REST API
98
+ > cambia (cada vault tiene su propio `.obsidian/plugins/obsidian-local-rest-api/data.json`
99
+ > con apiKey, cert y privateKey distintos). El MCP server registrado en
100
+ > Claude.ai tiene una apiKey hardcodeada — si no se actualiza tras cambiar de
101
+ > vault, todas las llamadas retornan `40101 Authorization required`.
102
+
103
+ Si tras `ToolSearch` el MCP carga sus tools pero la primera llamada (ej.
104
+ `obsidian_list_files_in_dir`) retorna `Error 40101: Authorization required`,
105
+ el síntoma es claro: apiKey desincronizada.
106
+
107
+ Acción:
108
+ 1. Leer la apiKey actual del plugin con
109
+ `grep '"apiKey"' "F:/Google Drive/Developer/Obsidian/Vault/SWL/.obsidian/plugins/obsidian-local-rest-api/data.json"`
110
+ 2. Reportar al usuario que actualice en **claude.ai → Settings → Connectors
111
+ → Obsidian → API Key**. Disconnect + Reconnect tras pegar el valor.
112
+ 3. Mientras tanto, usar filesystem como fallback para escribir al Inbox.
113
+
114
+ Si hay procesos zombie de Obsidian o `mcp-obsidian` cliente con apiKey
115
+ cacheada, requieren terminación + reinicio de Claude Code para tomar la
116
+ nueva apiKey.
117
+
58
118
  ## Paso 1 — Identificación del proyecto actual
59
119
 
60
120
  Detecta qué proyecto eres leyendo:
@@ -296,6 +356,40 @@ escribe a `_userland/staging/<timestamp>.md` dentro del CWD, luego mueve con
296
356
  Reportar al usuario qué canal se usó:
297
357
  - Canal A → `[OK] Vía: MCP Obsidian (puerto 27124)`
298
358
  - Canal B → `[OK] Vía: filesystem directo (MCP no disponible)`
359
+ - Canal Híbrido → `[OK] Vía: filesystem para Inbox, MCP para promociones`
360
+
361
+ ### Modo HÍBRIDO (validado en sesión 2026-05-13)
362
+
363
+ Cuando la sesión incluye **autorización ampliada para promover documentos**
364
+ a `02-Projects/`, `07-Decisions/` y `04-Resources/`, el flujo óptimo combina
365
+ ambos canales:
366
+
367
+ 1. **Inbox** (`00-Inbox/YYYY-MM-DD_HHMM_export-{slug}.md`): usar siempre
368
+ **filesystem directo via Bash heredoc con ruta absoluta** (canal B). Razón:
369
+ el Bash heredoc bypassa cualquier desalineación del vault activo de
370
+ Obsidian — escribe directamente al disco en la ruta exacta.
371
+
372
+ 2. **Promociones** (a `02-Projects/`, `07-Decisions/`, `04-Resources/`):
373
+ usar **MCP append_content** (canal A) tras validar Pasos 0c y 0d. Razón:
374
+ las promociones requieren enlaces bidireccionales con `[[...]]` que el
375
+ plugin de Obsidian indexa al detectar la escritura. El filesystem directo
376
+ crea el archivo pero no dispara la reindexación hasta que Obsidian
377
+ detecte el cambio.
378
+
379
+ 3. **Aprobación de revisiones** (cambiar `status: pending-review` → `reviewed`
380
+ en frontmatter): si el archivo está en `00-Inbox/`, usar `sed` via Bash;
381
+ si está en `02-Projects/` etc., usar `mcp__obsidian__obsidian_patch_content`
382
+ con `target_type: "frontmatter"`. Validado que `patch_content` puede
383
+ responder 404 en algunos archivos pre-existentes — caer al filesystem si
384
+ falla.
385
+
386
+ Reportar al usuario el desglose final del modo híbrido:
387
+
388
+ ```
389
+ [OK] Inbox creado via filesystem: F:\...\00-Inbox\...md
390
+ [OK] 3 promociones via MCP: 02-Projects, 07-Decisions, 04-Resources
391
+ [OK] Revisión aprobada (reviewed: true) en el Inbox
392
+ ```
299
393
 
300
394
  ## Paso 5 — Confirmación
301
395
 
@@ -373,6 +467,20 @@ Próximo paso: al abrir el vault, ejecuta:
373
467
 
374
468
  ## Historial de cambios del comando
375
469
 
470
+ - **v1.4.0** (2026-05-13) — Agregados Pasos 0c y 0d con verificación previa
471
+ del vault activo de Obsidian y de la apiKey del MCP. Origen: sesión
472
+ 2026-05-13 con autorización ampliada del usuario para promover docs;
473
+ el MCP escribió `02-Projects/DEV - swl-ses.md` en `Vault\` raíz en lugar
474
+ de `Vault\SWL\` porque Obsidian tenía abierto el vault un nivel arriba
475
+ del esperado. Hallazgo crítico: el plugin Local REST API resuelve paths
476
+ relativos contra el vault que Obsidian tiene abierto en `obsidian.json`
477
+ global, no contra una ruta hardcodeada. Si el vault activo está mal,
478
+ el MCP escribe a la ubicación equivocada sin error (el plugin crea
479
+ carpetas inexistentes). Agregado también Paso 0d para apiKey
480
+ desincronizada (síntoma `40101 Authorization required`). Y nuevo
481
+ modo HÍBRIDO en Paso 4: filesystem directo para Inbox + MCP para
482
+ promociones, validado en la misma sesión.
483
+
376
484
  - **v1.3.8** (2026-05-11) — Agregado flujo MCP-first en Paso 4 con detección
377
485
  en Paso 0b. Origen: en la sesión v1.3.4 → v1.3.8 el comando defaultó a
378
486
  `Write` directo al filesystem que fue bloqueado por `proteccion-rutas.js`.
@@ -135,11 +135,33 @@ Estructura:
135
135
  - Si el usuario no definió fases claras, propón una división lógica basada en las respuestas del Bloque C
136
136
  - Estado inicial de todas las fases: "Pendiente"
137
137
 
138
- ## Paso 6 — Reporte al usuario
138
+ ## Paso 6 — Generar CLAUDE.md inicial del proyecto
139
+
140
+ Si NO existe `CLAUDE.md` en la raíz del proyecto, generarlo con la estructura
141
+ mínima definida en `/swl:claudemd init-project`. **OBLIGATORIO** incluir como
142
+ primera sección bajo el título:
143
+
144
+ ```markdown
145
+ ## Reglas obligatorias
146
+
147
+ @reglas/usar-sistema-swl.md
148
+ ```
149
+
150
+ Esta referencia carga la matriz operacional del sistema SWL al inicio de cada
151
+ sesión del proyecto y previene que el agente haga trabajo directo cuando
152
+ existe un componente especializado. Sin ella, el proyecto pierde el contrato
153
+ de uso del sistema SWL.
154
+
155
+ Si ya existe `CLAUDE.md` (verificado en Paso 1), revisar que incluya
156
+ `@reglas/usar-sistema-swl.md` en la sección de reglas obligatorias. Si NO
157
+ lo incluye, agregarlo en este paso preservando el resto del contenido.
158
+
159
+ ## Paso 7 — Reporte al usuario
139
160
 
140
161
  Al terminar, reporta:
141
162
 
142
- 1. Lista de archivos creados con sus rutas absolutas
163
+ 1. Lista de archivos creados con sus rutas absolutas (incluyendo CLAUDE.md
164
+ si se generó o se modificó)
143
165
  2. Resumen de la investigación del agente (cuando esté disponible)
144
166
  3. Próximo paso recomendado: "Para comenzar a trabajar en la Fase 1, usa `/swl:discutir-fase 1`"
145
167
  4. Si encontraste riesgos o ambigüedades durante la entrevista, listarlos aquí como "Puntos a resolver"
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Base Adapter — Clase base abstracta para adaptadores de plataforma.
5
+ *
6
+ * Cada plataforma (Telegram, Discord, Webhook) extiende esta clase
7
+ * e implementa los métodos abstractos. El gateway/index.js orquesta
8
+ * múltiples adaptadores de forma uniforme.
9
+ *
10
+ * Patrón adoptado de Hermes Agent (gateway/platforms/base.py).
11
+ *
12
+ * @module gateway/adapters/base
13
+ */
14
+
15
+ /**
16
+ * @abstract
17
+ */
18
+ class BaseAdapter {
19
+ /**
20
+ * @param {string} name - Nombre de la plataforma (ej: 'telegram').
21
+ * @param {object} config - Configuración específica de la plataforma.
22
+ */
23
+ constructor(name, config) {
24
+ if (new.target === BaseAdapter) {
25
+ throw new Error('BaseAdapter es abstracto — usar un adaptador concreto.');
26
+ }
27
+ this.name = name;
28
+ this.config = config;
29
+ this.running = false;
30
+ this._messageHandler = null;
31
+ }
32
+
33
+ /**
34
+ * Inicia la conexión con la plataforma.
35
+ * @abstract
36
+ * @returns {Promise<void>}
37
+ */
38
+ async start() {
39
+ throw new Error(`${this.name}: start() no implementado`);
40
+ }
41
+
42
+ /**
43
+ * Detiene la conexión con la plataforma.
44
+ * @abstract
45
+ * @returns {Promise<void>}
46
+ */
47
+ async stop() {
48
+ this.running = false;
49
+ }
50
+
51
+ /**
52
+ * Envía un mensaje formateado a la plataforma.
53
+ * @abstract
54
+ * @param {object} message
55
+ * @param {string} message.text - Texto del mensaje (Markdown).
56
+ * @param {string} [message.chatId] - Destino específico.
57
+ * @param {string} [message.type] - Tipo: 'notification', 'alert', 'response'.
58
+ * @returns {Promise<boolean>} true si se envió correctamente.
59
+ */
60
+ async send(message) {
61
+ throw new Error(`${this.name}: send() no implementado`);
62
+ }
63
+
64
+ /**
65
+ * Registra un handler para mensajes entrantes de la plataforma.
66
+ * @param {function} handler - Callback (message) => void.
67
+ */
68
+ onMessage(handler) {
69
+ this._messageHandler = handler;
70
+ }
71
+
72
+ /**
73
+ * Emite un mensaje entrante al handler registrado.
74
+ * @protected
75
+ * @param {object} message
76
+ */
77
+ _emitMessage(message) {
78
+ if (typeof this._messageHandler === 'function') {
79
+ this._messageHandler({
80
+ platform: this.name,
81
+ ...message,
82
+ });
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Formatea un mensaje SWL para la plataforma específica.
88
+ * Override en cada adaptador para formato nativo (embeds, markdown, etc.).
89
+ *
90
+ * @param {object} swlMessage - Mensaje del sistema SWL.
91
+ * @returns {string} Texto formateado para la plataforma.
92
+ */
93
+ formatMessage(swlMessage) {
94
+ const { jobName, status, output, type } = swlMessage.payload || swlMessage;
95
+ const lines = [];
96
+
97
+ if (type === 'gateway_notification' || jobName) {
98
+ lines.push(`*${jobName || 'SWL Notificación'}*`);
99
+ if (status) lines.push(`Estado: ${status}`);
100
+ if (output) lines.push(`\`\`\`\n${output.substring(0, 500)}\n\`\`\``);
101
+ } else {
102
+ lines.push(swlMessage.text || JSON.stringify(swlMessage));
103
+ }
104
+
105
+ return lines.join('\n');
106
+ }
107
+ }
108
+
109
+ module.exports = BaseAdapter;
@@ -0,0 +1,167 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Discord Adapter — Adaptador de plataforma para Discord.
5
+ *
6
+ * Usa discord.js para conectar un bot. Soporta:
7
+ * - Recepción de mensajes en canal dedicado
8
+ * - Envío con embeds para notificaciones estructuradas
9
+ * - Comandos slash nativos
10
+ * - Filtro por guildId y channelId
11
+ *
12
+ * Dependencia: discord.js (npm install discord.js)
13
+ * Si no está instalada, el adaptador se desactiva silenciosamente.
14
+ *
15
+ * Inspirado en Hermes Agent (gateway/platforms/discord.py).
16
+ *
17
+ * @module gateway/adapters/discord
18
+ */
19
+
20
+ const BaseAdapter = require('./base');
21
+
22
+ /** Límite de caracteres por mensaje de Discord. */
23
+ const MAX_MSG_LENGTH = 2000;
24
+
25
+ class DiscordAdapter extends BaseAdapter {
26
+ constructor(config) {
27
+ super('discord', config);
28
+ this._client = null;
29
+ }
30
+
31
+ async start() {
32
+ let Discord;
33
+ try {
34
+ Discord = require('discord.js');
35
+ } catch (_) {
36
+ console.log('[discord] discord.js no instalado. Ejecutar: npm install discord.js');
37
+ return;
38
+ }
39
+
40
+ const token = this.config.token || process.env.DISCORD_BOT_TOKEN;
41
+ if (!token) {
42
+ console.log('[discord] Token no configurado. Establecer DISCORD_BOT_TOKEN.');
43
+ return;
44
+ }
45
+
46
+ const { Client, GatewayIntentBits } = Discord;
47
+
48
+ this._client = new Client({
49
+ intents: [
50
+ GatewayIntentBits.Guilds,
51
+ GatewayIntentBits.GuildMessages,
52
+ GatewayIntentBits.MessageContent,
53
+ ],
54
+ });
55
+
56
+ this._client.on('ready', () => {
57
+ this.running = true;
58
+ console.log(`[discord] Bot conectado como ${this._client.user.tag}`);
59
+ });
60
+
61
+ this._client.on('messageCreate', (msg) => {
62
+ // Ignorar mensajes del bot
63
+ if (msg.author.bot) return;
64
+
65
+ // Filtrar por guild y canal si están configurados
66
+ if (this.config.guildId && msg.guildId !== this.config.guildId) return;
67
+ if (this.config.channelId && msg.channelId !== this.config.channelId) return;
68
+
69
+ const text = msg.content || '';
70
+ const parts = text.split(/\s+/);
71
+ const command = parts[0].startsWith('/') ? parts[0] : null;
72
+ const args = command ? parts.slice(1).join(' ') : '';
73
+
74
+ this._emitMessage({
75
+ chatId: msg.channelId,
76
+ userId: msg.author.id,
77
+ userName: msg.author.username,
78
+ text: text,
79
+ command: command,
80
+ args: args,
81
+ guildId: msg.guildId,
82
+ raw: msg,
83
+ });
84
+ });
85
+
86
+ this._client.on('error', (err) => {
87
+ console.error(`[discord] Error: ${err.message}`);
88
+ });
89
+
90
+ await this._client.login(token);
91
+ console.log('[discord] Adaptador iniciado.');
92
+ }
93
+
94
+ async stop() {
95
+ if (this._client) {
96
+ try { await this._client.destroy(); } catch (_) {}
97
+ this._client = null;
98
+ }
99
+ this.running = false;
100
+ console.log('[discord] Adaptador detenido.');
101
+ }
102
+
103
+ async send(message) {
104
+ if (!this._client) return false;
105
+
106
+ const channelId = message.chatId || this.config.channelId;
107
+ if (!channelId) return false;
108
+
109
+ try {
110
+ const channel = await this._client.channels.fetch(channelId);
111
+ if (!channel || !channel.isTextBased()) return false;
112
+
113
+ const embed = this._buildEmbed(message);
114
+ if (embed) {
115
+ await channel.send({ embeds: [embed] });
116
+ } else {
117
+ const text = this.formatMessage(message);
118
+ // Split si excede límite
119
+ if (text.length > MAX_MSG_LENGTH) {
120
+ const chunks = [];
121
+ for (let i = 0; i < text.length; i += MAX_MSG_LENGTH) {
122
+ chunks.push(text.substring(i, i + MAX_MSG_LENGTH));
123
+ }
124
+ for (const chunk of chunks) await channel.send(chunk);
125
+ } else {
126
+ await channel.send(text);
127
+ }
128
+ }
129
+ return true;
130
+ } catch (err) {
131
+ console.error(`[discord] Error enviando: ${err.message}`);
132
+ return false;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Construye un embed de Discord para notificaciones estructuradas.
138
+ * @private
139
+ */
140
+ _buildEmbed(message) {
141
+ const payload = message.payload || message;
142
+ if (!payload.jobName && !payload.title) return null;
143
+
144
+ let Discord;
145
+ try { Discord = require('discord.js'); } catch (_) { return null; }
146
+
147
+ const { EmbedBuilder } = Discord;
148
+ const isError = payload.status === 'error';
149
+
150
+ const embed = new EmbedBuilder()
151
+ .setTitle(payload.jobName || payload.title || 'SWL Notificación')
152
+ .setColor(isError ? 0xFF0000 : 0x00CC66)
153
+ .setTimestamp();
154
+
155
+ if (payload.status) {
156
+ embed.addFields({ name: 'Estado', value: `\`${payload.status}\``, inline: true });
157
+ }
158
+ if (payload.output) {
159
+ const output = payload.output.substring(0, 1000);
160
+ embed.addFields({ name: 'Output', value: `\`\`\`\n${output}\n\`\`\`` });
161
+ }
162
+
163
+ return embed;
164
+ }
165
+ }
166
+
167
+ module.exports = DiscordAdapter;
@@ -0,0 +1,221 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Email Adapter — Adaptador para notificaciones por correo electrónico.
5
+ *
6
+ * Soporta:
7
+ * - Envío via SMTP directo (nodemailer)
8
+ * - Envío via API de servicios (SendGrid, Mailgun, SES) — HTTP nativo
9
+ * - Formato HTML con template básico para notificaciones
10
+ * - Unidireccional (solo envío, sin recepción)
11
+ *
12
+ * Dependencia SMTP: nodemailer (npm install nodemailer)
13
+ * Dependencia API: ninguna (HTTP nativo)
14
+ *
15
+ * @module gateway/adapters/email
16
+ */
17
+
18
+ const BaseAdapter = require('./base');
19
+ const https = require('https');
20
+
21
+ class EmailAdapter extends BaseAdapter {
22
+ constructor(config) {
23
+ super('email', config);
24
+ this._transporter = null;
25
+ this._mode = config.mode || 'api'; // 'smtp' | 'api'
26
+ }
27
+
28
+ async start() {
29
+ if (this._mode === 'smtp') {
30
+ return this._startSMTP();
31
+ }
32
+ return this._startAPI();
33
+ }
34
+
35
+ /**
36
+ * Modo SMTP con nodemailer.
37
+ */
38
+ async _startSMTP() {
39
+ let nodemailer;
40
+ try {
41
+ nodemailer = require('nodemailer');
42
+ } catch (_) {
43
+ console.log('[email] nodemailer no instalado. Ejecutar: npm install nodemailer');
44
+ return;
45
+ }
46
+
47
+ const host = this.config.smtpHost || process.env.SMTP_HOST;
48
+ const port = this.config.smtpPort || process.env.SMTP_PORT || 587;
49
+ const user = this.config.smtpUser || process.env.SMTP_USER;
50
+ const pass = this.config.smtpPass || process.env.SMTP_PASS;
51
+
52
+ if (!host || !user) {
53
+ console.log('[email] SMTP no configurado. Establecer SMTP_HOST y SMTP_USER.');
54
+ return;
55
+ }
56
+
57
+ this._transporter = nodemailer.createTransport({
58
+ host,
59
+ port: Number(port),
60
+ secure: Number(port) === 465,
61
+ auth: { user, pass },
62
+ });
63
+
64
+ this.running = true;
65
+ console.log(`[email] Adaptador iniciado (SMTP: ${host}:${port}).`);
66
+ }
67
+
68
+ /**
69
+ * Modo API (SendGrid, Mailgun, SES — via HTTP).
70
+ */
71
+ async _startAPI() {
72
+ const apiKey = this.config.apiKey || process.env.EMAIL_API_KEY;
73
+ const service = this.config.service || process.env.EMAIL_SERVICE || 'sendgrid';
74
+
75
+ if (!apiKey) {
76
+ console.log(`[email] API key no configurada. Establecer EMAIL_API_KEY para ${service}.`);
77
+ return;
78
+ }
79
+
80
+ this._apiKey = apiKey;
81
+ this._service = service;
82
+ this.running = true;
83
+ console.log(`[email] Adaptador iniciado (API: ${service}).`);
84
+ }
85
+
86
+ async stop() {
87
+ if (this._transporter) {
88
+ this._transporter.close();
89
+ this._transporter = null;
90
+ }
91
+ this.running = false;
92
+ console.log('[email] Adaptador detenido.');
93
+ }
94
+
95
+ async send(message) {
96
+ const to = message.chatId || this.config.defaultTo || process.env.EMAIL_DEFAULT_TO;
97
+ const from = this.config.from || process.env.EMAIL_FROM || 'swl-ses@noreply.com';
98
+
99
+ if (!to) return false;
100
+
101
+ const subject = this._buildSubject(message);
102
+ const html = this._buildHTML(message);
103
+ const text = this.formatMessage(message);
104
+
105
+ if (this._mode === 'smtp') {
106
+ return this._sendSMTP(from, to, subject, text, html);
107
+ }
108
+ return this._sendAPI(from, to, subject, text, html);
109
+ }
110
+
111
+ /**
112
+ * Envía via SMTP con nodemailer.
113
+ */
114
+ async _sendSMTP(from, to, subject, text, html) {
115
+ if (!this._transporter) return false;
116
+
117
+ try {
118
+ await this._transporter.sendMail({ from, to, subject, text, html });
119
+ return true;
120
+ } catch (err) {
121
+ console.error(`[email] Error SMTP: ${err.message}`);
122
+ return false;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Envía via SendGrid API (HTTP POST).
128
+ */
129
+ async _sendAPI(from, to, subject, text, html) {
130
+ if (!this._apiKey) return false;
131
+
132
+ if (this._service === 'sendgrid') {
133
+ return this._sendSendGrid(from, to, subject, text, html);
134
+ }
135
+
136
+ // Fallback genérico — tratar como webhook
137
+ console.log(`[email] Servicio "${this._service}" no implementado. Usar "sendgrid" o modo "smtp".`);
138
+ return false;
139
+ }
140
+
141
+ /**
142
+ * SendGrid v3 API.
143
+ */
144
+ _sendSendGrid(from, to, subject, text, html) {
145
+ const body = JSON.stringify({
146
+ personalizations: [{ to: [{ email: to }] }],
147
+ from: { email: from },
148
+ subject,
149
+ content: [
150
+ { type: 'text/plain', value: text },
151
+ { type: 'text/html', value: html },
152
+ ],
153
+ });
154
+
155
+ return new Promise((resolve) => {
156
+ const req = https.request({
157
+ hostname: 'api.sendgrid.com',
158
+ path: '/v3/mail/send',
159
+ method: 'POST',
160
+ headers: {
161
+ 'Authorization': `Bearer ${this._apiKey}`,
162
+ 'Content-Type': 'application/json',
163
+ 'Content-Length': Buffer.byteLength(body),
164
+ },
165
+ timeout: 10000,
166
+ }, (res) => {
167
+ let data = '';
168
+ res.on('data', chunk => data += chunk);
169
+ res.on('end', () => resolve(res.statusCode >= 200 && res.statusCode < 300));
170
+ });
171
+
172
+ req.on('error', () => resolve(false));
173
+ req.on('timeout', () => { req.destroy(); resolve(false); });
174
+ req.write(body);
175
+ req.end();
176
+ });
177
+ }
178
+
179
+ _buildSubject(message) {
180
+ const payload = message.payload || message;
181
+ const status = payload.status === 'completed' ? '✓' : '✗';
182
+ return `[SWL] ${status} ${payload.jobName || payload.title || 'Notificación'}`;
183
+ }
184
+
185
+ _buildHTML(message) {
186
+ const payload = message.payload || message;
187
+ const isError = payload.status === 'error';
188
+ const color = isError ? '#dc3545' : '#28a745';
189
+
190
+ return `
191
+ <!DOCTYPE html>
192
+ <html><body style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto;padding:20px">
193
+ <div style="border-left:4px solid ${color};padding:12px 16px;margin-bottom:16px">
194
+ <h2 style="margin:0 0 8px;color:${color}">${payload.jobName || 'SWL Notificación'}</h2>
195
+ <p style="margin:0;color:#666">Estado: <strong>${payload.status || 'unknown'}</strong></p>
196
+ </div>
197
+ ${payload.output ? `<pre style="background:#f5f5f5;padding:12px;border-radius:4px;overflow-x:auto;font-size:13px">${_escapeHTML(payload.output.substring(0, 2000))}</pre>` : ''}
198
+ <hr style="border:none;border-top:1px solid #eee;margin:20px 0">
199
+ <p style="color:#999;font-size:12px">Enviado por SWL-SES Gateway — ${new Date().toISOString()}</p>
200
+ </body></html>`.trim();
201
+ }
202
+
203
+ formatMessage(swlMessage) {
204
+ const payload = swlMessage.payload || swlMessage;
205
+
206
+ if (payload.jobName) {
207
+ const icon = payload.status === 'completed' ? '[OK]' : '[ERROR]';
208
+ const lines = [`${icon} ${payload.jobName}`, `Estado: ${payload.status}`];
209
+ if (payload.output) lines.push('', payload.output.substring(0, 1000));
210
+ return lines.join('\n');
211
+ }
212
+
213
+ return swlMessage.text || JSON.stringify(payload).substring(0, 500);
214
+ }
215
+ }
216
+
217
+ function _escapeHTML(str) {
218
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
219
+ }
220
+
221
+ module.exports = EmailAdapter;