|
|
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';
|
|
|
|
|
|
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();
|
|
|
|
|
|
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);
|
|
|
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;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
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',
|
|
|
'soltar': 'soltar',
|
|
|
'unassign': 'soltar',
|
|
|
'ayuda': 'ayuda',
|
|
|
'help': 'ayuda',
|
|
|
'?': 'ayuda',
|
|
|
'config': 'configurar',
|
|
|
'configurar': 'configurar'
|
|
|
};
|
|
|
const action = ACTION_ALIASES[rawAction] || rawAction;
|
|
|
|
|
|
// Helper para fechas dd/MM
|
|
|
const formatDDMM = (ymd?: string | null): string | null => {
|
|
|
if (!ymd) return null;
|
|
|
const parts = String(ymd).split('-');
|
|
|
if (parts.length >= 3) {
|
|
|
const [Y, M, D] = parts;
|
|
|
if (D && M) return `${D}/${M}`;
|
|
|
}
|
|
|
return String(ymd);
|
|
|
};
|
|
|
|
|
|
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
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
// Listar pendientes
|
|
|
if (action === 'ver') {
|
|
|
const scope = (tokens[2] || '').toLowerCase() || (isGroupId(context.groupId) ? 'grupo' : 'mis');
|
|
|
const LIMIT = 10;
|
|
|
|
|
|
// 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.'
|
|
|
}];
|
|
|
}
|
|
|
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
|
|
|
? '👥 sin dueño'
|
|
|
: `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`;
|
|
|
const datePart = t.due_date ? ` — 📅 ${formatDDMM(t.due_date)}` : '';
|
|
|
return `- ${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
|
|
|
? '👥 sin dueño'
|
|
|
: `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`;
|
|
|
const datePart = t.due_date ? ` — 📅 ${formatDDMM(t.due_date)}` : '';
|
|
|
return `- ${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
|
|
|
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 <id>'
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
const res = TaskService.completeTask(id, context.sender);
|
|
|
if (res.status === 'not_found') {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: `⚠️ Tarea ${id} no encontrada.`
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
const who = (await ContactsService.getDisplayName(context.sender)) || context.sender;
|
|
|
if (res.status === 'already') {
|
|
|
const due = res.task?.due_date ? ` — 📅 ${formatDDMM(res.task?.due_date)}` : '';
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: `ℹ️ ${id} ya estaba completada — “*${res.task?.description || '(sin descripción)'}*”${due}`
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
const due = res.task?.due_date ? ` — 📅 ${formatDDMM(res.task?.due_date)}` : '';
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: `✔️ ${id} completada — “*${res.task?.description || '(sin descripción)'}*”${due}\nGracias, ${who}.`
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
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 ackHeader = `✅ ${taskId} “*${description || '(sin descripción)'}*”`;
|
|
|
const ackLines: string[] = [ackHeader];
|
|
|
const dueFmt = formatDDMM(dueDate);
|
|
|
if (dueFmt) ackLines.push(`📅 ${dueFmt}`);
|
|
|
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: ackLines.join('\n'),
|
|
|
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: [
|
|
|
`🔔 ${taskId}${formatDDMM(dueDate) ? ` — 📅 ${formatDDMM(dueDate)}` : ''}`,
|
|
|
`“*${description || '(sin descripción)'}*”`,
|
|
|
groupName ? `Grupo: ${groupName}` : null,
|
|
|
`Completar: /t x ${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'
|
|
|
}];
|
|
|
}
|
|
|
}
|
|
|
}
|