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.

430 lines
14 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';
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'
}];
}
}
}