feat: agrega migración v17 task_origins y soporte de reacciones en webhook y queue

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
main
brobert 1 week ago
parent f020c809ec
commit db8d22c04c

@ -453,5 +453,24 @@ export const migrations: Migration[] = [
db.exec(`CREATE INDEX IF NOT EXISTS idx_groups_is_community ON groups (is_community);`); db.exec(`CREATE INDEX IF NOT EXISTS idx_groups_is_community ON groups (is_community);`);
} catch {} } 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);`);
}
} }
]; ];

@ -477,17 +477,64 @@ export class WebhookServer {
(TaskService as any).dbInstance = WebhookServer.dbInstance; (TaskService as any).dbInstance = WebhookServer.dbInstance;
// Delegar el manejo del comando // Delegar el manejo del comando
const messageId = typeof data?.key?.id === 'string' ? data.key.id : null;
const responses = await CommandService.handle({ const responses = await CommandService.handle({
sender: normalizedSenderId, sender: normalizedSenderId,
groupId: data.key.remoteJid, groupId: data.key.remoteJid,
message: messageText, message: messageText,
mentions mentions,
messageId: messageId || undefined
}); });
// Encolar respuestas si las hay // Encolar respuestas si las hay
if (responses.length > 0) { if (responses.length > 0) {
await ResponseQueue.add(responses); 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);
}
}
} }
} }

@ -17,6 +17,7 @@ type CommandContext = {
groupId: string; // full JID (e.g., xxx@g.us) groupId: string; // full JID (e.g., xxx@g.us)
message: string; // raw message text message: string; // raw message text
mentions: string[]; // array of raw JIDs mentioned mentions: string[]; // array of raw JIDs mentioned
messageId?: string; // id del mensaje origen (para task_origins y reacciones)
}; };
export type CommandResponse = { 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 // Recuperar la tarea creada para obtener display_code asignado
const createdTask = TaskService.getTaskById(taskId); const createdTask = TaskService.getTaskById(taskId);

@ -42,6 +42,7 @@ export const ResponseQueue = {
MAX_ATTEMPTS: process.env.RQ_MAX_ATTEMPTS ? Number(process.env.RQ_MAX_ATTEMPTS) : 6, 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, 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, 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) // Limpieza/retención (configurable por entorno)
CLEANUP_ENABLED: process.env.RQ_CLEANUP_ENABLED !== 'false', 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<void> {
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 { getHeaders(): HeadersInit {
return { return {
apikey: process.env.EVOLUTION_API_KEY || '', apikey: process.env.EVOLUTION_API_KEY || '',
@ -122,6 +159,42 @@ export const ResponseQueue = {
return { ok: false, error: msg }; 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 // Endpoint típico de Evolution API para texto simple
const url = `${baseUrl}/message/sendText/${instance}`; const url = `${baseUrl}/message/sendText/${instance}`;
@ -294,8 +367,13 @@ export const ResponseQueue = {
continue; continue;
} }
// 5xx o error de red: reintento con backoff si no superó el máximo // 5xx o error de red: reintento con backoff si no superó el máximo (ajustado para reacciones)
if (attemptsNow >= this.MAX_ATTEMPTS) { 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); this.markFailed(item.id, errMsg, status, attemptsNow);
continue; continue;
} }

Loading…
Cancel
Save