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