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 { CTA_HELP } from '../shared'; 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[]; }; // --------------------------------------------------------------------------- // Config & helpers // --------------------------------------------------------------------------- type FailReason = 'non_numeric' | 'too_short' | 'too_long' | 'from_lid' | 'invalid'; function getFallbackDigitLimits(): { min: number; max: number } { const min = parseInt((process.env.ONBOARDING_FALLBACK_MIN_DIGITS || '8').trim(), 10); const max = parseInt((process.env.ONBOARDING_FALLBACK_MAX_DIGITS || '15').trim(), 10); return { min: Number.isFinite(min) && min > 0 ? min : 8, max: Number.isFinite(max) && max > 0 ? max : 15, }; } function isDigits(s: string): boolean { return /^\d+$/.test(s); } function checkPlausibility(s: string, limits: { min: number; max: number }, fromLid: boolean): { ok: boolean; reason?: FailReason } { if (!s) return { ok: false, reason: 'invalid' }; if (fromLid) return { ok: false, reason: 'from_lid' }; if (!isDigits(s)) return { ok: false, reason: 'non_numeric' }; if (s.length < limits.min) return { ok: false, reason: 'too_short' }; if (s.length >= limits.max) return { ok: false, reason: 'too_long' }; return { ok: true }; } function recordOnboardingFailure(groupId: string, source: 'mentions' | 'tokens', reason: FailReason): void { try { const gid = isGroupId(groupId) ? groupId : 'dm'; Metrics.inc('onboarding_assign_failures_total', 1, { group_id: String(gid), source, reason }); } catch {} } // --------------------------------------------------------------------------- // Mention processing // --------------------------------------------------------------------------- interface MentionResult { ids: string[]; unresolved: string[]; } /** Extracts a display string from a raw JID (strips @domain, @, +). */ function displayFromJid(raw: string): string { return raw.split('@')[0].split(':')[0].replace(/^@+/, '').replace(/^\+/, ''); } function processContextMentions(mentions: string[], limits: { min: number; max: number }, groupId: string): MentionResult { const ids: string[] = []; const unresolved: string[] = []; for (const j of new Set(mentions)) { const norm = normalizeWhatsAppId(j); if (!norm) { const disp = displayFromJid(j); if (disp) unresolved.push(disp); recordOnboardingFailure(groupId, 'mentions', 'invalid'); continue; } const resolved = IdentityService.resolveAliasOrNull(norm); if (resolved) { ids.push(resolved); continue; } const dom = String(j).split('@')[1]?.toLowerCase() || ''; const fromLid = dom.includes('lid'); const p = checkPlausibility(norm, limits, fromLid); if (p.ok) { ids.push(norm); continue; } unresolved.push(norm); recordOnboardingFailure(groupId, 'mentions', p.reason!); } return { ids, unresolved }; } function processAtTokens(tokens: string[], limits: { min: number; max: number }, groupId: string): MentionResult { const candidates = tokens .filter(t => t.startsWith('@')) .map(t => t.replace(/^@+/, '').replace(/^\+/, '').replace(/[.,;:!?)\]}¿¡"'’”]+$/, '')); const ids: string[] = []; const unresolved: string[] = []; for (const v of new Set(candidates)) { // '@yo' → self-assignment marker, not an actual user if (String(v).toLowerCase() === 'yo') continue; const norm = normalizeWhatsAppId(v); if (!norm) { if (v) unresolved.push(v); recordOnboardingFailure(groupId, 'tokens', 'invalid'); continue; } const resolved = IdentityService.resolveAliasOrNull(norm); if (resolved) { ids.push(resolved); continue; } const p = checkPlausibility(norm, limits, false); if (p.ok) { ids.push(norm); continue; } unresolved.push(v); recordOnboardingFailure(groupId, 'tokens', p.reason!); } return { ids, unresolved }; } // --------------------------------------------------------------------------- // Assignment resolution // --------------------------------------------------------------------------- function resolveFinalAssignees( candidates: string[], selfAssign: boolean, sender: string, groupId: string, db: Database, ): { ensured: string[]; userIds: string[] } { const botNumber = process.env.CHATBOT_PHONE_NUMBER || ''; const source = Array.from(new Set([ ...(selfAssign ? [sender] : []), ...candidates, ].filter(id => !botNumber || id !== botNumber))); const ensured = source .map(id => ensureUserExists(id, db)) .filter((id): id is string => !!id); // Default: in groups → no assignment; in DMs → assign to creator const userIds = ensured.length > 0 ? ensured : (isGroupId(groupId) ? [] : [sender]); return { ensured, userIds }; } // --------------------------------------------------------------------------- // Response building // --------------------------------------------------------------------------- function buildAcknowledgement( taskId: number, displayCode: number | null, description: string, dueDate: string | null, assignmentUserIds: string[], assignedDisplayNames: string[], groupName: string | null, createdBy: string, ): Msg { const dueFmt = formatDDMM(dueDate); const ownerPart = assignmentUserIds.length === 0 ? `${ICONS.unassigned} ${groupName ? ` (${groupName})` : ''}` : `${assignmentUserIds.length > 1 ? '👥' : '👤'} ${assignedDisplayNames.join(', ')}`; const lines = [ `${ICONS.create} ${codeId(taskId, displayCode)} ${description || '(sin descripción)'}`, dueFmt ? `${ICONS.date} ${dueFmt}` : null, ownerPart, ].filter(Boolean); return { recipient: createdBy, message: [lines.join('\n'), '', CTA_HELP].join('\n'), ...(assignmentUserIds.length > 0 ? { mentions: assignmentUserIds.map(uid => `${uid}@s.whatsapp.net`) } : {}), }; } function buildAssigneeNotices( taskId: number, displayCode: number | null, description: string, dueDate: string | null, assignmentUserIds: string[], createdBy: string, groupName: string | null, ): Msg[] { const notices: Msg[] = []; for (const uid of assignmentUserIds) { if (uid === createdBy) continue; notices.push({ recipient: uid, message: [ `${ICONS.assignNotice} ${codeId(taskId, displayCode)}`, description || '(sin descripción)', formatDDMM(dueDate) ? `${ICONS.date} ${formatDDMM(dueDate)}` : null, groupName ? `Grupo: ${groupName}` : null, `- Completar: \`t x ${displayCode}\``, `- Soltar: \`t soltar ${displayCode}\``, ].filter(Boolean).join('\n') + '\n\n' + CTA_HELP, mentions: [`${createdBy}@s.whatsapp.net`], }); } return notices; } function recordTaskOrigin(db: Database, taskId: number, groupId: string, ctx: Ctx): void { if (!isGroupId(groupId) || !ctx.messageId) return; try { const participant = typeof ctx.participant === 'string' ? ctx.participant : null; const fromMe = typeof ctx.fromMe === 'boolean' ? (ctx.fromMe ? 1 : 0) : null; try { db.prepare(` INSERT OR IGNORE INTO task_origins (task_id, chat_id, message_id, participant, from_me) VALUES (?, ?, ?, ?, ?) `).run(taskId, groupId, ctx.messageId, participant, fromMe); } catch { db.prepare(` INSERT OR IGNORE INTO task_origins (task_id, chat_id, message_id) VALUES (?, ?, ?) `).run(taskId, groupId, ctx.messageId); } } catch {} } // --------------------------------------------------------------------------- // Main handler // --------------------------------------------------------------------------- export async function handleNueva(context: Ctx, deps: { db: Database }): Promise { const tokens = (context.message || '').trim().split(/\s+/); const limits = getFallbackDigitLimits(); // 1. Process mentions from context and @tokens from text const mentionResult = processContextMentions(context.mentions || [], limits, context.groupId); const tokenResult = processAtTokens(tokens.slice(2), limits, context.groupId); const combinedCandidates = Array.from(new Set([ ...mentionResult.ids, ...tokenResult.ids, ])); const unresolvedDisplays = Array.from(new Set([ ...mentionResult.unresolved, ...tokenResult.unresolved, ])); // 2. Parse command const { description, dueDate, selfAssign } = parseNueva( (context.message || '').trim(), mentionResult.ids, ); // 3. Ensure creator const createdBy = ensureUserExists(context.sender, deps.db); if (!createdBy) throw new Error('No se pudo asegurar el usuario creador'); // 4. Resolve assignees const { ensured: ensuredAssignees, userIds: assignmentUserIds } = resolveFinalAssignees( combinedCandidates, selfAssign, context.sender, context.groupId, deps.db, ); // 5. Determine group const groupIdToUse = (context.groupId && GroupSyncService.isGroupActive(context.groupId)) ? context.groupId : null; // 6. Create task 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 })), ); // 7. Record origin if (groupIdToUse) recordTaskOrigin(deps.db, taskId, groupIdToUse, context); // 8. Fetch created task & display names const createdTask = TaskService.getTaskById(taskId); const groupName = groupIdToUse ? (GroupSyncService.activeGroupsCache.get(groupIdToUse) ?? null) : null; const displayNames = await Promise.all( assignmentUserIds.map(uid => ContactsService.getDisplayName(uid).then(n => n || uid)), ); // 9. Build responses const responses: Msg[] = []; responses.push(buildAcknowledgement(taskId, createdTask?.display_code ?? null, description || '', dueDate, assignmentUserIds, displayNames, groupName, createdBy)); responses.push(...buildAssigneeNotices(taskId, createdTask?.display_code ?? null, description || '', dueDate, assignmentUserIds, createdBy, groupName)); responses.push(...buildJitAssigneePrompt(createdBy, context.groupId, unresolvedDisplays)); // 10. Onboarding bundle 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; }