feat: habilita /t y /tarea con parsing de fechas y respuestas compactas

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
pull/1/head
borja 2 months ago
parent 509e8dfb36
commit 714c7a6c4e

@ -0,0 +1,51 @@
# Pruebas manuales sugeridas (Iteración A)
Ejecuta el servidor (entorno de desarrollo) y usa un cliente WhatsApp conectado a Evolution API.
1) Comando base y ayuda
- En un grupo activo: enviar “/t” o “/t ayuda”.
- Esperado: no aparece nada en el grupo; recibes un DM con la guía rápida.
- En DM al bot: enviar “/t”.
- Esperado: recibes el mismo DM de ayuda.
2) Crear tarea en grupo (sin menciones)
- Enviar en el grupo: “/t n Comprar leche mañana”.
- Esperado:
- Se crea la tarea con due_date = YYYY-MM-DD (mañana).
- No se asigna a nadie (sin dueño).
- No aparece nada en el grupo.
- Recibes un DM con formato compacto:
<id> “*Comprar leche*”
📅 <fecha>
👥 sin dueño (<Nombre del grupo>)
3) Crear tarea en DM (sin menciones)
- Enviar al bot por DM: “/t n Pagar comedor hoy”.
- Esperado:
- Se crea la tarea con due_date = YYYY-MM-DD (hoy).
- Tarea asignada a ti (creador).
- Recibes un DM de confirmación (formato compacto).
- No se envía nada a ningún grupo.
4) Crear tarea con menciones en grupo
- Enviar: “/t n Acta de la reunión 2025-09-12 @34611122233”.
- Esperado:
- Se crea la tarea con due_date 2025-09-12.
- Se asigna a 34611122233 (normalizado).
- No aparece nada en el grupo.
- DM al creador con:
<id> “*Acta de la reunión*”
📅 2025-09-12
👤 <nombre o número del asignado>
- DM al asignado:
🔔 <id> — 📅 2025-09-12
“*Acta de la reunión*”
Grupo: <Nombre del grupo>
Completar: /t x <id>
5) Prefijos aceptados
- Repetir 24 usando “/tarea n ...” (debe comportarse igual que “/t ...”).
Notas
- En el log del servidor verás “✅ Sent message to with this as payload: ...” por cada DM encolado y enviado por Evolution API.
- Bajo tests (NODE_ENV=test), el servicio evita llamadas de red del ContactsService, por lo que los nombres pueden mostrarse como números.

@ -5,7 +5,7 @@ import { GroupSyncService } from './services/group-sync';
import { ResponseQueue } from './services/response-queue';
import { TaskService } from './tasks/service';
import { WebhookManager } from './services/webhook-manager';
import { normalizeWhatsAppId } from './utils/whatsapp';
import { normalizeWhatsAppId, isGroupId } from './utils/whatsapp';
import { ensureUserExists, db, initializeDatabase } from './db';
import { ContactsService } from './services/contacts';
@ -144,8 +144,8 @@ export class WebhookServer {
return;
}
// Check if the group is active
if (!GroupSyncService.isGroupActive(data.key.remoteJid)) {
// Check if the group is active (allow DMs always)
if (isGroupId(data.key.remoteJid) && !GroupSyncService.isGroupActive(data.key.remoteJid)) {
if (process.env.NODE_ENV !== 'test') {
console.log('⚠️ Group is not active, ignoring message');
}
@ -154,9 +154,10 @@ export class WebhookServer {
// Forward to command service only if:
// 1. It's a text message (has conversation field)
// 2. Starts with /tarea command
// 2. Starts with /t or /tarea command
const messageText = data.message.conversation;
if (typeof messageText === 'string' && messageText.trim().startsWith('/tarea')) {
const trimmedMessage = typeof messageText === 'string' ? messageText.trim() : '';
if (trimmedMessage.startsWith('/tarea') || trimmedMessage.startsWith('/t')) {
// Extraer menciones desde el mensaje
const mentions = data.message?.contextInfo?.mentionedJid || [];

@ -1,6 +1,6 @@
import type { Database } from 'bun:sqlite';
import { db, ensureUserExists } from '../db';
import { normalizeWhatsAppId } from '../utils/whatsapp';
import { normalizeWhatsAppId, isGroupId } from '../utils/whatsapp';
import { TaskService } from '../tasks/service';
import { GroupSyncService } from './group-sync';
import { ContactsService } from './contacts';
@ -29,49 +29,62 @@ export class CommandService {
const parts = (message || '').trim().split(/\s+/);
const action = (parts[1] || '').toLowerCase();
// Buscar última fecha futura con formato YYYY-MM-DD
const dateIndices: { index: number; text: string }[] = [];
const today = new Date();
today.setHours(0, 0, 0, 0);
const formatYMD = (d: Date) => d.toISOString().slice(0, 10);
type DateCandidate = { index: number; ymd: string };
const dateCandidates: DateCandidate[] = [];
const dateTokenIndexes = new Set<number>();
for (let i = 2; i < parts.length; i++) {
const p = parts[i];
const low = p.toLowerCase();
if (/^\d{4}-\d{2}-\d{2}$/.test(p)) {
const d = new Date(p);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (!isNaN(d.getTime()) && d >= today) {
dateIndices.push({ index: i, text: p });
if (!isNaN(d.getTime())) {
d.setHours(0, 0, 0, 0);
if (d >= today) {
dateCandidates.push({ index: i, ymd: formatYMD(d) });
dateTokenIndexes.add(i);
}
}
continue;
}
if (low === 'hoy') {
dateCandidates.push({ index: i, ymd: formatYMD(today) });
dateTokenIndexes.add(i);
continue;
}
if (low === 'mañana' || low === 'manana') {
const tmr = new Date(today);
tmr.setDate(tmr.getDate() + 1);
dateCandidates.push({ index: i, ymd: formatYMD(tmr) });
dateTokenIndexes.add(i);
continue;
}
}
let dueDate: string | null = null;
let descriptionTokens: string[] = [];
const dueDate = dateCandidates.length > 0
? dateCandidates[dateCandidates.length - 1].ymd
: null;
const isMentionToken = (token: string) => token.startsWith('@');
if (dateIndices.length > 0) {
const last = dateIndices[dateIndices.length - 1];
dueDate = last.text;
for (let i = 2; i < parts.length; i++) {
if (i === last.index) continue;
const token = parts[i];
if (isMentionToken(token)) continue; // quitar @menciones del texto
descriptionTokens.push(token);
}
} else {
const descriptionTokens: string[] = [];
for (let i = 2; i < parts.length; i++) {
if (dateTokenIndexes.has(i)) continue;
const token = parts[i];
if (isMentionToken(token)) continue;
descriptionTokens.push(token);
}
}
const description = descriptionTokens.join(' ').trim();
return {
action,
description,
dueDate,
};
return { action, description, dueDate };
}
private static async processTareaCommand(
@ -79,12 +92,50 @@ export class CommandService {
): Promise<CommandResponse[]> {
const trimmed = (context.message || '').trim();
const tokens = trimmed.split(/\s+/);
const action = (tokens[1] || '').toLowerCase();
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',
'soltar': 'soltar',
'unassign': 'soltar',
'ayuda': 'ayuda',
'help': 'ayuda',
'?': 'ayuda',
'config': 'configurar',
'configurar': 'configurar'
};
const action = ACTION_ALIASES[rawAction] || rawAction;
if (!action || action === 'ayuda') {
const help = [
'Guía rápida:',
'- Crear: /t n Descripción mañana @Ana',
'- Ver grupo: /t ver grupo',
'- Ver mis: /t ver mis',
'- Completar: /t x 123'
].join('\n');
return [{
recipient: context.sender,
message: help
}];
}
if (action !== 'nueva') {
return [{
recipient: context.sender,
message: `Acción ${action || '(vacía)'} no implementada aún`
message: `Acción ${rawAction || '(vacía)'} no implementada aún`
}];
}
@ -129,8 +180,15 @@ export class CommandService {
.map(id => ensureUserExists(id, this.dbInstance))
.filter((id): id is string => !!id);
// Si no hay asignados, asignar al creador
const assignmentUserIds = ensuredAssignees.length > 0 ? ensuredAssignees : [createdBy];
// Asignación por defecto según contexto:
// - En grupos: si no hay menciones → sin dueño (ningún asignado)
// - En DM: si no hay menciones → asignada al creador
let assignmentUserIds: string[] = [];
if (ensuredAssignees.length > 0) {
assignmentUserIds = ensuredAssignees;
} else {
assignmentUserIds = (context.groupId && isGroupId(context.groupId)) ? [] : [createdBy];
}
// Definir group_id solo si el grupo está activo
const groupIdToUse = (context.groupId && GroupSyncService.isGroupActive(context.groupId))
@ -167,10 +225,19 @@ export class CommandService {
const responses: CommandResponse[] = [];
// 1) Ack al creador siempre, en una sola línea con id y descripción
// 1) Ack al creador con formato compacto
const ackHeader = `${taskId} “*${description || '(sin descripción)'}*”`;
const ackLines: string[] = [ackHeader];
if (dueDate) ackLines.push(`📅 ${dueDate}`);
if (assignmentUserIds.length === 0) {
ackLines.push(`👥 sin dueño${groupName ? ` (${groupName})` : ''}`);
} else {
const assigneesList = assignedDisplayNames.join(', ');
ackLines.push(`${assignmentUserIds.length > 1 ? '👥' : '👤'} ${assigneesList}`);
}
responses.push({
recipient: createdBy,
message: `✅ Tarea ${taskId} creada: "${description || '(sin descripción)'}"`,
message: ackLines.join('\n'),
mentions: mentionsForSending.length > 0 ? mentionsForSending : undefined
});
@ -179,38 +246,24 @@ export class CommandService {
if (uid === createdBy) continue;
responses.push({
recipient: uid,
message:
`🆕 Nueva tarea:\n` +
`${description || '(sin descripción)'}\n` +
(dueDate ? `• Vence: ${dueDate}\n` : '') +
`• Asignada por: ${creatorName || createdBy} (@${createdBy})` +
(groupName ? `\n• Grupo: ${groupName}` : ''),
message: [
`🔔 ${taskId}${dueDate ? ` — 📅 ${dueDate}` : ''}`,
`“*${description || '(sin descripción)'}*”`,
groupName ? `Grupo: ${groupName}` : null,
`Completar: /t x ${taskId}`
].filter(Boolean).join('\n'),
mentions: [creatorJid]
});
}
// 3) Opcional: mensaje al grupo con menciones para visibilidad
if (groupIdToUse && process.env.NOTIFY_GROUP_ON_CREATE === 'true') {
const assignNamesForGroup = await Promise.all(
assignmentUserIds.map(async uid => '@' + (await ContactsService.getDisplayName(uid) || uid))
);
responses.push({
recipient: groupIdToUse,
message:
`📝 Tarea ${taskId} creada por ${creatorName || createdBy}:\n` +
`${description || '(sin descripción)'}\n` +
(dueDate ? `• Vence: ${dueDate}\n` : '') +
(assignNamesForGroup.length ? `• Asignados: ${assignNamesForGroup.join(' ')}` : ''),
mentions: mentionsForSending.length > 0 ? mentionsForSending : undefined
});
}
return responses;
}
static async handle(context: CommandContext): Promise<CommandResponse[]> {
if (!context.message.startsWith('/tarea')) {
const msg = (context.message || '').trim();
if (!/^\/(tarea|t)\b/i.test(msg)) {
return [];
}

Loading…
Cancel
Save