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.

321 lines
11 KiB
TypeScript

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<Msg[]> {
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;
}