diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 1f44184..009f326 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -453,5 +453,24 @@ export const migrations: Migration[] = [ db.exec(`CREATE INDEX IF NOT EXISTS idx_groups_is_community ON groups (is_community);`); } catch {} } + }, + { + version: 17, + name: 'task-origins', + checksum: 'v17-task-origins-2025-10-20', + up: (db: Database) => { + db.exec(`PRAGMA foreign_keys = ON;`); + db.exec(` + CREATE TABLE IF NOT EXISTS task_origins ( + task_id INTEGER PRIMARY KEY, + chat_id TEXT NOT NULL, + message_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')), + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE + ); + `); + db.exec(`CREATE INDEX IF NOT EXISTS idx_task_origins_task ON task_origins (task_id);`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_task_origins_chat_msg ON task_origins (chat_id, message_id);`); + } } ]; diff --git a/src/server.ts b/src/server.ts index 838002c..a4c0294 100644 --- a/src/server.ts +++ b/src/server.ts @@ -477,17 +477,64 @@ export class WebhookServer { (TaskService as any).dbInstance = WebhookServer.dbInstance; // Delegar el manejo del comando + const messageId = typeof data?.key?.id === 'string' ? data.key.id : null; const responses = await CommandService.handle({ sender: normalizedSenderId, groupId: data.key.remoteJid, message: messageText, - mentions + mentions, + messageId: messageId || undefined }); // Encolar respuestas si las hay if (responses.length > 0) { await ResponseQueue.add(responses); } + + // Reaccionar al mensaje del comando (Fase 1) + try { + const reactionsEnabled = String(process.env.REACTIONS_ENABLED || 'false').toLowerCase(); + const enabled = ['true','1','yes','on'].includes(reactionsEnabled); + if (!enabled) return; + + if (!messageId) return; + + const scope = String(process.env.REACTIONS_SCOPE || 'groups').toLowerCase(); + const isGroup = isGroupId(data.key.remoteJid); + if (scope !== 'all' && !isGroup) return; + + // Respetar gating 'enforce' + try { (AllowedGroups as any).dbInstance = WebhookServer.dbInstance; } catch {} + const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); + if (mode === 'enforce' && isGroup) { + try { + if (!AllowedGroups.isAllowed(data.key.remoteJid)) { + return; + } + } catch {} + } + + // Heurística de outcome: si alguna respuesta sugiere error → ⚠️ + const anyError = (responses || []).some(r => { + const m = String(r?.message || '').toLowerCase(); + return m.startsWith('ℹ️ uso:'.toLowerCase()) + || m.includes('uso:'.toLowerCase()) + || m.includes('no puedes') + || m.includes('no permitido') + || m.includes('no encontrada') + || m.includes('comando no reconocido') + || (m.includes('acción') && m.includes('no reconocida')) + || m.includes('⚠️'.toLowerCase()); + }); + const emoji = anyError ? '⚠️' : '🤖'; + + await ResponseQueue.enqueueReaction(data.key.remoteJid, messageId, emoji); + } catch (e) { + // No romper el flujo por errores de reacción + if (process.env.NODE_ENV !== 'test') { + console.warn('⚠️ Reaction enqueue failed:', e); + } + } } } diff --git a/src/services/command.ts b/src/services/command.ts index f276d81..a65dc01 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -17,6 +17,7 @@ type CommandContext = { groupId: string; // full JID (e.g., xxx@g.us) message: string; // raw message text mentions: string[]; // array of raw JIDs mentioned + messageId?: string; // id del mensaje origen (para task_origins y reacciones) }; export type CommandResponse = { @@ -1206,6 +1207,16 @@ export class CommandService { })) ); + // Registrar origen del comando para esta tarea (Fase 1) + try { + if (groupIdToUse && isGroupId(groupIdToUse) && context.messageId) { + this.dbInstance.prepare(` + INSERT OR IGNORE INTO task_origins (task_id, chat_id, message_id) + VALUES (?, ?, ?) + `).run(taskId, groupIdToUse, context.messageId); + } + } catch {} + // Recuperar la tarea creada para obtener display_code asignado const createdTask = TaskService.getTaskById(taskId); diff --git a/src/services/response-queue.ts b/src/services/response-queue.ts index 18d09b7..deadcde 100644 --- a/src/services/response-queue.ts +++ b/src/services/response-queue.ts @@ -42,6 +42,7 @@ export const ResponseQueue = { MAX_ATTEMPTS: process.env.RQ_MAX_ATTEMPTS ? Number(process.env.RQ_MAX_ATTEMPTS) : 6, BASE_BACKOFF_MS: process.env.RQ_BASE_BACKOFF_MS ? Number(process.env.RQ_BASE_BACKOFF_MS) : 5000, MAX_BACKOFF_MS: process.env.RQ_MAX_BACKOFF_MS ? Number(process.env.RQ_MAX_BACKOFF_MS) : 3600000, + REACTIONS_MAX_ATTEMPTS: process.env.RQ_REACTIONS_MAX_ATTEMPTS ? Number(process.env.RQ_REACTIONS_MAX_ATTEMPTS) : null, // Limpieza/retención (configurable por entorno) CLEANUP_ENABLED: process.env.RQ_CLEANUP_ENABLED !== 'false', @@ -106,6 +107,42 @@ export const ResponseQueue = { } }, + // Encolar una reacción con idempotencia (24h) usando metadata canónica + async enqueueReaction(chatId: string, messageId: string, emoji: string): Promise { + try { + if (!chatId || !messageId || !emoji) return; + + // Construir JSON canónico + const metaObj = { kind: 'reaction', emoji, chatId, messageId }; + const metadata = JSON.stringify(metaObj); + + // Ventana de 24h + const cutoff = this.futureIso(-24 * 60 * 60 * 1000); + + // Idempotencia: existe job igual reciente en estados activos? + const exists = this.dbInstance.prepare(` + SELECT 1 + FROM response_queue + WHERE metadata = ? + AND status IN ('queued','processing','sent') + AND (updated_at > ? OR created_at > ?) + LIMIT 1 + `).get(metadata, cutoff, cutoff) as any; + + if (exists) { + return; + } + + this.dbInstance.prepare(` + INSERT INTO response_queue (recipient, message, metadata, next_attempt_at) + VALUES (?, ?, ?, ?) + `).run(chatId, '', metadata, this.nowIso()); + } catch (err) { + console.error('Failed to enqueue reaction:', err); + throw err; + } + }, + getHeaders(): HeadersInit { return { apikey: process.env.EVOLUTION_API_KEY || '', @@ -122,6 +159,42 @@ export const ResponseQueue = { return { ok: false, error: msg }; } + // Detectar jobs de reacción + let meta: any = null; + try { meta = item.metadata ? JSON.parse(item.metadata) : null; } catch {} + if (meta && meta.kind === 'reaction') { + const reactionUrl = `${baseUrl}/message/sendReaction/${instance}`; + const chatId = String(meta.chatId || ''); + const messageId = String(meta.messageId || ''); + const emoji = String(meta.emoji || ''); + if (!chatId || !messageId || !emoji) { + return { ok: false, error: 'invalid_reaction_metadata' }; + } + const payload = { + key: { remoteJid: chatId, fromMe: true, id: messageId }, + reaction: emoji + }; + try { + const response = await fetch(reactionUrl, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(payload), + }); + if (!response.ok) { + const body = await response.text().catch(() => ''); + const errTxt = body?.slice(0, 200) || `HTTP ${response.status}`; + console.warn('Send reaction failed:', { status: response.status, body: errTxt }); + return { ok: false, status: response.status, error: errTxt }; + } + console.log(`✅ Sent reaction with payload: ${JSON.stringify(payload)}`); + return { ok: true, status: response.status }; + } catch (err) { + const errMsg = (err instanceof Error ? err.message : String(err)); + console.error('Network error sending reaction:', errMsg); + return { ok: false, error: errMsg }; + } + } + // Endpoint típico de Evolution API para texto simple const url = `${baseUrl}/message/sendText/${instance}`; @@ -294,8 +367,13 @@ export const ResponseQueue = { continue; } - // 5xx o error de red: reintento con backoff si no superó el máximo - if (attemptsNow >= this.MAX_ATTEMPTS) { + // 5xx o error de red: reintento con backoff si no superó el máximo (ajustado para reacciones) + let metaForMax: any = null; + try { metaForMax = item.metadata ? JSON.parse(String(item.metadata)) : null; } catch {} + const isReactionJob = !!(metaForMax && metaForMax.kind === 'reaction'); + const effectiveMax = isReactionJob && this.REACTIONS_MAX_ATTEMPTS ? this.REACTIONS_MAX_ATTEMPTS : this.MAX_ATTEMPTS; + + if (attemptsNow >= effectiveMax) { this.markFailed(item.id, errMsg, status, attemptsNow); continue; }