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.

280 lines
8.7 KiB
TypeScript

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;
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 ${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];
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: 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}${dueDate ? ` — 📅 ${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'
}];
}
}
}