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.

230 lines
7.4 KiB
TypeScript

import type { Database } from 'bun:sqlite';
import { db, ensureUserExists } from '../db';
import { normalizeWhatsAppId } 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();
// Buscar última fecha futura con formato YYYY-MM-DD
const dateIndices: { index: number; text: string }[] = [];
for (let i = 2; i < parts.length; i++) {
const p = parts[i];
if (/^\d{4}-\d{2}-\d{2}$/.test(p)) {
const d = new Date(p);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (!isNaN(d.getTime()) && d >= today) {
dateIndices.push({ index: i, text: p });
}
}
}
let dueDate: string | null = null;
let descriptionTokens: string[] = [];
const isMentionToken = (token: string) => token.startsWith('@');
if (dateIndices.length > 0) {
const last = dateIndices[dateIndices.length - 1];
dueDate = last.text;
for (let i = 2; i < parts.length; i++) {
if (i === last.index) continue;
const token = parts[i];
if (isMentionToken(token)) continue; // quitar @menciones del texto
descriptionTokens.push(token);
}
} else {
for (let i = 2; i < parts.length; i++) {
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 action = (tokens[1] || '').toLowerCase();
if (action !== 'nueva') {
return [{
recipient: context.sender,
message: `Acción ${action || '(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);
// Si no hay asignados, asignar al creador
const assignmentUserIds = ensuredAssignees.length > 0 ? ensuredAssignees : [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 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, salvo que él sea el único asignado
const creatorIsOnlyAssignee = assignmentUserIds.length === 1 && assignmentUserIds[0] === createdBy;
if (!creatorIsOnlyAssignee) {
responses.push({
recipient: createdBy,
message:
`✅ Tarea ${taskId} creada:\n` +
`${description || '(sin descripción)'}\n` +
(dueDate ? `• Vence: ${dueDate}\n` : '') +
(assignedDisplayNames.length ? `• Asignados: ${assignedDisplayNames.join(', ')}` : '')
});
}
// 2) DM a cada asignado
for (const uid of assignmentUserIds) {
responses.push({
recipient: uid,
message:
`🆕 Nueva tarea:\n` +
`${description || '(sin descripción)'}\n` +
(dueDate ? `• Vence: ${dueDate}\n` : '') +
`• Asignada por: ${creatorName || createdBy}` +
(groupName ? `\n• Grupo: ${groupName}` : '')
});
}
// 3) Opcional: mensaje al grupo con menciones para visibilidad
if (groupIdToUse && process.env.NOTIFY_GROUP_ON_CREATE === 'true') {
const assignNamesForGroup = await Promise.all(
assignmentUserIds.map(async uid => '@' + (await ContactsService.getDisplayName(uid) || uid))
);
responses.push({
recipient: groupIdToUse,
message:
`📝 Tarea ${taskId} creada por ${creatorName || createdBy}:\n` +
`${description || '(sin descripción)'}\n` +
(dueDate ? `• Vence: ${dueDate}\n` : '') +
(assignNamesForGroup.length ? `• Asignados: ${assignNamesForGroup.join(' ')}` : ''),
mentions: mentionsForSending.length > 0 ? mentionsForSending : undefined
});
}
return responses;
}
static async handle(context: CommandContext): Promise<CommandResponse[]> {
if (!context.message.startsWith('/tarea')) {
return [];
}
try {
return await this.processTareaCommand(context);
} catch (error) {
return [{
recipient: context.sender,
message: 'Error processing command'
}];
}
}
}