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