You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

839 lines
31 KiB
TypeScript

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import type { Database } from 'bun:sqlite';
import { db, ensureUserExists } from '../db';
import { normalizeWhatsAppId, isGroupId } from '../utils/whatsapp';
import { TaskService } from '../tasks/service';
import { GroupSyncService } from './group-sync';
import { ContactsService } from './contacts';
import { ICONS } from '../utils/icons';
import { padTaskId, codeId, formatDDMM, bold, italic } from '../utils/formatting';
type CommandContext = {
sender: string; // normalized user id (digits only), but accept raw too
groupId: string; // full JID (e.g., xxx@g.us)
message: string; // raw message text
mentions: string[]; // array of raw JIDs mentioned
};
export type CommandResponse = {
recipient: string;
message: string;
mentions?: string[]; // full JIDs to mention in the outgoing message
};
export class CommandService {
static dbInstance: Database = db;
private static parseNueva(message: string, mentionsNormalized: string[]): {
action: string;
description: string;
dueDate: string | null;
} {
const parts = (message || '').trim().split(/\s+/);
const action = (parts[1] || '').toLowerCase();
// Zona horaria configurable (por defecto Europe/Madrid)
const TZ = process.env.TZ && process.env.TZ.trim() ? process.env.TZ : 'Europe/Madrid';
// Utilidades locales para operar con fechas en la TZ elegida sin depender del huso del host
const ymdFromDateInTZ = (d: Date): string => {
const fmt = new Intl.DateTimeFormat('en-GB', {
timeZone: TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(d);
const get = (t: string) => fmt.find(p => p.type === t)?.value || '';
return `${get('year')}-${get('month')}-${get('day')}`;
};
const addDaysToYMD = (ymd: string, days: number): string => {
const [Y, M, D] = ymd.split('-').map(n => parseInt(n, 10));
const base = new Date(Date.UTC(Y, (M || 1) - 1, D || 1));
base.setUTCDate(base.getUTCDate() + days);
return ymdFromDateInTZ(base);
};
const todayYMD = ymdFromDateInTZ(new Date());
type DateCandidate = { index: number; ymd: string };
const dateCandidates: DateCandidate[] = [];
const dateTokenIndexes = new Set<number>();
for (let i = 2; i < parts.length; i++) {
// Normalizar token: minúsculas y sin puntuación adyacente simple
const raw = parts[i];
const low = raw.toLowerCase().replace(/^[([{¿¡"']+/, '').replace(/[.,;:!?)\]}¿¡"'']+$/, '');
// Fecha explícita YYYY-MM-DD
if (/^\d{4}-\d{2}-\d{2}$/.test(low)) {
// Validar rango básico y filtrar pasado según hoy en TZ
if (low >= todayYMD) {
dateCandidates.push({ index: i, ymd: low });
dateTokenIndexes.add(i);
}
continue;
}
// Tokens naturales "hoy"/"mañana" (con o sin acento)
if (low === 'hoy') {
dateCandidates.push({ index: i, ymd: todayYMD });
dateTokenIndexes.add(i);
continue;
}
if (low === 'mañana' || low === 'manana') {
dateCandidates.push({ index: i, ymd: addDaysToYMD(todayYMD, 1) });
dateTokenIndexes.add(i);
continue;
}
}
const dueDate = dateCandidates.length > 0
? dateCandidates[dateCandidates.length - 1].ymd
: null;
const isMentionToken = (token: string) => token.startsWith('@');
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 };
}
private static async processTareaCommand(
context: CommandContext
): Promise<CommandResponse[]> {
const trimmed = (context.message || '').trim();
const tokens = trimmed.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'
};
const action = ACTION_ALIASES[rawAction] || rawAction;
// Usar formatDDMM desde utils/formatting
// TZ y "hoy" en TZ para marcar vencidas en listados
const TZ = process.env.TZ && process.env.TZ.trim() ? process.env.TZ : 'Europe/Madrid';
const ymdInTZ = (d: Date): string => {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(d);
const get = (t: string) => parts.find(p => p.type === t)?.value || '';
return `${get('year')}-${get('month')}-${get('day')}`;
};
const todayYMD = ymdInTZ(new Date());
if (!action || action === 'ayuda') {
const isAdvanced = (tokens[2] || '').toLowerCase() === 'avanzada';
if (isAdvanced) {
const adv = [
'Ayuda avanzada:',
'- Comandos y alias:',
' · Crear: n, nueva, crear, +',
' · Ver: ver, mostrar, listar, ls (scopes: grupo | mis | todos | sin)',
' · Completar: x, hecho, completar, done',
' · Tomar: tomar, claim',
' · Soltar: soltar, unassign',
'- Preferencias:',
' · /t configurar daily|weekly|off (hora por defecto 08:30; semanal: lunes 08:30)',
'- Notas:',
' · En grupos, el bot responde por DM al autor (no publica en el grupo).',
' · Si creas en grupo y no mencionas a nadie → “sin responsable”; en DM → se asigna al creador.',
' · Fechas dd/MM con ⚠️ si está vencida.',
' · Mostramos los IDs con 4 dígitos, pero puedes escribirlos sin ceros (p. ej., 26).',
].join('\n');
return [{
recipient: context.sender,
message: adv
}];
}
const help = [
'Guía rápida:',
'- Crear: /t n Descripción mañana @Ana',
'- Ver grupo: /t ver grupo',
'- Ver mis: /t ver mis',
'- Ver todos: /t ver todos',
'- Completar: /t x 123',
'- Configurar recordatorios: /t configurar daily|weekly|off'
].join('\n');
return [{
recipient: context.sender,
message: help
}];
}
// Listar pendientes
if (action === 'ver') {
const scopeRaw = (tokens[2] || '').toLowerCase();
const SCOPE_ALIASES: Record<string, 'grupo' | 'mis' | 'todos' | 'sin'> = {
'todo': 'todos',
'todos': 'todos',
'todas': 'todos',
'mis': 'mis',
'mias': 'mis',
'mías': 'mis',
'yo': 'mis',
};
const scope = scopeRaw ? (SCOPE_ALIASES[scopeRaw] || scopeRaw) : (isGroupId(context.groupId) ? 'grupo' : 'mis');
const LIMIT = 10;
// Ver sin dueño del grupo actual
if (scope === 'sin') {
if (!isGroupId(context.groupId)) {
return [{
recipient: context.sender,
message: 'Este comando se usa en grupos. Prueba: /t ver mis'
}];
}
if (!GroupSyncService.isGroupActive(context.groupId)) {
return [{
recipient: context.sender,
message: '⚠️ Este grupo no está activo.'
}];
}
const items = TaskService.listGroupUnassigned(context.groupId, LIMIT);
const groupName = GroupSyncService.activeGroupsCache.get(context.groupId) || context.groupId;
if (items.length === 0) {
return [{
recipient: context.sender,
message: `No hay tareas sin responsable en ${groupName}.`
}];
}
const rendered = items.map((t) => {
const isOverdue = t.due_date ? t.due_date < todayYMD : false;
const datePart = t.due_date ? `${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : '';
return `- ${codeId(t.id)} _${t.description || '(sin descripción)'}_${datePart}${ICONS.unassigned} sin responsable`;
});
const total = TaskService.countGroupUnassigned(context.groupId);
if (total > items.length) {
rendered.push(`… y ${total - items.length} más`);
}
return [{
recipient: context.sender,
message: [`${groupName} — Sin responsable`, ...rendered].join('\n')
}];
}
// Ver todos: "tus tareas" + "sin responsable" de grupos donde eres miembro activo (snapshot fresca)
if (scope === 'todos') {
const sections: string[] = [];
// Encabezado fijo para la sección de tareas del usuario
sections.push(bold('Tus tareas'));
// Tus tareas (mis)
const myItems = TaskService.listUserPending(context.sender, LIMIT);
if (myItems.length > 0) {
// Agrupar por grupo como en "ver mis"
const byGroup = new Map<string, typeof myItems>();
for (const t of myItems) {
const key = t.group_id || '(sin grupo)';
const arr = byGroup.get(key) || [];
arr.push(t);
byGroup.set(key, arr);
}
for (const [groupId, arr] of byGroup.entries()) {
const groupName =
(groupId && GroupSyncService.activeGroupsCache.get(groupId)) ||
(groupId && groupId !== '(sin grupo)' ? groupId : 'Sin grupo');
sections.push(groupName);
const rendered = await Promise.all(arr.map(async (t) => {
const names = await Promise.all(
(t.assignees || []).map(async (uid) => (await ContactsService.getDisplayName(uid)) || uid)
);
const owner =
(t.assignees?.length || 0) === 0
? `${ICONS.unassigned} sin responsable`
: `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`;
const isOverdue = t.due_date ? t.due_date < todayYMD : false;
const datePart = t.due_date ? `${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : '';
return `- ${codeId(t.id)} _${t.description || '(sin descripción)'}_${datePart}${owner}`;
}));
sections.push(...rendered);
}
const totalMy = TaskService.countUserPending(context.sender);
if (totalMy > myItems.length) {
sections.push(`… y ${totalMy - myItems.length} más`);
}
} else {
sections.push('No tienes tareas pendientes.');
}
// En contexto de grupo: mantener compatibilidad mostrando solo "sin responsable" del grupo actual
if (isGroupId(context.groupId)) {
if (!GroupSyncService.isGroupActive(context.groupId)) {
sections.push('⚠️ Este grupo no está activo.');
} else {
const groupName = GroupSyncService.activeGroupsCache.get(context.groupId) || context.groupId;
const unassigned = TaskService.listGroupUnassigned(context.groupId, LIMIT);
if (unassigned.length > 0) {
sections.push(`${groupName} — Sin responsable`);
const renderedUnassigned = unassigned.map((t) => {
const isOverdue = t.due_date ? t.due_date < todayYMD : false;
const datePart = t.due_date ? `${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : '';
return `- ${codeId(t.id)} _${t.description || '(sin descripción)'}_${datePart}${ICONS.unassigned} sin responsable`;
});
sections.push(...renderedUnassigned);
const totalUnassigned = TaskService.countGroupUnassigned(context.groupId);
if (totalUnassigned > unassigned.length) {
sections.push(`… y ${totalUnassigned - unassigned.length} más`);
}
} else {
sections.push(`${groupName} — Sin responsable\n(no hay tareas sin responsable)`);
}
}
} else {
// En DM: usar membresía real (snapshot fresca) para incluir "sin responsable" por grupo
const memberGroups = GroupSyncService.getFreshMemberGroupsForUser(context.sender);
if (memberGroups.length > 0) {
const perGroup = TaskService.listUnassignedByGroups(memberGroups, LIMIT);
for (const gid of perGroup.keys()) {
const unassigned = perGroup.get(gid)!;
const groupName =
(gid && GroupSyncService.activeGroupsCache.get(gid)) ||
gid;
if (unassigned.length > 0) {
sections.push(`${groupName} — Sin responsable`);
const renderedUnassigned = unassigned.map((t) => {
const isOverdue = t.due_date ? t.due_date < todayYMD : false;
const datePart = t.due_date ? `${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : '';
return `- ${codeId(t.id)} _${t.description || '(sin descripción)'}_${datePart}${ICONS.unassigned} sin responsable`;
});
sections.push(...renderedUnassigned);
const totalUnassigned = TaskService.countGroupUnassigned(gid);
if (totalUnassigned > unassigned.length) {
sections.push(`… y ${totalUnassigned - unassigned.length} más`);
}
}
}
} else {
// Si no hay snapshot fresca de membresía, mantenemos una nota instructiva mínima
sections.push(' Para ver tareas sin responsable de un grupo, usa “/t ver sin” desde ese grupo.');
}
}
return [{
recipient: context.sender,
message: sections.join('\n')
}];
}
// Ver grupo
if (scope === 'grupo') {
if (!isGroupId(context.groupId)) {
return [{
recipient: context.sender,
message: 'Este comando se usa en grupos. Prueba: /t ver mis'
}];
}
if (!GroupSyncService.isGroupActive(context.groupId)) {
return [{
recipient: context.sender,
message: '⚠️ Este grupo no está activo.'
}];
}
// Enforcement opcional basado en membresía si la snapshot es fresca
const enforce = String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true';
const fresh = GroupSyncService.isSnapshotFresh(context.groupId);
if (enforce && fresh && !GroupSyncService.isUserActiveInGroup(context.sender, context.groupId)) {
return [{
recipient: context.sender,
message: 'No puedes ver las tareas de este grupo porque no apareces como miembro activo. Pide acceso a un admin si crees que es un error.'
}];
}
const items = TaskService.listGroupPending(context.groupId, LIMIT);
const groupName = GroupSyncService.activeGroupsCache.get(context.groupId) || context.groupId;
if (items.length === 0) {
return [{
recipient: context.sender,
message: `No hay pendientes en ${groupName}.`
}];
}
const rendered = await Promise.all(items.map(async (t) => {
const names = await Promise.all(
(t.assignees || []).map(async (uid) => (await ContactsService.getDisplayName(uid)) || uid)
);
const owner =
(t.assignees?.length || 0) === 0
? `${ICONS.unassigned} sin responsable`
: `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`;
const isOverdue = t.due_date ? t.due_date < todayYMD : false;
const datePart = t.due_date ? `${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : '';
return `- ${codeId(t.id)} _${t.description || '(sin descripción)'}_${datePart}${owner}`;
}));
const total = TaskService.countGroupPending(context.groupId);
if (total > items.length) {
rendered.push(`… y ${total - items.length} más`);
}
return [{
recipient: context.sender,
message: [groupName, ...rendered].join('\n')
}];
}
// Ver mis
const items = TaskService.listUserPending(context.sender, LIMIT);
if (items.length === 0) {
return [{
recipient: context.sender,
message: 'No tienes tareas pendientes.'
}];
}
const total = TaskService.countUserPending(context.sender);
// Agrupar por grupo
const byGroup = new Map<string, typeof items>();
for (const t of items) {
const key = t.group_id || '(sin grupo)';
const arr = byGroup.get(key) || [];
arr.push(t);
byGroup.set(key, arr);
}
const sections: string[] = [];
for (const [groupId, arr] of byGroup.entries()) {
const groupName =
(groupId && GroupSyncService.activeGroupsCache.get(groupId)) ||
(groupId && groupId !== '(sin grupo)' ? groupId : 'Sin grupo');
sections.push(groupName);
const rendered = await Promise.all(arr.map(async (t) => {
const names = await Promise.all(
(t.assignees || []).map(async (uid) => (await ContactsService.getDisplayName(uid)) || uid)
);
const owner =
(t.assignees?.length || 0) === 0
? `${ICONS.unassigned} sin responsable`
: `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`;
const isOverdue = t.due_date ? t.due_date < todayYMD : false;
const datePart = t.due_date ? `${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : '';
return `- ${codeId(t.id)} _${t.description || '(sin descripción)'}_${datePart}${owner}`;
}));
sections.push(...rendered);
}
if (total > items.length) {
sections.push(`… y ${total - items.length} más`);
}
return [{
recipient: context.sender,
message: sections.join('\n')
}];
}
// Completar tarea (con validación opcional de membresía)
if (action === 'completar') {
const idToken = tokens[2];
const id = idToken ? parseInt(idToken, 10) : NaN;
if (!id || Number.isNaN(id)) {
return [{
recipient: context.sender,
message: ' Uso: /t x 26'
}];
}
const task = TaskService.getTaskById(id);
if (!task) {
return [{
recipient: context.sender,
message: `⚠️ Tarea ${codeId(id)} no encontrada.`
}];
}
const enforce = String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true';
if (task.group_id && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) {
return [{
recipient: context.sender,
message: 'No puedes completar esta tarea porque no apareces como miembro activo del grupo.'
}];
}
const res = TaskService.completeTask(id, context.sender);
const who = (await ContactsService.getDisplayName(context.sender)) || context.sender;
if (res.status === 'not_found') {
return [{
recipient: context.sender,
message: `⚠️ Tarea ${codeId(id)} no encontrada.`
}];
}
if (res.status === 'already') {
const due = res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '';
return [{
recipient: context.sender,
message: ` ${codeId(id)} ya estaba completada — _${res.task?.description || '(sin descripción)'}_${due}`
}];
}
const due = res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '';
return [{
recipient: context.sender,
message: `${ICONS.complete} ${codeId(id)} completada — _${res.task?.description || '(sin descripción)'}_${due}`
}];
}
// Tomar tarea (con validación opcional de membresía)
if (action === 'tomar') {
const idToken = tokens[2];
const id = idToken ? parseInt(idToken, 10) : NaN;
if (!id || Number.isNaN(id)) {
return [{
recipient: context.sender,
message: ' Uso: /t tomar 26'
}];
}
const task = TaskService.getTaskById(id);
if (!task) {
return [{
recipient: context.sender,
message: `⚠️ Tarea ${codeId(id)} no encontrada.`
}];
}
const enforce = String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true';
if (task.group_id && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) {
return [{
recipient: context.sender,
message: 'No puedes tomar esta tarea: no apareces como miembro activo del grupo. Pide acceso a un admin si crees que es un error.'
}];
}
const res = TaskService.claimTask(id, context.sender);
const due = res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '';
if (res.status === 'not_found') {
return [{
recipient: context.sender,
message: `⚠️ Tarea ${codeId(id)} no encontrada.`
}];
}
if (res.status === 'completed') {
return [{
recipient: context.sender,
message: ` ${codeId(id)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}`
}];
}
if (res.status === 'already') {
return [{
recipient: context.sender,
message: ` ${codeId(id)} ya la tenías — ${res.task?.description || '(sin descripción)'}${due}`
}];
}
const lines = [
`${ICONS.take} Has tomado ${codeId(id)}`,
`${res.task?.description || '(sin descripción)'}`,
res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''
].filter(Boolean);
return [{
recipient: context.sender,
message: lines.join('\n')
}];
}
// Soltar tarea (con validación opcional de membresía)
if (action === 'soltar') {
const idToken = tokens[2];
const id = idToken ? parseInt(idToken, 10) : NaN;
if (!id || Number.isNaN(id)) {
return [{
recipient: context.sender,
message: ' Uso: /t soltar 26'
}];
}
const task = TaskService.getTaskById(id);
if (!task) {
return [{
recipient: context.sender,
message: `⚠️ Tarea ${codeId(id)} no encontrada.`
}];
}
const enforce = String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true';
if (task.group_id && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) {
return [{
recipient: context.sender,
message: 'No puedes soltar esta tarea porque no apareces como miembro activo del grupo.'
}];
}
const res = TaskService.unassignTask(id, context.sender);
const due = res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '';
if (res.status === 'not_found') {
return [{
recipient: context.sender,
message: `⚠️ Tarea ${codeId(id)} no encontrada.`
}];
}
if (res.status === 'completed') {
return [{
recipient: context.sender,
message: ` ${codeId(id)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}`
}];
}
if (res.status === 'not_assigned') {
return [{
recipient: context.sender,
message: ` ${codeId(id)} no la tenías asignada — ${res.task?.description || '(sin descripción)'}${due}`
}];
}
if (res.now_unassigned) {
const lines = [
`${ICONS.unassigned} ${codeId(id)}`,
`${res.task?.description || '(sin descripción)'}`,
res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '',
italic('queda sin responsable.')
].filter(Boolean);
return [{
recipient: context.sender,
message: lines.join('\n')
}];
}
const lines = [
`${ICONS.unassign} ${codeId(id)}`,
`${res.task?.description || '(sin descripción)'}`,
res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''
].filter(Boolean);
return [{
recipient: context.sender,
message: lines.join('\n')
}];
}
if (action === 'configurar') {
const optRaw = (tokens[2] || '').toLowerCase();
const map: Record<string, 'daily' | 'weekly' | 'off'> = {
'daily': 'daily',
'diario': 'daily',
'diaria': 'daily',
'semanal': 'weekly',
'weekly': 'weekly',
'off': 'off',
'apagar': 'off',
'ninguno': 'off'
};
const freq = map[optRaw];
if (!freq) {
return [{
recipient: context.sender,
message: 'Uso: /t configurar daily|weekly|off'
}];
}
const ensured = ensureUserExists(context.sender, this.dbInstance);
if (!ensured) {
throw new Error('No se pudo asegurar el usuario');
}
this.dbInstance.prepare(`
INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at)
VALUES (?, ?, COALESCE((SELECT reminder_time FROM user_preferences WHERE user_id = ?), '08:30'), NULL, strftime('%Y-%m-%d %H:%M:%f', 'now'))
ON CONFLICT(user_id) DO UPDATE SET
reminder_freq = excluded.reminder_freq,
updated_at = excluded.updated_at
`).run(ensured, freq, ensured);
const label = freq === 'daily' ? 'diario' : freq === 'weekly' ? 'semanal (lunes 08:30)' : 'apagado';
return [{
recipient: context.sender,
message: `✅ Recordatorios: ${label}`
}];
}
if (action !== 'nueva') {
return [{
recipient: context.sender,
message: `Acción ${rawAction || '(vacía)'} no implementada aún`
}];
}
// Parseo específico de "nueva"
// Normalizar menciones del contexto para parseo y asignaciones
const mentionsNormalizedFromContext = Array.from(new Set(
(context.mentions || [])
.map(j => normalizeWhatsAppId(j))
.filter((id): id is string => !!id)
));
// Detectar también tokens de texto que empiezan por '@' como posibles asignados
const atTokenCandidates = tokens.slice(2)
.filter(t => t.startsWith('@'))
.map(t => t.replace(/^@+/, ''));
const normalizedFromAtTokens = Array.from(new Set(
atTokenCandidates
.map(v => normalizeWhatsAppId(v))
.filter((id): id is string => !!id)
));
const combinedAssigneeCandidates = Array.from(new Set([
...mentionsNormalizedFromContext,
...normalizedFromAtTokens
]));
const { description, dueDate } = this.parseNueva(trimmed, mentionsNormalizedFromContext);
// Asegurar creador
const createdBy = ensureUserExists(context.sender, this.dbInstance);
if (!createdBy) {
throw new Error('No se pudo asegurar el usuario creador');
}
// Normalizar menciones y excluir duplicados y el número del bot
const botNumber = process.env.CHATBOT_PHONE_NUMBER || '';
const assigneesNormalized = Array.from(new Set(
combinedAssigneeCandidates
.filter(id => !botNumber || id !== botNumber)
));
// Asegurar usuarios asignados
const ensuredAssignees = assigneesNormalized
.map(id => ensureUserExists(id, this.dbInstance))
.filter((id): id is string => !!id);
// 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))
? context.groupId
: null;
// Crear tarea y asignaciones
const taskId = TaskService.createTask(
{
description: description || '',
due_date: dueDate ?? null,
group_id: groupIdToUse,
created_by: createdBy,
},
assignmentUserIds.map(uid => ({
user_id: uid,
assigned_by: createdBy,
}))
);
const mentionsForSending = assignmentUserIds.map(uid => `${uid}@s.whatsapp.net`);
// Resolver nombres útiles
const creatorName = await ContactsService.getDisplayName(createdBy);
const creatorJid = `${createdBy}@s.whatsapp.net`;
const groupName = groupIdToUse ? GroupSyncService.activeGroupsCache.get(groupIdToUse) : null;
const assignedDisplayNames = await Promise.all(
assignmentUserIds.map(async uid => {
const name = await ContactsService.getDisplayName(uid);
return name || uid;
})
);
const responses: CommandResponse[] = [];
// 1) Ack al creador con formato compacto
const dueFmt = formatDDMM(dueDate);
const ownerPart = assignmentUserIds.length === 0
? `${ICONS.unassigned} sin responsable${groupName ? ` (${groupName})` : ''}`
: `${assignmentUserIds.length > 1 ? '👥' : '👤'} ${assignedDisplayNames.join(', ')}`;
const ackParts = [
`${ICONS.create} ${codeId(taskId)} _${description || '(sin descripción)'}_`,
dueFmt ? `${ICONS.date} ${dueFmt}` : null,
ownerPart
].filter(Boolean);
responses.push({
recipient: createdBy,
message: ackParts.join(' — '),
mentions: mentionsForSending.length > 0 ? mentionsForSending : undefined
});
// 2) DM a cada asignado (excluyendo al creador para evitar duplicados)
for (const uid of assignmentUserIds) {
if (uid === createdBy) continue;
responses.push({
recipient: uid,
message: [
`${ICONS.assignNotice} ${codeId(taskId)}`,
`${description || '(sin descripción)'}`,
formatDDMM(dueDate) ? `${ICONS.date} ${formatDDMM(dueDate)}` : null,
groupName ? `Grupo: ${groupName}` : null,
italic(`Acciones: Completar → /t x ${taskId} · Soltar → /t soltar ${taskId}`)
].filter(Boolean).join('\n'),
mentions: [creatorJid]
});
}
return responses;
}
static async handle(context: CommandContext): Promise<CommandResponse[]> {
const msg = (context.message || '').trim();
if (!/^\/(tarea|t)\b/i.test(msg)) {
return [];
}
try {
return await this.processTareaCommand(context);
} catch (error) {
return [{
recipient: context.sender,
message: 'Error processing command'
}];
}
}
}