feat: mover lógica de nueva a handler dedicado y añadir onboarding

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
main
brobert 4 days ago
parent ada071d220
commit 91fe688e4e

@ -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;
}

@ -11,6 +11,7 @@ import { handleVer } from './handlers/ver';
import { handleCompletar } from './handlers/completar';
import { handleTomar } from './handlers/tomar';
import { handleSoltar } from './handlers/soltar';
import { handleNueva } from './handlers/nueva';
import { ResponseQueue } from '../response-queue';
import { isGroupId } from '../../utils/whatsapp';
import { Metrics } from '../metrics';
@ -41,6 +42,11 @@ export async function route(context: RouteContext, deps?: { db: Database }): Pro
const database = deps?.db;
if (!database) return null;
if (action === 'nueva') {
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
return await handleNueva(context as any, { db: database });
}
if (action === 'ver') {
// Métricas de alias (mias/todas) como en el código actual
try {

@ -0,0 +1,198 @@
import type { Database } from 'bun:sqlite';
import { ResponseQueue } from './response-queue';
import { GroupSyncService } from './group-sync';
import { AllowedGroups } from './allowed-groups';
import { Metrics } from './metrics';
import { randomTokenBase64Url } from '../utils/crypto';
import { ICONS } from '../utils/icons';
import { codeId, code, bold, padTaskId } from '../utils/formatting';
type CommandResponse = {
recipient: string;
message: string;
mentions?: string[];
};
/**
* Construye (si aplica) el DM JIT al creador cuando hay menciones/tokens irrecuperables.
* Aplica flags y métricas exactamente como en CommandService.
*/
export function buildJitAssigneePrompt(createdBy: string, groupId: string, unresolvedAssigneeDisplays: string[]): CommandResponse[] {
const responses: CommandResponse[] = [];
const unresolvedList = Array.from(new Set((unresolvedAssigneeDisplays || []).filter(Boolean)));
if (unresolvedList.length === 0) return responses;
const isTest = String(process.env.NODE_ENV || '').toLowerCase() === 'test';
const enabled = isTest
? String(process.env.ONBOARDING_ENABLE_IN_TEST || '').toLowerCase() === 'true'
: (() => {
const v = process.env.ONBOARDING_PROMPTS_ENABLED;
return v == null ? true : ['true', '1', 'yes'].includes(String(v).toLowerCase());
})();
const groupLabel = String(groupId && groupId.includes('@g.us') ? groupId : 'dm');
if (!enabled) {
try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupLabel, source: 'jit_assignee_failure', reason: 'disabled' }); } catch { }
return responses;
}
const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim();
if (!bot || !/^\d+$/.test(bot)) {
try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupLabel, source: 'jit_assignee_failure', reason: 'missing_bot_number' }); } catch { }
return responses;
}
const list = unresolvedList.join(', ');
let groupCtx = '';
if (groupId && groupId.includes('@g.us')) {
const name = GroupSyncService.activeGroupsCache.get(groupId) || groupId;
groupCtx = ` (en el grupo ${name})`;
}
const msg = `No puedo asignar a ${list} aún${groupCtx}. Pídele que toque este enlace y diga 'activar': https://wa.me/${bot}`;
responses.push({ recipient: createdBy, message: msg });
try { Metrics.inc('onboarding_prompts_sent_total', 1, { group_id: groupLabel, source: 'jit_assignee_failure' }); } catch { }
return responses;
}
/**
* Encola el paquete de 2 DMs de onboarding para miembros del grupo (si aplica).
* Respeta gating AllowedGroups, cap, cooldown, delays y métricas.
*/
export function maybeEnqueueOnboardingBundle(db: Database, params: {
gid: string | null;
createdBy: string;
assignmentUserIds: string[];
taskId: number;
displayCode: number | null;
description: string;
}): void {
const isTest = String(process.env.NODE_ENV || '').toLowerCase() === 'test';
const enabledBase = ['true','1','yes','on'].includes(String(process.env.ONBOARDING_DM_ENABLED || '').toLowerCase());
const enabled = enabledBase && (!isTest || String(process.env.ONBOARDING_ENABLE_IN_TEST || '').toLowerCase() === 'true');
const gid = params.gid;
if (!enabled) {
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'disabled', group_id: String(gid || '') }); } catch {}
return;
}
if (!gid) {
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'no_group', group_id: '' }); } catch {}
return;
}
// Gating enforce
let allowed = true;
try {
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (mode === 'enforce') {
try { (AllowedGroups as any).dbInstance = db; } catch {}
allowed = AllowedGroups.isAllowed(gid);
}
} catch {}
if (!allowed) {
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'not_allowed', group_id: String(gid) }); } catch {}
return;
}
const displayCode = params.displayCode;
if (!(typeof displayCode === 'number' && Number.isFinite(displayCode))) {
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'no_display_code', group_id: String(gid) }); } catch {}
return;
}
// Candidatos
let members = GroupSyncService.listActiveMemberIds(gid);
const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim();
const exclude = new Set<string>([params.createdBy, ...params.assignmentUserIds]);
members = members
.filter(id => /^\d+$/.test(id) && id.length < 14)
.filter(id => !exclude.has(id))
.filter(id => !bot || id !== bot);
if (members.length === 0) {
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'no_members', group_id: String(gid) }); } catch {}
return;
}
const capRaw = Number(process.env.ONBOARDING_EVENT_CAP);
const cap = Number.isFinite(capRaw) && capRaw > 0 ? Math.floor(capRaw) : 30;
let recipients = members;
if (recipients.length > cap) {
try { Metrics.inc('onboarding_recipients_capped_total', recipients.length - cap, { group_id: String(gid) }); } catch {}
recipients = recipients.slice(0, cap);
}
const cooldownRaw = Number(process.env.ONBOARDING_DM_COOLDOWN_DAYS);
const cooldownDays = Number.isFinite(cooldownRaw) && cooldownRaw >= 0 ? Math.floor(cooldownRaw) : 14;
const delayEnv = Number(process.env.ONBOARDING_BUNDLE_DELAY_MS);
const delay2 = Number.isFinite(delayEnv) && delayEnv >= 0 ? Math.floor(delayEnv) : 5000 + Math.floor(Math.random() * 5001); // 510s
const groupLabel = GroupSyncService.activeGroupsCache.get(gid) || gid;
const codeStr = String(displayCode);
const desc = (params.description || '(sin descripción)').trim();
const shortDesc = desc.length > 100 ? (desc.slice(0, 100) + '…') : desc;
const codeInline = codeId(params.taskId, displayCode);
const cmdTake = code(`/t tomar ${padTaskId(displayCode)}`);
const cmdInfo = code(`/t info`);
const groupBold = bold(`${groupLabel}`);
const msg1 = `¡Hola!, soy el bot de tareas. En ${groupBold} acaban de crear una tarea: ${codeInline} _${shortDesc}_
- Para hacerte cargo: ${cmdTake}
- Más info: ${cmdInfo}
${ICONS.info} Solo escribo por privado.
${ICONS.info} Cuando reciba tu primer mensaje ya no te enviaré más este recordatorio`;
const msg2 = `GUÍA RÁPIDA
Puedes interactuar escribiéndome por privado:
- Ver tus tareas: ${code('/t mias')}
- Ver todas: ${code('/t todas')}
- Recibe recordatorios: ${code('/t configurar diario|l-v|semanal|off')}
- Web: ${code('/t web')}`;
for (const rcpt of recipients) {
const stats = ResponseQueue.getOnboardingStats(rcpt);
let variant: 'initial' | 'reminder' | null = null;
if (!stats || (stats.total || 0) === 0) {
variant = 'initial';
} else if (stats.firstInitialAt) {
let firstMs = NaN;
try {
const s = String(stats.firstInitialAt);
const iso = s.includes('T') ? s : (s.replace(' ', 'T') + 'Z');
firstMs = Date.parse(iso);
} catch {}
const nowMs = Date.now();
const okCooldown = Number.isFinite(firstMs) ? (nowMs - firstMs) >= cooldownDays * 24 * 60 * 60 * 1000 : false;
// Interacción del usuario desde el primer paquete
let hadInteraction = false;
try {
const row = db.prepare(`SELECT last_command_at FROM users WHERE id = ?`).get(rcpt) as any;
const lcRaw = row?.last_command_at ? String(row.last_command_at) : null;
if (lcRaw) {
const lcIso = lcRaw.includes('T') ? lcRaw : (lcRaw.replace(' ', 'T') + 'Z');
const lcMs = Date.parse(lcIso);
hadInteraction = Number.isFinite(lcMs) && Number.isFinite(firstMs) && lcMs > firstMs;
}
} catch {}
if (okCooldown && !hadInteraction) {
variant = 'reminder';
} else {
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: hadInteraction ? 'had_interaction' : 'cooldown_active', group_id: String(gid) }); } catch {}
}
}
if (!variant) continue;
const bundleId = randomTokenBase64Url(12);
try {
ResponseQueue.enqueueOnboarding(rcpt, msg1, { variant, part: 1, bundle_id: bundleId, group_id: gid, task_id: params.taskId, display_code: displayCode }, 0);
ResponseQueue.enqueueOnboarding(rcpt, msg2, { variant, part: 2, bundle_id: bundleId, group_id: gid, task_id: params.taskId, display_code: displayCode }, delay2);
try { Metrics.inc('onboarding_bundle_sent_total', 1, { variant, group_id: String(gid) }); } catch {}
} catch {}
}
}
Loading…
Cancel
Save