feat: añadir handleWithOutcome y usar outcome en WebhookServer

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

@ -81,7 +81,7 @@ Envío (Evolution API):
- Body: - Body:
``` ```
{ {
"key": { "remoteJid": "<jid>", "fromMe": true, "id": "<msg-id>" }, "key": { "remoteJid": "<jid>", "fromMe": false, "id": "<msg-id>" },
"reaction": "<emoji>" "reaction": "<emoji>"
} }
``` ```

@ -478,20 +478,21 @@ export class WebhookServer {
// Delegar el manejo del comando // Delegar el manejo del comando
const messageId = typeof data?.key?.id === 'string' ? data.key.id : null; const messageId = typeof data?.key?.id === 'string' ? data.key.id : null;
const responses = await CommandService.handle({ const outcome = await CommandService.handleWithOutcome({
sender: normalizedSenderId, sender: normalizedSenderId,
groupId: data.key.remoteJid, groupId: data.key.remoteJid,
message: messageText, message: messageText,
mentions, mentions,
messageId: messageId || undefined messageId: messageId || undefined
}); });
const responses = outcome.responses;
// 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) // Reaccionar al mensaje del comando con outcome explícito
try { try {
const reactionsEnabled = String(process.env.REACTIONS_ENABLED || 'false').toLowerCase(); const reactionsEnabled = String(process.env.REACTIONS_ENABLED || 'false').toLowerCase();
const enabled = ['true','1','yes','on'].includes(reactionsEnabled); const enabled = ['true','1','yes','on'].includes(reactionsEnabled);
@ -514,20 +515,7 @@ export class WebhookServer {
} catch {} } catch {}
} }
// Heurística de outcome: si alguna respuesta sugiere error → ⚠️ const emoji = outcome.ok ? '🤖' : '⚠️';
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); await ResponseQueue.enqueueReaction(data.key.remoteJid, messageId, emoji);
} catch (e) { } catch (e) {
// No romper el flujo por errores de reacción // No romper el flujo por errores de reacción

@ -26,6 +26,12 @@ export type CommandResponse = {
mentions?: string[]; // full JIDs to mention in the outgoing message mentions?: string[]; // full JIDs to mention in the outgoing message
}; };
export type CommandOutcome = {
responses: CommandResponse[];
ok: boolean;
createdTaskIds?: number[];
};
export class CommandService { export class CommandService {
static dbInstance: Database = db; static dbInstance: Database = db;
@ -1306,12 +1312,17 @@ export class CommandService {
} }
static async handle(context: CommandContext): Promise<CommandResponse[]> { static async handle(context: CommandContext): Promise<CommandResponse[]> {
const outcome = await this.handleWithOutcome(context);
return outcome.responses;
}
static async handleWithOutcome(context: CommandContext): Promise<CommandOutcome> {
const msg = (context.message || '').trim(); const msg = (context.message || '').trim();
if (!/^\/(tarea|t)\b/i.test(msg)) { if (!/^\/(tarea|t)\b/i.test(msg)) {
return []; return { responses: [], ok: true };
} }
// Gating de grupos en modo 'enforce' (Etapa 3) cuando CommandService se invoca directamente // Gating de grupos en modo 'enforce' (cuando CommandService se invoca directamente)
if (isGroupId(context.groupId)) { if (isGroupId(context.groupId)) {
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {} try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {}
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
@ -1319,7 +1330,7 @@ export class CommandService {
try { try {
if (!AllowedGroups.isAllowed(context.groupId)) { if (!AllowedGroups.isAllowed(context.groupId)) {
try { Metrics.inc('commands_blocked_total'); } catch {} try { Metrics.inc('commands_blocked_total'); } catch {}
return []; return { responses: [], ok: true };
} }
} catch { } catch {
// Si falla el check, ser permisivos // Si falla el check, ser permisivos
@ -1328,12 +1339,79 @@ export class CommandService {
} }
try { try {
return await this.processTareaCommand(context); const responses = await this.processTareaCommand(context);
// Clasificación explícita del outcome (evita lógica en server)
const tokens = msg.split(/\s+/);
const rawAction = (tokens[1] || '').toLowerCase();
const ACTION_ALIASES: Record<string, string> = {
'n': 'nueva',
'nueva': 'nueva',
'crear': 'nueva',
'+': 'nueva',
'ver': 'ver',
'mostrar': 'ver',
'listar': 'ver',
'ls': 'ver',
'x': 'completar',
'hecho': 'completar',
'completar': 'completar',
'done': 'completar',
'tomar': 'tomar',
'claim': 'tomar',
'asumir': 'tomar',
'asumo': 'tomar',
'soltar': 'soltar',
'unassign': 'soltar',
'dejar': 'soltar',
'liberar': 'soltar',
'renunciar': 'soltar',
'ayuda': 'ayuda',
'help': 'ayuda',
'?': 'ayuda',
'config': 'configurar',
'configurar': 'configurar',
'web': 'web'
};
const action = ACTION_ALIASES[rawAction] || rawAction;
// Casos explícitos considerados éxito
if (!action || action === 'ayuda' || action === 'web') {
return { responses, ok: true };
}
const lowerMsgs = (responses || []).map(r => String(r?.message || '').toLowerCase());
const isOkException = (m: string) =>
m.includes('ya estaba completada') ||
m.includes('ya la tenías') ||
m.includes('no la tenías');
const isErrorMsg = (m: string) =>
m.startsWith(' uso:'.toLowerCase()) ||
m.includes('uso:') ||
m.includes('no puedes') ||
m.includes('no permitido') ||
m.includes('no encontrada') ||
m.includes('comando no reconocido');
let hasError = false;
for (const m of lowerMsgs) {
if (isErrorMsg(m) && !isOkException(m)) {
hasError = true;
break;
}
}
return { responses, ok: !hasError };
} catch (error) { } catch (error) {
return [{ return {
responses: [{
recipient: context.sender, recipient: context.sender,
message: 'Error processing command' message: 'Error processing command'
}]; }],
ok: false
};
} }
} }
} }

@ -115,6 +115,7 @@ export const ResponseQueue = {
// Construir JSON canónico // Construir JSON canónico
const metaObj = { kind: 'reaction', emoji, chatId, messageId }; const metaObj = { kind: 'reaction', emoji, chatId, messageId };
const metadata = JSON.stringify(metaObj); const metadata = JSON.stringify(metaObj);
const emojiLabel = emoji === '✅' ? 'check' : (emoji === '🤖' ? 'robot' : (emoji === '⚠️' ? 'warn' : 'other'));
// Ventana de 24h // Ventana de 24h
const cutoff = this.futureIso(-24 * 60 * 60 * 1000); const cutoff = this.futureIso(-24 * 60 * 60 * 1000);
@ -137,6 +138,7 @@ export const ResponseQueue = {
INSERT INTO response_queue (recipient, message, metadata, next_attempt_at) INSERT INTO response_queue (recipient, message, metadata, next_attempt_at)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
`).run(chatId, '', metadata, this.nowIso()); `).run(chatId, '', metadata, this.nowIso());
try { Metrics.inc('reactions_enqueued_total', 1, { emoji: emojiLabel }); } catch {}
} catch (err) { } catch (err) {
console.error('Failed to enqueue reaction:', err); console.error('Failed to enqueue reaction:', err);
throw err; throw err;

@ -318,7 +318,6 @@ export class TaskService {
if (allowed) { if (allowed) {
// Encolar reacción ✅ con idempotencia; no bloquear si falla // Encolar reacción ✅ con idempotencia; no bloquear si falla
ResponseQueue.enqueueReaction(chatId, String(origin.message_id), '✅') ResponseQueue.enqueueReaction(chatId, String(origin.message_id), '✅')
.then(() => { try { Metrics.inc('reactions_enqueued_total', 1, { emoji: 'check' }); } catch {} })
.catch(() => {}); .catch(() => {});
} }
} }

Loading…
Cancel
Save