@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
@@ -0,0 +1,192 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Slack Adapter — Adaptador para Slack via Bolt SDK.
5
+ *
6
+ * Soporta:
7
+ * - Envío de notificaciones con Blocks (formato rico)
8
+ * - Recepción de comandos slash y mensajes directos
9
+ * - Threads para conversaciones agrupadas
10
+ * - Rate limiting (Slack: 1 msg/seg por canal)
11
+ *
12
+ * Dependencia: @slack/bolt (npm install @slack/bolt)
13
+ * Si no está instalada, el adaptador se desactiva silenciosamente.
14
+ *
15
+ * @module gateway/adapters/slack
16
+ */
17
+
18
+ const BaseAdapter = require('./base');
19
+
20
+ /** Límite de caracteres por mensaje de Slack. */
21
+ const MAX_MSG_LENGTH = 3000;
22
+
23
+ /** Comandos reconocidos en mensajes directos al bot. */
24
+ const COMMANDS = {
25
+ 'status': 'Estado del sistema SWL',
26
+ 'salud': 'Ejecutar diagnóstico de salud',
27
+ 'sesiones': 'Listar últimas sesiones',
28
+ 'approve': 'Aprobar operación pendiente',
29
+ 'reject': 'Rechazar operación pendiente',
30
+ };
31
+
32
+ class SlackAdapter extends BaseAdapter {
33
+ constructor(config) {
34
+ super('slack', config);
35
+ this._app = null;
36
+ }
37
+
38
+ async start() {
39
+ let Bolt;
40
+ try {
41
+ Bolt = require('@slack/bolt');
42
+ } catch (_) {
43
+ console.log('[slack] @slack/bolt no instalado. Ejecutar: npm install @slack/bolt');
44
+ return;
45
+ }
46
+
47
+ const token = this.config.token || process.env.SLACK_BOT_TOKEN;
48
+ const sigSecret = this.config.signingSecret || process.env.SLACK_SIGNING_SECRET;
49
+ const appToken = this.config.appToken || process.env.SLACK_APP_TOKEN;
50
+
51
+ if (!token) {
52
+ console.log('[slack] Token no configurado. Establecer SLACK_BOT_TOKEN.');
53
+ return;
54
+ }
55
+
56
+ try {
57
+ const opts = {
58
+ token,
59
+ signingSecret: sigSecret || 'dummy',
60
+ };
61
+
62
+ // Socket mode (recomendado, sin servidor público)
63
+ if (appToken) {
64
+ opts.socketMode = true;
65
+ opts.appToken = appToken;
66
+ }
67
+
68
+ this._app = new Bolt.App(opts);
69
+
70
+ // Escuchar mensajes directos al bot
71
+ this._app.message(async ({ message, say }) => {
72
+ if (message.subtype) return; // Ignorar ediciones, joins, etc.
73
+
74
+ const text = message.text || '';
75
+ const parts = text.trim().split(/\s+/);
76
+ const cmdWord = parts[0].toLowerCase().replace(/^\//, '');
77
+ const command = COMMANDS[cmdWord] ? cmdWord : null;
78
+ const args = command ? parts.slice(1).join(' ') : '';
79
+
80
+ this._emitMessage({
81
+ chatId: message.channel,
82
+ userId: message.user,
83
+ userName: message.user,
84
+ text: text,
85
+ command: command ? `/${command}` : null,
86
+ args: args,
87
+ threadTs: message.thread_ts || message.ts,
88
+ raw: message,
89
+ });
90
+ });
91
+
92
+ if (appToken) {
93
+ await this._app.start();
94
+ }
95
+
96
+ this.running = true;
97
+ console.log(`[slack] Adaptador iniciado (${appToken ? 'socket' : 'events'} mode).`);
98
+ } catch (err) {
99
+ console.error(`[slack] Error al iniciar: ${err.message}`);
100
+ }
101
+ }
102
+
103
+ async stop() {
104
+ if (this._app) {
105
+ try { await this._app.stop(); } catch (_) {}
106
+ this._app = null;
107
+ }
108
+ this.running = false;
109
+ console.log('[slack] Adaptador detenido.');
110
+ }
111
+
112
+ async send(message) {
113
+ if (!this._app) return false;
114
+
115
+ const channel = message.chatId || this.config.defaultChannel || process.env.SLACK_DEFAULT_CHANNEL;
116
+ if (!channel) return false;
117
+
118
+ const text = this.formatMessage(message);
119
+ const blocks = this._buildBlocks(message);
120
+
121
+ try {
122
+ await this._app.client.chat.postMessage({
123
+ token: this.config.token || process.env.SLACK_BOT_TOKEN,
124
+ channel,
125
+ text,
126
+ blocks,
127
+ unfurl_links: false,
128
+ });
129
+ return true;
130
+ } catch (err) {
131
+ console.error(`[slack] Error enviando: ${err.message}`);
132
+ return false;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Construye Slack Blocks para notificaciones estructuradas.
138
+ * @private
139
+ */
140
+ _buildBlocks(message) {
141
+ const payload = message.payload || message;
142
+ if (!payload.jobName && !payload.title) return undefined;
143
+
144
+ const isError = payload.status === 'error';
145
+ const icon = isError ? ':x:' : ':white_check_mark:';
146
+ const blocks = [];
147
+
148
+ blocks.push({
149
+ type: 'header',
150
+ text: { type: 'plain_text', text: `${payload.jobName || payload.title || 'SWL'}` },
151
+ });
152
+
153
+ blocks.push({
154
+ type: 'section',
155
+ fields: [
156
+ { type: 'mrkdwn', text: `*Estado:*\n${icon} \`${payload.status || 'unknown'}\`` },
157
+ { type: 'mrkdwn', text: `*Fuente:*\nSWL-SES Gateway` },
158
+ ],
159
+ });
160
+
161
+ if (payload.output) {
162
+ blocks.push({
163
+ type: 'section',
164
+ text: { type: 'mrkdwn', text: '```' + payload.output.substring(0, 2500) + '```' },
165
+ });
166
+ }
167
+
168
+ blocks.push({ type: 'divider' });
169
+
170
+ return blocks;
171
+ }
172
+
173
+ formatMessage(swlMessage) {
174
+ const payload = swlMessage.payload || swlMessage;
175
+
176
+ if (payload.jobName) {
177
+ const icon = payload.status === 'completed' ? ':white_check_mark:' : ':x:';
178
+ const lines = [
179
+ `${icon} *${payload.jobName}*`,
180
+ `Estado: \`${payload.status}\``,
181
+ ];
182
+ if (payload.output) {
183
+ lines.push('```' + payload.output.substring(0, 1000) + '```');
184
+ }
185
+ return lines.join('\n');
186
+ }
187
+
188
+ return swlMessage.text || JSON.stringify(payload).substring(0, 500);
189
+ }
190
+ }
191
+
192
+ module.exports = SlackAdapter;
@@ -0,0 +1,183 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Telegram Adapter — Adaptador de plataforma para Telegram Bot API.
5
+ *
6
+ * Usa polling HTTP (sin webhook, sin servidor público). Soporta:
7
+ * - Recepción de mensajes y comandos (/status, /salud, /approve)
8
+ * - Envío de notificaciones con formato Markdown
9
+ * - Batching de mensajes largos (split a 4096 chars)
10
+ * - Rate limiting (30 msg/seg Telegram limit)
11
+ * - Filtro de usuarios permitidos (allowedUsers)
12
+ *
13
+ * Dependencia: node-telegram-bot-api (npm install node-telegram-bot-api)
14
+ * Si no está instalada, el adaptador se desactiva silenciosamente.
15
+ *
16
+ * Inspirado en Hermes Agent (gateway/platforms/telegram.py).
17
+ *
18
+ * @module gateway/adapters/telegram
19
+ */
20
+
21
+ const BaseAdapter = require('./base');
22
+
23
+ /** Límite de caracteres por mensaje de Telegram. */
24
+ const MAX_MSG_LENGTH = 4096;
25
+
26
+ /** Comandos reconocidos por el bot. */
27
+ const COMMANDS = {
28
+ '/status': 'Estado del sistema SWL',
29
+ '/salud': 'Ejecutar diagnóstico de salud',
30
+ '/sesiones': 'Listar últimas sesiones',
31
+ '/approve': 'Aprobar operación pendiente',
32
+ '/reject': 'Rechazar operación pendiente',
33
+ '/help': 'Mostrar ayuda',
34
+ };
35
+
36
+ class TelegramAdapter extends BaseAdapter {
37
+ constructor(config) {
38
+ super('telegram', config);
39
+ this._bot = null;
40
+ this._allowedUsers = new Set(config.allowedUsers || []);
41
+ }
42
+
43
+ async start() {
44
+ let TelegramBot;
45
+ try {
46
+ TelegramBot = require('node-telegram-bot-api');
47
+ } catch (_) {
48
+ console.log('[telegram] node-telegram-bot-api no instalado. Ejecutar: npm install node-telegram-bot-api');
49
+ return;
50
+ }
51
+
52
+ const token = this.config.token || process.env.TELEGRAM_BOT_TOKEN;
53
+ if (!token) {
54
+ console.log('[telegram] Token no configurado. Establecer TELEGRAM_BOT_TOKEN.');
55
+ return;
56
+ }
57
+
58
+ this._bot = new TelegramBot(token, { polling: true });
59
+ this.running = true;
60
+
61
+ this._bot.on('message', (msg) => {
62
+ // Filtrar usuarios no permitidos
63
+ if (this._allowedUsers.size > 0 && !this._allowedUsers.has(String(msg.from.id))) {
64
+ return;
65
+ }
66
+
67
+ const text = msg.text || '';
68
+
69
+ // Parsear comando
70
+ const parts = text.split(/\s+/);
71
+ const command = parts[0];
72
+ const args = parts.slice(1).join(' ');
73
+
74
+ this._emitMessage({
75
+ chatId: String(msg.chat.id),
76
+ userId: String(msg.from.id),
77
+ userName: msg.from.username || msg.from.first_name || 'unknown',
78
+ text: text,
79
+ command: COMMANDS[command] ? command : null,
80
+ args: args,
81
+ raw: msg,
82
+ });
83
+ });
84
+
85
+ this._bot.on('polling_error', (err) => {
86
+ // Log silencioso — no crashear
87
+ if (err.code === 'ETELEGRAM' && err.message.includes('409')) {
88
+ console.error('[telegram] Conflicto: otro bot con el mismo token. Deteniendo polling.');
89
+ this.stop();
90
+ }
91
+ });
92
+
93
+ console.log('[telegram] Adaptador iniciado (polling mode).');
94
+ }
95
+
96
+ async stop() {
97
+ if (this._bot) {
98
+ try { await this._bot.stopPolling(); } catch (_) {}
99
+ this._bot = null;
100
+ }
101
+ this.running = false;
102
+ console.log('[telegram] Adaptador detenido.');
103
+ }
104
+
105
+ async send(message) {
106
+ if (!this._bot) return false;
107
+
108
+ const chatId = message.chatId || this.config.homeChat;
109
+ if (!chatId) return false;
110
+
111
+ const text = this.formatMessage(message);
112
+
113
+ // Batching: split en chunks de MAX_MSG_LENGTH
114
+ const chunks = _splitMessage(text, MAX_MSG_LENGTH);
115
+
116
+ for (const chunk of chunks) {
117
+ try {
118
+ await this._bot.sendMessage(chatId, chunk, {
119
+ parse_mode: 'Markdown',
120
+ disable_web_page_preview: true,
121
+ });
122
+ } catch (err) {
123
+ // Retry sin Markdown si falla el parse
124
+ try {
125
+ await this._bot.sendMessage(chatId, chunk);
126
+ } catch (_) {
127
+ return false;
128
+ }
129
+ }
130
+ }
131
+
132
+ return true;
133
+ }
134
+
135
+ formatMessage(swlMessage) {
136
+ const payload = swlMessage.payload || swlMessage;
137
+
138
+ if (payload.jobName) {
139
+ // Notificación de cron
140
+ const status = payload.status === 'completed' ? '✓' : '✗';
141
+ const lines = [
142
+ `${status} *${payload.jobName}*`,
143
+ `Estado: \`${payload.status}\``,
144
+ ];
145
+ if (payload.output) {
146
+ lines.push('```', payload.output.substring(0, 1000), '```');
147
+ }
148
+ return lines.join('\n');
149
+ }
150
+
151
+ // Mensaje genérico
152
+ return swlMessage.text || JSON.stringify(payload).substring(0, 500);
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Divide un mensaje largo en chunks respetando el límite de caracteres.
158
+ * Intenta dividir en líneas completas.
159
+ */
160
+ function _splitMessage(text, maxLength) {
161
+ if (text.length <= maxLength) return [text];
162
+
163
+ const chunks = [];
164
+ let remaining = text;
165
+
166
+ while (remaining.length > 0) {
167
+ if (remaining.length <= maxLength) {
168
+ chunks.push(remaining);
169
+ break;
170
+ }
171
+
172
+ // Buscar el último newline dentro del límite
173
+ let splitAt = remaining.lastIndexOf('\n', maxLength);
174
+ if (splitAt < maxLength * 0.3) splitAt = maxLength; // Si no hay newline útil, cortar
175
+
176
+ chunks.push(remaining.substring(0, splitAt));
177
+ remaining = remaining.substring(splitAt).trimStart();
178
+ }
179
+
180
+ return chunks;
181
+ }
182
+
183
+ module.exports = TelegramAdapter;
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webhook Adapter — Adaptador genérico para webhooks HTTP.
5
+ *
6
+ * Envía notificaciones a un endpoint configurable via POST.
7
+ * No recibe mensajes (unidireccional: SWL → Webhook).
8
+ *
9
+ * Compatible con: Slack Incoming Webhooks, Microsoft Teams, ntfy.sh,
10
+ * Pushover, cualquier endpoint que acepte JSON POST.
11
+ *
12
+ * @module gateway/adapters/webhook
13
+ */
14
+
15
+ const BaseAdapter = require('./base');
16
+ const https = require('https');
17
+ const http = require('http');
18
+ const { URL } = require('url');
19
+
20
+ class WebhookAdapter extends BaseAdapter {
21
+ constructor(config) {
22
+ super('webhook', config);
23
+ }
24
+
25
+ async start() {
26
+ const url = this.config.url || process.env.WEBHOOK_URL;
27
+ if (!url) {
28
+ console.log('[webhook] URL no configurada. Establecer WEBHOOK_URL.');
29
+ return;
30
+ }
31
+
32
+ this.running = true;
33
+ console.log('[webhook] Adaptador iniciado (unidireccional).');
34
+ }
35
+
36
+ async stop() {
37
+ this.running = false;
38
+ console.log('[webhook] Adaptador detenido.');
39
+ }
40
+
41
+ async send(message) {
42
+ const url = this.config.url || process.env.WEBHOOK_URL;
43
+ if (!url) return false;
44
+
45
+ const payload = {
46
+ source: 'swl-ses',
47
+ timestamp: new Date().toISOString(),
48
+ ...message,
49
+ text: this.formatMessage(message),
50
+ };
51
+
52
+ try {
53
+ await _postJSON(url, payload, this.config.secret || process.env.WEBHOOK_SECRET);
54
+ return true;
55
+ } catch (err) {
56
+ console.error(`[webhook] Error: ${err.message}`);
57
+ return false;
58
+ }
59
+ }
60
+ }
61
+
62
+ /**
63
+ * POST JSON a una URL con timeout y HMAC opcional.
64
+ * @param {string} url
65
+ * @param {object} data
66
+ * @param {string} [secret]
67
+ * @returns {Promise<string>}
68
+ */
69
+ function _postJSON(url, data, secret) {
70
+ return new Promise((resolve, reject) => {
71
+ const parsed = new URL(url);
72
+ const client = parsed.protocol === 'https:' ? https : http;
73
+ const body = JSON.stringify(data);
74
+ const headers = {
75
+ 'Content-Type': 'application/json',
76
+ 'Content-Length': Buffer.byteLength(body),
77
+ 'User-Agent': 'SWL-SES-Gateway/1.0',
78
+ };
79
+
80
+ // HMAC signature si hay secret
81
+ if (secret) {
82
+ const crypto = require('crypto');
83
+ const sig = crypto.createHmac('sha256', secret).update(body).digest('hex');
84
+ headers['X-SWL-Signature'] = `sha256=${sig}`;
85
+ }
86
+
87
+ const req = client.request({
88
+ hostname: parsed.hostname,
89
+ port: parsed.port,
90
+ path: parsed.pathname + parsed.search,
91
+ method: 'POST',
92
+ headers,
93
+ timeout: 10000,
94
+ }, (res) => {
95
+ let responseBody = '';
96
+ res.on('data', chunk => responseBody += chunk);
97
+ res.on('end', () => {
98
+ if (res.statusCode >= 200 && res.statusCode < 300) {
99
+ resolve(responseBody);
100
+ } else {
101
+ reject(new Error(`HTTP ${res.statusCode}: ${responseBody.substring(0, 200)}`));
102
+ }
103
+ });
104
+ });
105
+
106
+ req.on('error', reject);
107
+ req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
108
+ req.write(body);
109
+ req.end();
110
+ });
111
+ }
112
+
113
+ module.exports = WebhookAdapter;
@@ -0,0 +1,214 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * WhatsApp Adapter — Adaptador para WhatsApp Business API / bridges.
5
+ *
6
+ * Soporta dos modos:
7
+ * 1. WhatsApp Business API (Cloud API de Meta) — producción
8
+ * 2. whatsapp-web.js (bridge no oficial) — desarrollo/personal
9
+ *
10
+ * Ambos modos son bidireccionales: envío y recepción de mensajes.
11
+ *
12
+ * Dependencia modo Business API: ninguna (HTTP nativo)
13
+ * Dependencia modo bridge: whatsapp-web.js (npm install whatsapp-web.js)
14
+ *
15
+ * @module gateway/adapters/whatsapp
16
+ */
17
+
18
+ const BaseAdapter = require('./base');
19
+ const https = require('https');
20
+
21
+ /** Límite de caracteres por mensaje de WhatsApp. */
22
+ const MAX_MSG_LENGTH = 4096;
23
+
24
+ /** Comandos reconocidos. */
25
+ const COMMANDS = {
26
+ '/status': 'Estado del sistema SWL',
27
+ '/salud': 'Ejecutar diagnóstico de salud',
28
+ '/sesiones': 'Listar últimas sesiones',
29
+ '/approve': 'Aprobar operación pendiente',
30
+ '/reject': 'Rechazar operación pendiente',
31
+ };
32
+
33
+ class WhatsAppAdapter extends BaseAdapter {
34
+ constructor(config) {
35
+ super('whatsapp', config);
36
+ this._client = null;
37
+ this._mode = config.mode || 'business'; // 'business' | 'bridge'
38
+ }
39
+
40
+ async start() {
41
+ if (this._mode === 'bridge') {
42
+ return this._startBridge();
43
+ }
44
+ return this._startBusinessAPI();
45
+ }
46
+
47
+ /**
48
+ * Modo Business API (Cloud API de Meta).
49
+ * Requiere: WHATSAPP_TOKEN, WHATSAPP_PHONE_ID
50
+ */
51
+ async _startBusinessAPI() {
52
+ const token = this.config.token || process.env.WHATSAPP_TOKEN;
53
+ const phoneId = this.config.phoneId || process.env.WHATSAPP_PHONE_ID;
54
+
55
+ if (!token || !phoneId) {
56
+ console.log('[whatsapp] Token o Phone ID no configurado. Establecer WHATSAPP_TOKEN y WHATSAPP_PHONE_ID.');
57
+ return;
58
+ }
59
+
60
+ this._token = token;
61
+ this._phoneId = phoneId;
62
+ this.running = true;
63
+ console.log('[whatsapp] Adaptador iniciado (Business API mode).');
64
+ }
65
+
66
+ /**
67
+ * Modo bridge con whatsapp-web.js.
68
+ * Requiere: npm install whatsapp-web.js
69
+ */
70
+ async _startBridge() {
71
+ let wwjs;
72
+ try {
73
+ wwjs = require('whatsapp-web.js');
74
+ } catch (_) {
75
+ console.log('[whatsapp] whatsapp-web.js no instalado. Ejecutar: npm install whatsapp-web.js');
76
+ return;
77
+ }
78
+
79
+ const { Client, LocalAuth } = wwjs;
80
+
81
+ this._client = new Client({
82
+ authStrategy: new LocalAuth({ dataPath: '.planning/whatsapp-session' }),
83
+ puppeteer: { headless: true },
84
+ });
85
+
86
+ this._client.on('qr', (qr) => {
87
+ console.log('[whatsapp] Escanea el código QR para conectar:');
88
+ console.log('[whatsapp] QR:', qr.substring(0, 50) + '...');
89
+ });
90
+
91
+ this._client.on('ready', () => {
92
+ this.running = true;
93
+ console.log('[whatsapp] Adaptador iniciado (bridge mode).');
94
+ });
95
+
96
+ this._client.on('message', (msg) => {
97
+ if (msg.fromMe) return;
98
+
99
+ const text = msg.body || '';
100
+ const parts = text.split(/\s+/);
101
+ const command = COMMANDS[parts[0]] ? parts[0] : null;
102
+ const args = command ? parts.slice(1).join(' ') : '';
103
+
104
+ this._emitMessage({
105
+ chatId: msg.from,
106
+ userId: msg.from,
107
+ userName: msg._data?.notifyName || msg.from,
108
+ text: text,
109
+ command: command,
110
+ args: args,
111
+ raw: msg,
112
+ });
113
+ });
114
+
115
+ this._client.on('auth_failure', () => {
116
+ console.error('[whatsapp] Autenticación fallida. Eliminar .planning/whatsapp-session/ y reiniciar.');
117
+ });
118
+
119
+ try {
120
+ await this._client.initialize();
121
+ } catch (err) {
122
+ console.error(`[whatsapp] Error inicializando: ${err.message}`);
123
+ }
124
+ }
125
+
126
+ async stop() {
127
+ if (this._client) {
128
+ try { await this._client.destroy(); } catch (_) {}
129
+ this._client = null;
130
+ }
131
+ this.running = false;
132
+ console.log('[whatsapp] Adaptador detenido.');
133
+ }
134
+
135
+ async send(message) {
136
+ const chatId = message.chatId || this.config.defaultChat;
137
+ if (!chatId) return false;
138
+
139
+ const text = this.formatMessage(message);
140
+
141
+ if (this._mode === 'business') {
142
+ return this._sendBusinessAPI(chatId, text);
143
+ }
144
+
145
+ // Bridge mode
146
+ if (!this._client) return false;
147
+ try {
148
+ await this._client.sendMessage(chatId, text);
149
+ return true;
150
+ } catch (err) {
151
+ console.error(`[whatsapp] Error enviando: ${err.message}`);
152
+ return false;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Envía mensaje via WhatsApp Business Cloud API.
158
+ */
159
+ async _sendBusinessAPI(to, text) {
160
+ if (!this._token || !this._phoneId) return false;
161
+
162
+ const body = JSON.stringify({
163
+ messaging_product: 'whatsapp',
164
+ to: to.replace(/[^0-9]/g, ''),
165
+ type: 'text',
166
+ text: { body: text.substring(0, MAX_MSG_LENGTH) },
167
+ });
168
+
169
+ return new Promise((resolve) => {
170
+ const req = https.request({
171
+ hostname: 'graph.facebook.com',
172
+ path: `/v18.0/${this._phoneId}/messages`,
173
+ method: 'POST',
174
+ headers: {
175
+ 'Authorization': `Bearer ${this._token}`,
176
+ 'Content-Type': 'application/json',
177
+ 'Content-Length': Buffer.byteLength(body),
178
+ },
179
+ timeout: 10000,
180
+ }, (res) => {
181
+ let data = '';
182
+ res.on('data', chunk => data += chunk);
183
+ res.on('end', () => {
184
+ resolve(res.statusCode >= 200 && res.statusCode < 300);
185
+ });
186
+ });
187
+
188
+ req.on('error', () => resolve(false));
189
+ req.on('timeout', () => { req.destroy(); resolve(false); });
190
+ req.write(body);
191
+ req.end();
192
+ });
193
+ }
194
+
195
+ formatMessage(swlMessage) {
196
+ const payload = swlMessage.payload || swlMessage;
197
+
198
+ if (payload.jobName) {
199
+ const icon = payload.status === 'completed' ? '✅' : '❌';
200
+ const lines = [
201
+ `${icon} *${payload.jobName}*`,
202
+ `Estado: ${payload.status}`,
203
+ ];
204
+ if (payload.output) {
205
+ lines.push('```' + payload.output.substring(0, 800) + '```');
206
+ }
207
+ return lines.join('\n');
208
+ }
209
+
210
+ return swlMessage.text || JSON.stringify(payload).substring(0, 500);
211
+ }
212
+ }
213
+
214
+ module.exports = WhatsAppAdapter;