|
|
|
|
@ -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 {
|
|
|
|
|
for (let i = 2; i < parts.length; i++) {
|
|
|
|
|
const token = parts[i];
|
|
|
|
|
if (isMentionToken(token)) continue;
|
|
|
|
|
descriptionTokens.push(token);
|
|
|
|
|
}
|
|
|
|
|
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 [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|