|
|
|
@ -0,0 +1,262 @@
|
|
|
|
|
|
|
|
import type { Database } from 'bun:sqlite';
|
|
|
|
|
|
|
|
import { ensureUserExists } from '../../../db';
|
|
|
|
|
|
|
|
import { normalizeWhatsAppId, isGroupId } from '../../../utils/whatsapp';
|
|
|
|
|
|
|
|
import { TaskService } from '../../../tasks/service';
|
|
|
|
|
|
|
|
import { GroupSyncService } from '../../group-sync';
|
|
|
|
|
|
|
|
import { ContactsService } from '../../contacts';
|
|
|
|
|
|
|
|
import { IdentityService } from '../../identity';
|
|
|
|
|
|
|
|
import { Metrics } from '../../metrics';
|
|
|
|
|
|
|
|
import { ICONS } from '../../../utils/icons';
|
|
|
|
|
|
|
|
import { codeId, formatDDMM } from '../../../utils/formatting';
|
|
|
|
|
|
|
|
import { parseNueva } from '../parsers/nueva';
|
|
|
|
|
|
|
|
import { buildJitAssigneePrompt, maybeEnqueueOnboardingBundle } from '../../onboarding';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
type Ctx = {
|
|
|
|
|
|
|
|
sender: string;
|
|
|
|
|
|
|
|
groupId: string;
|
|
|
|
|
|
|
|
message: string;
|
|
|
|
|
|
|
|
mentions: string[];
|
|
|
|
|
|
|
|
messageId?: string;
|
|
|
|
|
|
|
|
participant?: string;
|
|
|
|
|
|
|
|
fromMe?: boolean;
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
type Msg = {
|
|
|
|
|
|
|
|
recipient: string;
|
|
|
|
|
|
|
|
message: string;
|
|
|
|
|
|
|
|
mentions?: string[];
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const CTA_HELP = 'ℹ️ Tus tareas: `/t mias` · Todas: `/t todas` · Info: `/t info` · Web: `/t web`';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function handleNueva(context: Ctx, deps: { db: Database }): Promise<Msg[]> {
|
|
|
|
|
|
|
|
const tokens = (context.message || '').trim().split(/\s+/);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Normalizar menciones del contexto para parseo y asignaciones (A2: fallback a números plausibles)
|
|
|
|
|
|
|
|
const MIN_FALLBACK_DIGITS = (() => {
|
|
|
|
|
|
|
|
const raw = (process.env.ONBOARDING_FALLBACK_MIN_DIGITS || '').trim();
|
|
|
|
|
|
|
|
const n = parseInt(raw || '8', 10);
|
|
|
|
|
|
|
|
return Number.isFinite(n) && n > 0 ? n : 8;
|
|
|
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
const MAX_FALLBACK_DIGITS = (() => {
|
|
|
|
|
|
|
|
const raw = (process.env.ONBOARDING_FALLBACK_MAX_DIGITS || '').trim();
|
|
|
|
|
|
|
|
const n = parseInt(raw || '15', 10);
|
|
|
|
|
|
|
|
return Number.isFinite(n) && n > 0 ? n : 15;
|
|
|
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
type FailReason = 'non_numeric' | 'too_short' | 'too_long' | 'from_lid' | 'invalid';
|
|
|
|
|
|
|
|
const isDigits = (s: string) => /^\d+$/.test(s);
|
|
|
|
|
|
|
|
const plausibility = (s: string, opts?: { fromLid?: boolean }): { ok: boolean; reason?: FailReason } => {
|
|
|
|
|
|
|
|
if (!s) return { ok: false, reason: 'invalid' };
|
|
|
|
|
|
|
|
if (opts?.fromLid) return { ok: false, reason: 'from_lid' };
|
|
|
|
|
|
|
|
if (!isDigits(s)) return { ok: false, reason: 'non_numeric' };
|
|
|
|
|
|
|
|
if (s.length < MIN_FALLBACK_DIGITS) return { ok: false, reason: 'too_short' };
|
|
|
|
|
|
|
|
if (s.length >= MAX_FALLBACK_DIGITS) return { ok: false, reason: 'too_long' };
|
|
|
|
|
|
|
|
return { ok: true };
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
const incOnboardingFailure = (source: 'mentions' | 'tokens', reason: FailReason) => {
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
const gid = isGroupId(context.groupId) ? context.groupId : 'dm';
|
|
|
|
|
|
|
|
Metrics.inc('onboarding_assign_failures_total', 1, { group_id: String(gid), source, reason });
|
|
|
|
|
|
|
|
} catch { }
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 1) Menciones aportadas por el backend (JIDs crudos)
|
|
|
|
|
|
|
|
const unresolvedAssigneeDisplays: string[] = [];
|
|
|
|
|
|
|
|
const mentionsNormalizedFromContext = Array.from(new Set(
|
|
|
|
|
|
|
|
(context.mentions || []).map((j) => {
|
|
|
|
|
|
|
|
const norm = normalizeWhatsAppId(j);
|
|
|
|
|
|
|
|
if (!norm) {
|
|
|
|
|
|
|
|
// agregar a no resolubles para JIT (mostrar sin @ ni dominio)
|
|
|
|
|
|
|
|
const raw = String(j || '');
|
|
|
|
|
|
|
|
const disp = raw.split('@')[0].split(':')[0].replace(/^@+/, '').replace(/^\+/, '');
|
|
|
|
|
|
|
|
if (disp) unresolvedAssigneeDisplays.push(disp);
|
|
|
|
|
|
|
|
incOnboardingFailure('mentions', 'invalid');
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const resolved = IdentityService.resolveAliasOrNull(norm);
|
|
|
|
|
|
|
|
if (resolved) return resolved;
|
|
|
|
|
|
|
|
// detectar si la mención proviene de un JID @lid (no plausible aunque sea numérico)
|
|
|
|
|
|
|
|
const dom = String(j || '').split('@')[1]?.toLowerCase() || '';
|
|
|
|
|
|
|
|
const fromLid = dom.includes('lid');
|
|
|
|
|
|
|
|
const p = plausibility(norm, { fromLid });
|
|
|
|
|
|
|
|
if (p.ok) return norm;
|
|
|
|
|
|
|
|
// conservar para copy JIT
|
|
|
|
|
|
|
|
unresolvedAssigneeDisplays.push(norm);
|
|
|
|
|
|
|
|
incOnboardingFailure('mentions', p.reason!);
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
}).filter((id): id is string => !!id)
|
|
|
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 2) Tokens de texto que empiezan por '@' como posibles asignados
|
|
|
|
|
|
|
|
const atTokenCandidates = tokens.slice(2)
|
|
|
|
|
|
|
|
.filter(t => t.startsWith('@'))
|
|
|
|
|
|
|
|
.map(t => t.replace(/^@+/, '').replace(/^\+/, '').replace(/[.,;:!?)\]}¿¡"'’”]+$/, ''));
|
|
|
|
|
|
|
|
const normalizedFromAtTokens = Array.from(new Set(
|
|
|
|
|
|
|
|
atTokenCandidates.map((v) => {
|
|
|
|
|
|
|
|
// Token especial: '@yo' → autoasignación; no cuenta como fallo
|
|
|
|
|
|
|
|
if (String(v).toLowerCase() === 'yo') {
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const norm = normalizeWhatsAppId(v);
|
|
|
|
|
|
|
|
if (!norm) {
|
|
|
|
|
|
|
|
// agregar a no resolubles para JIT (texto ya viene sin @/+)
|
|
|
|
|
|
|
|
if (v) unresolvedAssigneeDisplays.push(v);
|
|
|
|
|
|
|
|
incOnboardingFailure('tokens', 'invalid');
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const resolved = IdentityService.resolveAliasOrNull(norm);
|
|
|
|
|
|
|
|
if (resolved) return resolved;
|
|
|
|
|
|
|
|
const p = plausibility(norm, { fromLid: false });
|
|
|
|
|
|
|
|
if (p.ok) return norm;
|
|
|
|
|
|
|
|
// conservar para copy JIT (preferimos el token limpio v)
|
|
|
|
|
|
|
|
unresolvedAssigneeDisplays.push(v);
|
|
|
|
|
|
|
|
incOnboardingFailure('tokens', p.reason!);
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
}).filter((id): id is string => !!id)
|
|
|
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 3) Unir y deduplicar
|
|
|
|
|
|
|
|
const combinedAssigneeCandidates = Array.from(new Set([
|
|
|
|
|
|
|
|
...mentionsNormalizedFromContext,
|
|
|
|
|
|
|
|
...normalizedFromAtTokens
|
|
|
|
|
|
|
|
]));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const { description, dueDate, selfAssign } = parseNueva((context.message || '').trim(), mentionsNormalizedFromContext);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Asegurar creador
|
|
|
|
|
|
|
|
const createdBy = ensureUserExists(context.sender, deps.db);
|
|
|
|
|
|
|
|
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(
|
|
|
|
|
|
|
|
[
|
|
|
|
|
|
|
|
...(selfAssign ? [context.sender] : []),
|
|
|
|
|
|
|
|
...combinedAssigneeCandidates
|
|
|
|
|
|
|
|
].filter(id => !botNumber || id !== botNumber)
|
|
|
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Asegurar usuarios asignados
|
|
|
|
|
|
|
|
const ensuredAssignees = assigneesNormalized
|
|
|
|
|
|
|
|
.map(id => ensureUserExists(id, deps.db))
|
|
|
|
|
|
|
|
.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,
|
|
|
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Registrar origen del comando para esta tarea (si aplica)
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
if (groupIdToUse && isGroupId(groupIdToUse) && context.messageId) {
|
|
|
|
|
|
|
|
const participant = typeof context.participant === 'string' ? context.participant : null;
|
|
|
|
|
|
|
|
const fromMe = typeof context.fromMe === 'boolean' ? (context.fromMe ? 1 : 0) : null;
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
deps.db.prepare(`
|
|
|
|
|
|
|
|
INSERT OR IGNORE INTO task_origins (task_id, chat_id, message_id, participant, from_me)
|
|
|
|
|
|
|
|
VALUES (?, ?, ?, ?, ?)
|
|
|
|
|
|
|
|
`).run(taskId, groupIdToUse, context.messageId, participant, fromMe);
|
|
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
|
|
deps.db.prepare(`
|
|
|
|
|
|
|
|
INSERT OR IGNORE INTO task_origins (task_id, chat_id, message_id)
|
|
|
|
|
|
|
|
VALUES (?, ?, ?)
|
|
|
|
|
|
|
|
`).run(taskId, groupIdToUse, context.messageId);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} catch { }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Recuperar la tarea creada para obtener display_code asignado
|
|
|
|
|
|
|
|
const createdTask = TaskService.getTaskById(taskId);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const mentionsForSending = assignmentUserIds.map(uid => `${uid}@s.whatsapp.net`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Resolver nombres útiles
|
|
|
|
|
|
|
|
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: Msg[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 1) Ack al creador con formato compacto
|
|
|
|
|
|
|
|
const dueFmt = formatDDMM(dueDate);
|
|
|
|
|
|
|
|
const ownerPart = assignmentUserIds.length === 0
|
|
|
|
|
|
|
|
? `${ICONS.unassigned} ${groupName ? ` (${groupName})` : ''}`
|
|
|
|
|
|
|
|
: `${assignmentUserIds.length > 1 ? '👥' : '👤'} ${assignedDisplayNames.join(', ')}`;
|
|
|
|
|
|
|
|
const ackLines = [
|
|
|
|
|
|
|
|
`${ICONS.create} ${codeId(taskId, createdTask?.display_code)} ${description || '(sin descripción)'}`,
|
|
|
|
|
|
|
|
dueFmt ? `${ICONS.date} ${dueFmt}` : null,
|
|
|
|
|
|
|
|
ownerPart
|
|
|
|
|
|
|
|
].filter(Boolean);
|
|
|
|
|
|
|
|
responses.push({
|
|
|
|
|
|
|
|
recipient: createdBy,
|
|
|
|
|
|
|
|
message: [ackLines.join('\n'), '', CTA_HELP].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: [
|
|
|
|
|
|
|
|
`${ICONS.assignNotice} ${codeId(taskId, createdTask?.display_code)}`,
|
|
|
|
|
|
|
|
`${description || '(sin descripción)'}`,
|
|
|
|
|
|
|
|
formatDDMM(dueDate) ? `${ICONS.date} ${formatDDMM(dueDate)}` : null,
|
|
|
|
|
|
|
|
groupName ? `Grupo: ${groupName}` : null,
|
|
|
|
|
|
|
|
`- Completar: \`/t x ${createdTask?.display_code}\``,
|
|
|
|
|
|
|
|
`- Soltar: \`/t soltar ${createdTask?.display_code}\``
|
|
|
|
|
|
|
|
].filter(Boolean).join('\n') + '\n\n' + CTA_HELP,
|
|
|
|
|
|
|
|
mentions: [`${createdBy}@s.whatsapp.net`]
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// A4: DM JIT al asignador si quedaron menciones/tokens irrecuperables
|
|
|
|
|
|
|
|
responses.push(...buildJitAssigneePrompt(createdBy, context.groupId, unresolvedAssigneeDisplays));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Fase 2: disparar paquete de onboarding (2 DMs) tras crear tarea en grupo
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
const gid = groupIdToUse || (isGroupId(context.groupId) ? context.groupId : null);
|
|
|
|
|
|
|
|
maybeEnqueueOnboardingBundle(deps.db, {
|
|
|
|
|
|
|
|
gid,
|
|
|
|
|
|
|
|
createdBy,
|
|
|
|
|
|
|
|
assignmentUserIds,
|
|
|
|
|
|
|
|
taskId,
|
|
|
|
|
|
|
|
displayCode: createdTask?.display_code ?? null,
|
|
|
|
|
|
|
|
description: description || ''
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return responses;
|
|
|
|
|
|
|
|
}
|