Compare commits
No commits in common. 'main' and 'webui' have entirely different histories.
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 574 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 75 KiB |
@ -1,18 +0,0 @@
|
||||
{
|
||||
"metrics": [
|
||||
"commands_alias_used_total",
|
||||
"ver_dm_transition_total",
|
||||
"web_tokens_issued_total",
|
||||
"commands_unknown_total",
|
||||
"commands_blocked_total",
|
||||
"onboarding_prompts_sent_total",
|
||||
"onboarding_prompts_skipped_total",
|
||||
"onboarding_assign_failures_total",
|
||||
"onboarding_bundle_sent_total",
|
||||
"onboarding_recipients_capped_total",
|
||||
"onboarding_dm_skipped_total"
|
||||
],
|
||||
"labels": {
|
||||
"commands_alias_used_total": ["info", "mias", "todas"]
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,262 +0,0 @@
|
||||
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[];
|
||||
};
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
@ -1,166 +0,0 @@
|
||||
/**
|
||||
* Router de comandos (Etapa 3)
|
||||
* Maneja 'configurar' y 'web', y delega el resto al código actual (null → fallback).
|
||||
* Nota: No importar CommandService aquí para evitar ciclos de import.
|
||||
*/
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import { ACTION_ALIASES } from './shared';
|
||||
import { handleConfigurar } from './handlers/configurar';
|
||||
import { handleWeb } from './handlers/web';
|
||||
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';
|
||||
|
||||
function getQuickHelp(): string {
|
||||
return [
|
||||
'Guía rápida:',
|
||||
'- Ver tus tareas: `/t mias`',
|
||||
'- Ver todas: `/t todas`',
|
||||
'- Crear: `/t n Descripción 2028-11-26 @Ana`',
|
||||
'- Completar: `/t x 123`',
|
||||
'- Tomar: `/t tomar 12`',
|
||||
'- Configurar recordatorios: `/t configurar diario|l-v|semanal|off [HH:MM]`',
|
||||
'- Web: `/t web`'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function getFullHelp(): string {
|
||||
return [
|
||||
'Ayuda avanzada:',
|
||||
'Comandos y alias:',
|
||||
' · Crear: `n`, `nueva`, `crear`, `+`',
|
||||
' · Ver: `ver`, `listar`, `mostrar`, `ls` (scopes: `mis` | `todas`)',
|
||||
' · Completar: `x`, `hecho`, `completar`, `done`',
|
||||
' · Tomar: `tomar`, `claim`',
|
||||
' · Soltar: `soltar`, `unassign`',
|
||||
'Preferencias:',
|
||||
' · `/t configurar diario|l-v|semanal|off [HH:MM]`',
|
||||
'Fechas:',
|
||||
' · `YYYY-MM-DD` o `YY-MM-DD` → `20YY-MM-DD` (ej.: 27-09-04)',
|
||||
' · Palabras: `hoy`, `mañana`',
|
||||
'Acceso web:',
|
||||
' · `/t web`',
|
||||
'Atajos:',
|
||||
' · `/t mias`',
|
||||
' · `/t todas`'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildUnknownHelp(): string {
|
||||
const header = '❓ COMANDO NO RECONOCIDO';
|
||||
const cta = 'Prueba `/t ayuda`';
|
||||
return [header, cta, '', getQuickHelp()].join('\n');
|
||||
}
|
||||
|
||||
export type RoutedMessage = {
|
||||
recipient: string;
|
||||
message: string;
|
||||
mentions?: string[];
|
||||
};
|
||||
|
||||
export type RouteContext = {
|
||||
sender: string;
|
||||
groupId: string;
|
||||
message: string;
|
||||
mentions: string[];
|
||||
messageId?: string;
|
||||
participant?: string;
|
||||
fromMe?: boolean;
|
||||
};
|
||||
|
||||
export async function route(context: RouteContext, deps?: { db: Database }): Promise<RoutedMessage[] | null> {
|
||||
const trimmed = (context.message || '').trim();
|
||||
const tokens = trimmed.split(/\s+/);
|
||||
const rawAction = (tokens[1] || '').toLowerCase();
|
||||
const action = ACTION_ALIASES[rawAction] || rawAction;
|
||||
|
||||
// Ayuda (no requiere DB)
|
||||
if (action === 'ayuda') {
|
||||
// Métrica de alias "info" (compatibilidad con legacy)
|
||||
try {
|
||||
if (rawAction === 'info' || rawAction === '?') {
|
||||
Metrics.inc('commands_alias_used_total', 1, { action: 'info' });
|
||||
}
|
||||
} catch {}
|
||||
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
|
||||
|
||||
const isAdvanced = (tokens[2] || '').toLowerCase() === 'avanzada';
|
||||
const message = isAdvanced
|
||||
? getFullHelp()
|
||||
: [getQuickHelp(), '', 'Ayuda avanzada: `/t ayuda avanzada`'].join('\n');
|
||||
return [{
|
||||
recipient: context.sender,
|
||||
message
|
||||
}];
|
||||
}
|
||||
|
||||
// Requiere db inyectada para poder operar (CommandService la inyecta)
|
||||
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 {
|
||||
if (rawAction === 'mias' || rawAction === 'mías') {
|
||||
Metrics.inc('commands_alias_used_total', 1, { action: 'mias' });
|
||||
} else if (rawAction === 'todas' || rawAction === 'todos') {
|
||||
Metrics.inc('commands_alias_used_total', 1, { action: 'todas' });
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
|
||||
|
||||
// En grupo: transición a DM
|
||||
if (isGroupId(context.groupId)) {
|
||||
try { Metrics.inc('ver_dm_transition_total'); } catch {}
|
||||
return [{
|
||||
recipient: context.sender,
|
||||
message: 'No respondo en grupos. Tus tareas: /t mias · Todas: /t todas · Info: /t info · Web: /t web'
|
||||
}];
|
||||
}
|
||||
|
||||
return await handleVer(context as any);
|
||||
}
|
||||
|
||||
if (action === 'completar') {
|
||||
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
|
||||
return await handleCompletar(context as any);
|
||||
}
|
||||
|
||||
if (action === 'tomar') {
|
||||
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
|
||||
return await handleTomar(context as any);
|
||||
}
|
||||
|
||||
if (action === 'soltar') {
|
||||
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
|
||||
return await handleSoltar(context as any);
|
||||
}
|
||||
|
||||
if (action === 'configurar') {
|
||||
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
|
||||
return handleConfigurar(context as any, { db: database });
|
||||
}
|
||||
|
||||
if (action === 'web') {
|
||||
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
|
||||
return await handleWeb(context as any, { db: database });
|
||||
}
|
||||
|
||||
// Desconocido → ayuda rápida
|
||||
try { Metrics.inc('commands_unknown_total'); } catch {}
|
||||
return [{
|
||||
recipient: context.sender,
|
||||
message: buildUnknownHelp()
|
||||
}];
|
||||
}
|
||||
@ -1,128 +0,0 @@
|
||||
export function parseNueva(message: string, _mentionsNormalized: string[]): {
|
||||
action: string;
|
||||
description: string;
|
||||
dueDate: string | null;
|
||||
selfAssign: boolean;
|
||||
} {
|
||||
const parts = (message || '').trim().split(/\s+/);
|
||||
const action = (parts[1] || '').toLowerCase();
|
||||
|
||||
// Zona horaria configurable (por defecto Europe/Madrid)
|
||||
const TZ = process.env.TZ && process.env.TZ.trim() ? process.env.TZ : 'Europe/Madrid';
|
||||
|
||||
// Utilidades locales para operar con fechas en la TZ elegida sin depender del huso del host
|
||||
const ymdFromDateInTZ = (d: Date): string => {
|
||||
const fmt = new Intl.DateTimeFormat('es-ES', {
|
||||
timeZone: TZ,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(d);
|
||||
const get = (t: string) => fmt.find(p => p.type === t)?.value || '';
|
||||
return `${get('year')}-${get('month')}-${get('day')}`;
|
||||
};
|
||||
|
||||
const addDaysToYMD = (ymd: string, days: number): string => {
|
||||
const [Y, M, D] = ymd.split('-').map(n => parseInt(n, 10));
|
||||
const base = new Date(Date.UTC(Y, (M || 1) - 1, D || 1));
|
||||
base.setUTCDate(base.getUTCDate() + days);
|
||||
return ymdFromDateInTZ(base);
|
||||
};
|
||||
|
||||
const todayYMD = ymdFromDateInTZ(new Date());
|
||||
|
||||
// Helpers para validar y normalizar fechas explícitas
|
||||
const isLeap = (y: number) => (y % 4 === 0 && y % 100 !== 0) || (y % 400 === 0);
|
||||
const daysInMonth = (y: number, m: number) => {
|
||||
if (m === 2) return isLeap(y) ? 29 : 28;
|
||||
return [31, -1, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][m - 1];
|
||||
};
|
||||
const isValidYMD = (ymd: string): boolean => {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd);
|
||||
if (!m) return false;
|
||||
const Y = parseInt(m[1], 10);
|
||||
const MM = parseInt(m[2], 10);
|
||||
const DD = parseInt(m[3], 10);
|
||||
if (MM < 1 || MM > 12) return false;
|
||||
const dim = daysInMonth(Y, MM);
|
||||
if (!dim || DD < 1 || DD > dim) return false;
|
||||
return true;
|
||||
};
|
||||
const normalizeDateToken = (t: string): string | null => {
|
||||
// YYYY-MM-DD
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(t)) {
|
||||
return isValidYMD(t) ? t : null;
|
||||
}
|
||||
// YY-MM-DD -> 20YY-MM-DD
|
||||
const m = /^(\d{2})-(\d{2})-(\d{2})$/.exec(t);
|
||||
if (m) {
|
||||
const yy = parseInt(m[1], 10);
|
||||
const mm = m[2];
|
||||
const dd = m[3];
|
||||
const yyyy = 2000 + yy;
|
||||
const ymd = `${String(yyyy)}-${mm}-${dd}`;
|
||||
return isValidYMD(ymd) ? ymd : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
type DateCandidate = { index: number; ymd: string };
|
||||
const dateCandidates: DateCandidate[] = [];
|
||||
const dateTokenIndexes = new Set<number>();
|
||||
const selfTokenIndexes = new Set<number>();
|
||||
let selfAssign = false;
|
||||
|
||||
for (let i = 2; i < parts.length; i++) {
|
||||
// Normalizar token: minúsculas y sin puntuación adyacente simple
|
||||
const raw = parts[i];
|
||||
const low = raw.toLowerCase().replace(/^[([{¿¡"']+/, '').replace(/[.,;:!?)\]}¿¡"'']+$/, '');
|
||||
|
||||
// Fecha explícita en formatos permitidos: YYYY-MM-DD o YY-MM-DD (expandido a 20YY)
|
||||
{
|
||||
const norm = normalizeDateToken(low);
|
||||
if (norm && norm >= todayYMD) {
|
||||
dateCandidates.push({ index: i, ymd: norm });
|
||||
dateTokenIndexes.add(i);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Tokens naturales "hoy"/"mañana" (con o sin acento)
|
||||
if (low === 'hoy') {
|
||||
dateCandidates.push({ index: i, ymd: todayYMD });
|
||||
dateTokenIndexes.add(i);
|
||||
continue;
|
||||
}
|
||||
if (low === 'mañana' || low === 'manana') {
|
||||
dateCandidates.push({ index: i, ymd: addDaysToYMD(todayYMD, 1) });
|
||||
dateTokenIndexes.add(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Autoasignación: detectar 'yo' o '@yo' como palabra aislada (insensible a mayúsculas; ignora puntuación simple)
|
||||
if (low === 'yo' || low === '@yo') {
|
||||
selfAssign = true;
|
||||
selfTokenIndexes.add(i);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const dueDate = dateCandidates.length > 0
|
||||
? dateCandidates[dateCandidates.length - 1].ymd
|
||||
: null;
|
||||
|
||||
const isMentionToken = (token: string) => token.startsWith('@');
|
||||
|
||||
const descriptionTokens: string[] = [];
|
||||
for (let i = 2; i < parts.length; i++) {
|
||||
if (dateTokenIndexes.has(i)) continue;
|
||||
if (selfTokenIndexes.has(i)) continue;
|
||||
const token = parts[i];
|
||||
if (isMentionToken(token)) continue;
|
||||
descriptionTokens.push(token);
|
||||
}
|
||||
|
||||
const description = descriptionTokens.join(' ').trim();
|
||||
|
||||
return { action, description, dueDate, selfAssign };
|
||||
}
|
||||
@ -1,131 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { initializeDatabase } from '../../../src/db';
|
||||
import { WebhookServer } from '../../../src/server';
|
||||
import { ResponseQueue } from '../../../src/services/response-queue';
|
||||
import { AllowedGroups } from '../../../src/services/allowed-groups';
|
||||
import { GroupSyncService } from '../../../src/services/group-sync';
|
||||
|
||||
function makePayload(event: string, data: any) {
|
||||
return {
|
||||
event,
|
||||
instance: 'test-instance',
|
||||
data
|
||||
};
|
||||
}
|
||||
|
||||
async function postWebhook(payload: any) {
|
||||
const req = new Request('http://localhost/webhook', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
return await WebhookServer.handleRequest(req);
|
||||
}
|
||||
|
||||
describe('WebhookServer E2E - reacciones por comando', () => {
|
||||
let memdb: Database;
|
||||
const envBackup = { ...process.env };
|
||||
|
||||
beforeAll(() => {
|
||||
memdb = new Database(':memory:');
|
||||
initializeDatabase(memdb);
|
||||
(WebhookServer as any).dbInstance = memdb;
|
||||
(ResponseQueue as any).dbInstance = memdb;
|
||||
(AllowedGroups as any).dbInstance = memdb;
|
||||
(GroupSyncService as any).dbInstance = memdb;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = envBackup;
|
||||
try { memdb.close(); } catch {}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = {
|
||||
...envBackup,
|
||||
NODE_ENV: 'test',
|
||||
REACTIONS_ENABLED: 'true',
|
||||
REACTIONS_SCOPE: 'groups',
|
||||
GROUP_GATING_MODE: 'enforce',
|
||||
CHATBOT_PHONE_NUMBER: '999'
|
||||
};
|
||||
memdb.exec(`
|
||||
DELETE FROM response_queue;
|
||||
DELETE FROM task_origins;
|
||||
DELETE FROM tasks;
|
||||
DELETE FROM users;
|
||||
DELETE FROM groups;
|
||||
DELETE FROM allowed_groups;
|
||||
`);
|
||||
GroupSyncService.activeGroupsCache?.clear?.();
|
||||
});
|
||||
|
||||
it('encola 🤖 en grupo allowed y activo tras /t n', async () => {
|
||||
const groupId = 'g1@g.us';
|
||||
// Sembrar grupo activo y allowed
|
||||
memdb.exec(`
|
||||
INSERT OR IGNORE INTO groups (id, community_id, name, active, archived, is_community, last_verified)
|
||||
VALUES ('${groupId}', 'comm-1', 'G1', 1, 0, 0, strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||
`);
|
||||
GroupSyncService.activeGroupsCache.set(groupId, 'G1');
|
||||
AllowedGroups.setStatus(groupId, 'allowed');
|
||||
|
||||
const payload = makePayload('MESSAGES_UPSERT', {
|
||||
key: { remoteJid: groupId, id: 'MSG-OK-1', fromMe: false, participant: '600111222@s.whatsapp.net' },
|
||||
message: { conversation: '/t n prueba e2e' }
|
||||
});
|
||||
|
||||
const res = await postWebhook(payload);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = memdb.prepare(`SELECT metadata FROM response_queue WHERE metadata LIKE '%"kind":"reaction"%' ORDER BY id DESC LIMIT 1`).get() as any;
|
||||
expect(row).toBeTruthy();
|
||||
const meta = JSON.parse(String(row.metadata));
|
||||
expect(meta.kind).toBe('reaction');
|
||||
expect(meta.emoji).toBe('🤖');
|
||||
expect(meta.chatId).toBe(groupId);
|
||||
expect(meta.messageId).toBe('MSG-OK-1');
|
||||
});
|
||||
|
||||
it('no encola reacción en DM cuando REACTIONS_SCOPE=groups', async () => {
|
||||
const dmJid = '600111222@s.whatsapp.net';
|
||||
|
||||
const payload = makePayload('MESSAGES_UPSERT', {
|
||||
key: { remoteJid: dmJid, id: 'MSG-DM-1', fromMe: false },
|
||||
message: { conversation: '/t n en DM no reacciona' }
|
||||
});
|
||||
|
||||
const res = await postWebhook(payload);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const cnt = memdb.prepare(`SELECT COUNT(*) AS c FROM response_queue WHERE metadata LIKE '%"kind":"reaction"%'`).get() as any;
|
||||
expect(Number(cnt.c)).toBe(0);
|
||||
});
|
||||
|
||||
it('encola ⚠️ en grupo allowed y activo para comando inválido (/t x sin IDs)', async () => {
|
||||
const groupId = 'g2@g.us';
|
||||
memdb.exec(`
|
||||
INSERT OR IGNORE INTO groups (id, community_id, name, active, archived, is_community, last_verified)
|
||||
VALUES ('${groupId}', 'comm-1', 'G2', 1, 0, 0, strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||
`);
|
||||
GroupSyncService.activeGroupsCache.set(groupId, 'G2');
|
||||
AllowedGroups.setStatus(groupId, 'allowed');
|
||||
|
||||
const payload = makePayload('MESSAGES_UPSERT', {
|
||||
key: { remoteJid: groupId, id: 'MSG-ERR-1', fromMe: false, participant: '600111222@s.whatsapp.net' },
|
||||
message: { conversation: '/t x' }
|
||||
});
|
||||
|
||||
const res = await postWebhook(payload);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = memdb.prepare(`SELECT metadata FROM response_queue WHERE metadata LIKE '%"kind":"reaction"%' ORDER BY id DESC LIMIT 1`).get() as any;
|
||||
expect(row).toBeTruthy();
|
||||
const meta = JSON.parse(String(row.metadata));
|
||||
expect(meta.kind).toBe('reaction');
|
||||
expect(meta.emoji).toBe('⚠️');
|
||||
expect(meta.chatId).toBe(groupId);
|
||||
expect(meta.messageId).toBe('MSG-ERR-1');
|
||||
});
|
||||
});
|
||||
@ -1,159 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { initializeDatabase } from '../../../src/db';
|
||||
import { TaskService } from '../../../src/tasks/service';
|
||||
import { CommandService } from '../../../src/services/command';
|
||||
import { Metrics } from '../../../src/services/metrics';
|
||||
|
||||
describe('CommandService - autoasignación con "yo" / "@yo"', () => {
|
||||
let memdb: Database;
|
||||
|
||||
beforeAll(() => {
|
||||
memdb = new Database(':memory:');
|
||||
initializeDatabase(memdb);
|
||||
TaskService.dbInstance = memdb;
|
||||
CommandService.dbInstance = memdb;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.METRICS_ENABLED = 'true';
|
||||
Metrics.reset?.();
|
||||
|
||||
memdb.exec(`
|
||||
DELETE FROM task_assignments;
|
||||
DELETE FROM tasks;
|
||||
DELETE FROM users;
|
||||
DELETE FROM user_preferences;
|
||||
`);
|
||||
});
|
||||
|
||||
function getLastTask() {
|
||||
return memdb.prepare(`SELECT id, description FROM tasks ORDER BY id DESC LIMIT 1`).get() as any;
|
||||
}
|
||||
|
||||
function getAssignees(taskId: number): string[] {
|
||||
const rows = memdb.prepare(`SELECT user_id FROM task_assignments WHERE task_id = ? ORDER BY assigned_at ASC`).all(taskId) as any[];
|
||||
return rows.map(r => String(r.user_id));
|
||||
}
|
||||
|
||||
it('en grupo: "yo" autoasigna al remitente y no queda en la descripción', async () => {
|
||||
const sender = '600111222';
|
||||
await CommandService.handle({
|
||||
sender,
|
||||
groupId: '12345@g.us', // contexto grupo
|
||||
message: '/t n Hacer algo yo',
|
||||
mentions: [],
|
||||
});
|
||||
|
||||
const t = getLastTask();
|
||||
const assignees = getAssignees(Number(t.id));
|
||||
expect(assignees).toContain(sender);
|
||||
expect(String(t.description)).toBe('Hacer algo');
|
||||
});
|
||||
|
||||
it('en grupo: "@yo" autoasigna y no incrementa métricas de fallo', async () => {
|
||||
const sender = '600222333';
|
||||
await CommandService.handle({
|
||||
sender,
|
||||
groupId: 'group@g.us',
|
||||
message: '/t n Revisar docs @yo',
|
||||
mentions: [],
|
||||
});
|
||||
|
||||
const t = getLastTask();
|
||||
const assignees = getAssignees(Number(t.id));
|
||||
expect(assignees).toContain(sender);
|
||||
expect(String(t.description)).toBe('Revisar docs');
|
||||
|
||||
const prom = Metrics.render?.('prom') || '';
|
||||
expect(prom).not.toContain('onboarding_assign_failures_total');
|
||||
});
|
||||
|
||||
it('no falsos positivos: "yoyo" y "hoyo" no autoasignan en grupo (queda sin dueño)', async () => {
|
||||
const sender = '600333444';
|
||||
// yoyo
|
||||
await CommandService.handle({
|
||||
sender,
|
||||
groupId: 'grp@g.us',
|
||||
message: '/t n Caso yoyo',
|
||||
mentions: [],
|
||||
});
|
||||
let t = getLastTask();
|
||||
let assignees = getAssignees(Number(t.id));
|
||||
expect(assignees.length).toBe(0);
|
||||
|
||||
// hoyo
|
||||
await CommandService.handle({
|
||||
sender,
|
||||
groupId: 'grp@g.us',
|
||||
message: '/t n Voy a cavar un hoyo',
|
||||
mentions: [],
|
||||
});
|
||||
t = getLastTask();
|
||||
assignees = getAssignees(Number(t.id));
|
||||
expect(assignees.length).toBe(0);
|
||||
});
|
||||
|
||||
it('combinado: "yo @34600123456" asigna al remitente y al otro usuario', async () => {
|
||||
const sender = '600444555';
|
||||
await CommandService.handle({
|
||||
sender,
|
||||
groupId: 'g@g.us',
|
||||
message: '/t n Tarea combinada yo @34600123456',
|
||||
mentions: [],
|
||||
});
|
||||
|
||||
const t = getLastTask();
|
||||
const assignees = getAssignees(Number(t.id));
|
||||
expect(new Set(assignees)).toEqual(new Set([sender, '34600123456']));
|
||||
});
|
||||
|
||||
it('en DM: "yo" también se asigna al remitente y no queda en la descripción', async () => {
|
||||
const sender = '600555666';
|
||||
await CommandService.handle({
|
||||
sender,
|
||||
groupId: `${sender}@s.whatsapp.net`, // DM
|
||||
message: '/t n Mi tarea yo',
|
||||
mentions: [],
|
||||
});
|
||||
|
||||
const t = getLastTask();
|
||||
const assignees = getAssignees(Number(t.id));
|
||||
expect(assignees).toContain(sender);
|
||||
expect(String(t.description)).toBe('Mi tarea');
|
||||
});
|
||||
|
||||
it('en grupo: "@yo," autoasigna y no incrementa métricas de fallo', async () => {
|
||||
const sender = '600666777';
|
||||
await CommandService.handle({
|
||||
sender,
|
||||
groupId: 'group2@g.us',
|
||||
message: '/t n Revisar algo @yo,',
|
||||
mentions: [],
|
||||
});
|
||||
|
||||
const t = getLastTask();
|
||||
const assignees = getAssignees(Number(t.id));
|
||||
expect(assignees).toContain(sender);
|
||||
expect(String(t.description)).toBe('Revisar algo');
|
||||
|
||||
const prom = Metrics.render?.('prom') || '';
|
||||
expect(prom).not.toContain('onboarding_assign_failures_total');
|
||||
});
|
||||
|
||||
it('en grupo: "(yo)" autoasigna y no queda en la descripción', async () => {
|
||||
const sender = '600777888';
|
||||
await CommandService.handle({
|
||||
sender,
|
||||
groupId: 'grp2@g.us',
|
||||
message: '/t n Hacer (yo)',
|
||||
mentions: [],
|
||||
});
|
||||
|
||||
const t = getLastTask();
|
||||
const assignees = getAssignees(Number(t.id));
|
||||
expect(assignees).toContain(sender);
|
||||
expect(String(t.description)).toBe('Hacer');
|
||||
});
|
||||
});
|
||||
@ -1,55 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { initializeDatabase } from '../../../src/db';
|
||||
import { TaskService } from '../../../src/tasks/service';
|
||||
import { CommandService } from '../../../src/services/command';
|
||||
import { GroupSyncService } from '../../../src/services/group-sync';
|
||||
|
||||
describe('CommandService - inserta task_origins al crear en grupo con messageId', () => {
|
||||
let memdb: Database;
|
||||
|
||||
beforeAll(() => {
|
||||
memdb = new Database(':memory:');
|
||||
initializeDatabase(memdb);
|
||||
(TaskService as any).dbInstance = memdb;
|
||||
(CommandService as any).dbInstance = memdb;
|
||||
|
||||
// Sembrar grupo activo y cache
|
||||
memdb.exec(`
|
||||
INSERT OR IGNORE INTO groups (id, community_id, name, active, archived, is_community, last_verified)
|
||||
VALUES ('g1@g.us', 'comm-1', 'G1', 1, 0, 0, strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||
`);
|
||||
try { (GroupSyncService as any).dbInstance = memdb; } catch {}
|
||||
GroupSyncService.activeGroupsCache?.clear?.();
|
||||
GroupSyncService.activeGroupsCache?.set?.('g1@g.us', 'G1');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
memdb.exec('DELETE FROM task_assignments; DELETE FROM tasks; DELETE FROM task_origins;');
|
||||
});
|
||||
|
||||
it('crea tarea en grupo y registra (task_id, chat_id, message_id)', async () => {
|
||||
const sender = '600111222';
|
||||
const res = await CommandService.handle({
|
||||
sender,
|
||||
groupId: 'g1@g.us',
|
||||
message: '/t n pruebas origen 2099-01-05',
|
||||
mentions: [],
|
||||
messageId: 'MSG-ORIG-1'
|
||||
});
|
||||
|
||||
expect(res.length).toBeGreaterThan(0);
|
||||
|
||||
const t = memdb.prepare(`SELECT id FROM tasks ORDER BY id DESC LIMIT 1`).get() as any;
|
||||
expect(t).toBeTruthy();
|
||||
const row = memdb.prepare(`
|
||||
SELECT task_id, chat_id, message_id FROM task_origins WHERE task_id = ?
|
||||
`).get(Number(t.id)) as any;
|
||||
|
||||
expect(row).toBeTruthy();
|
||||
expect(Number(row.task_id)).toBe(Number(t.id));
|
||||
expect(String(row.chat_id)).toBe('g1@g.us');
|
||||
expect(String(row.message_id)).toBe('MSG-ORIG-1');
|
||||
});
|
||||
});
|
||||
@ -1,97 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { ResponseQueue } from '../../../src/services/response-queue';
|
||||
|
||||
describe('ResponseQueue - payload de reacción', () => {
|
||||
const OLD_FETCH = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.EVOLUTION_API_URL = 'http://evolution.local';
|
||||
process.env.EVOLUTION_API_INSTANCE = 'instance-1';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = OLD_FETCH;
|
||||
delete process.env.EVOLUTION_API_URL;
|
||||
delete process.env.EVOLUTION_API_INSTANCE;
|
||||
});
|
||||
|
||||
it('incluye participant y fromMe cuando están presentes', async () => {
|
||||
const calls: any[] = [];
|
||||
globalThis.fetch = (async (url: any, init?: any) => {
|
||||
calls.push({ url, init });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => ''
|
||||
} as any;
|
||||
}) as any;
|
||||
|
||||
const item = {
|
||||
id: 1,
|
||||
recipient: '12345-67890@g.us',
|
||||
message: '',
|
||||
metadata: JSON.stringify({
|
||||
kind: 'reaction',
|
||||
emoji: '✅',
|
||||
chatId: '12345-67890@g.us',
|
||||
messageId: 'MSG-123',
|
||||
fromMe: true,
|
||||
participant: '34600123456@s.whatsapp.net'
|
||||
}),
|
||||
attempts: 0
|
||||
};
|
||||
|
||||
const res = await ResponseQueue.sendOne(item as any);
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
expect(calls.length).toBe(1);
|
||||
const { url, init } = calls[0];
|
||||
expect(String(url)).toBe('http://evolution.local/message/sendReaction/instance-1');
|
||||
const payload = JSON.parse(String(init.body || '{}'));
|
||||
expect(payload).toBeTruthy();
|
||||
expect(payload.reaction).toBe('✅');
|
||||
expect(payload.key).toEqual({
|
||||
remoteJid: '12345-67890@g.us',
|
||||
fromMe: true,
|
||||
id: 'MSG-123',
|
||||
participant: '34600123456@s.whatsapp.net'
|
||||
});
|
||||
});
|
||||
|
||||
it('omite participant y usa fromMe=false por defecto cuando no se proveen', async () => {
|
||||
const calls: any[] = [];
|
||||
globalThis.fetch = (async (url: any, init?: any) => {
|
||||
calls.push({ url, init });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => ''
|
||||
} as any;
|
||||
}) as any;
|
||||
|
||||
const item = {
|
||||
id: 2,
|
||||
recipient: '12345-67890@g.us',
|
||||
message: '',
|
||||
metadata: JSON.stringify({
|
||||
kind: 'reaction',
|
||||
emoji: '✅',
|
||||
chatId: '12345-67890@g.us',
|
||||
messageId: 'MSG-456'
|
||||
}),
|
||||
attempts: 0
|
||||
};
|
||||
|
||||
const res = await ResponseQueue.sendOne(item as any);
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
const { url, init } = calls[0];
|
||||
expect(String(url)).toBe('http://evolution.local/message/sendReaction/instance-1');
|
||||
const payload = JSON.parse(String(init.body || '{}'));
|
||||
expect(payload.reaction).toBe('✅');
|
||||
expect(payload.key.remoteJid).toBe('12345-67890@g.us');
|
||||
expect(payload.key.id).toBe('MSG-456');
|
||||
expect(payload.key.fromMe).toBe(false);
|
||||
expect('participant' in payload.key).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -1,112 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { initializeDatabase } from '../../../src/db';
|
||||
import { ResponseQueue } from '../../../src/services/response-queue';
|
||||
|
||||
const ORIGINAL_FETCH = globalThis.fetch;
|
||||
const envBackup = { ...process.env };
|
||||
|
||||
describe('ResponseQueue - jobs de reacción (enqueue + sendOne)', () => {
|
||||
let memdb: Database;
|
||||
let captured: { url?: string; payload?: any } = {};
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = {
|
||||
...envBackup,
|
||||
NODE_ENV: 'test',
|
||||
EVOLUTION_API_URL: 'http://evolution.test',
|
||||
EVOLUTION_API_INSTANCE: 'instance-1',
|
||||
EVOLUTION_API_KEY: 'apikey',
|
||||
RQ_REACTIONS_MAX_ATTEMPTS: '3',
|
||||
};
|
||||
|
||||
memdb = new Database(':memory:');
|
||||
memdb.exec('PRAGMA foreign_keys = ON;');
|
||||
initializeDatabase(memdb);
|
||||
|
||||
(ResponseQueue as any).dbInstance = memdb;
|
||||
|
||||
globalThis.fetch = async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
captured.url = String(url);
|
||||
try {
|
||||
captured.payload = init?.body ? JSON.parse(String(init.body)) : null;
|
||||
} catch {
|
||||
captured.payload = null;
|
||||
}
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
};
|
||||
|
||||
memdb.exec('DELETE FROM response_queue');
|
||||
captured = {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = ORIGINAL_FETCH;
|
||||
process.env = envBackup;
|
||||
try { memdb.close(); } catch {}
|
||||
});
|
||||
|
||||
it('enqueueReaction aplica idempotencia por (chatId, messageId, emoji) en ventana 24h', async () => {
|
||||
await ResponseQueue.enqueueReaction('123@g.us', 'MSG-1', '🤖');
|
||||
await ResponseQueue.enqueueReaction('123@g.us', 'MSG-1', '🤖'); // duplicado → ignorar
|
||||
|
||||
const cnt = memdb.prepare(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
|
||||
expect(Number(cnt.c)).toBe(1);
|
||||
|
||||
// Mismo chat y mensaje, emoji distinto → debe insertar
|
||||
await ResponseQueue.enqueueReaction('123@g.us', 'MSG-1', '⚠️');
|
||||
const cnt2 = memdb.prepare(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
|
||||
expect(Number(cnt2.c)).toBe(2);
|
||||
});
|
||||
|
||||
it('sendOne con metadata.kind === "reaction" usa /message/sendReaction y payload esperado', async () => {
|
||||
const item = {
|
||||
id: 42,
|
||||
recipient: '123@g.us',
|
||||
message: '', // no se usa para reaction
|
||||
attempts: 0,
|
||||
metadata: JSON.stringify({ kind: 'reaction', emoji: '🤖', chatId: '123@g.us', messageId: 'MSG-99' }),
|
||||
};
|
||||
|
||||
const res = await ResponseQueue.sendOne(item as any);
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
expect(captured.url?.includes('/message/sendReaction/instance-1')).toBe(true);
|
||||
expect(captured.payload).toBeDefined();
|
||||
expect(captured.payload.reaction).toBe('🤖');
|
||||
expect(captured.payload.key).toEqual({ remoteJid: '123@g.us', fromMe: false, id: 'MSG-99' });
|
||||
});
|
||||
|
||||
it('sendOne incluye key.participant cuando viene en metadata (grupo, fromMe:false)', async () => {
|
||||
const item = {
|
||||
id: 43,
|
||||
recipient: '120363401791776728@g.us',
|
||||
message: '',
|
||||
attempts: 0,
|
||||
metadata: JSON.stringify({
|
||||
kind: 'reaction',
|
||||
emoji: '✅',
|
||||
chatId: '120363401791776728@g.us',
|
||||
messageId: 'MSG-100',
|
||||
participant: '34650861805:32@s.whatsapp.net',
|
||||
fromMe: false
|
||||
}),
|
||||
};
|
||||
|
||||
const res = await ResponseQueue.sendOne(item as any);
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
expect(captured.url?.includes('/message/sendReaction/instance-1')).toBe(true);
|
||||
expect(captured.payload).toBeDefined();
|
||||
expect(captured.payload.reaction).toBe('✅');
|
||||
expect(captured.payload.key).toEqual({
|
||||
remoteJid: '120363401791776728@g.us',
|
||||
fromMe: false,
|
||||
id: 'MSG-100',
|
||||
participant: '34650861805:32@s.whatsapp.net'
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,157 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { initializeDatabase } from '../../../src/db';
|
||||
import { TaskService } from '../../../src/tasks/service';
|
||||
import { ResponseQueue } from '../../../src/services/response-queue';
|
||||
import { AllowedGroups } from '../../../src/services/allowed-groups';
|
||||
|
||||
function toIsoSql(d: Date): string {
|
||||
return d.toISOString().replace('T', ' ').replace('Z', '');
|
||||
}
|
||||
|
||||
describe('TaskService - reacción ✅ al completar (Fase 2)', () => {
|
||||
let memdb: Database;
|
||||
let envBackup: Record<string, string | undefined>;
|
||||
|
||||
beforeAll(() => {
|
||||
envBackup = { ...process.env };
|
||||
memdb = new Database(':memory:');
|
||||
initializeDatabase(memdb);
|
||||
(TaskService as any).dbInstance = memdb;
|
||||
(ResponseQueue as any).dbInstance = memdb;
|
||||
(AllowedGroups as any).dbInstance = memdb;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = envBackup;
|
||||
try { memdb.close(); } catch {}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.REACTIONS_ENABLED = 'true';
|
||||
process.env.REACTIONS_SCOPE = 'groups';
|
||||
process.env.REACTIONS_TTL_DAYS = '14';
|
||||
process.env.GROUP_GATING_MODE = 'enforce';
|
||||
|
||||
memdb.exec(`
|
||||
DELETE FROM response_queue;
|
||||
DELETE FROM task_origins;
|
||||
DELETE FROM tasks;
|
||||
DELETE FROM users;
|
||||
DELETE FROM allowed_groups;
|
||||
`);
|
||||
});
|
||||
|
||||
it('enqueuea ✅ al completar una tarea con task_origins dentro de TTL y grupo allowed', async () => {
|
||||
const groupId = 'grp-1@g.us';
|
||||
AllowedGroups.setStatus(groupId, 'allowed');
|
||||
|
||||
const taskId = TaskService.createTask({
|
||||
description: 'Prueba ✅',
|
||||
due_date: null,
|
||||
group_id: groupId,
|
||||
created_by: '600111222'
|
||||
});
|
||||
|
||||
// Origen reciente (dentro de TTL)
|
||||
const msgId = 'MSG-OK-1';
|
||||
memdb.prepare(`
|
||||
INSERT INTO task_origins (task_id, chat_id, message_id, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(taskId, groupId, msgId, toIsoSql(new Date()));
|
||||
|
||||
const res = TaskService.completeTask(taskId, '600111222');
|
||||
expect(res.status).toBe('updated');
|
||||
|
||||
const row = memdb.prepare(`SELECT id, recipient, metadata FROM response_queue ORDER BY id DESC LIMIT 1`).get() as any;
|
||||
expect(row).toBeTruthy();
|
||||
expect(String(row.recipient)).toBe(groupId);
|
||||
|
||||
const meta = JSON.parse(String(row.metadata || '{}'));
|
||||
expect(meta.kind).toBe('reaction');
|
||||
expect(meta.emoji).toBe('✅');
|
||||
expect(meta.chatId).toBe(groupId);
|
||||
expect(meta.messageId).toBe(msgId);
|
||||
});
|
||||
|
||||
it('no encola ✅ si el origen está fuera de TTL', async () => {
|
||||
const groupId = 'grp-2@g.us';
|
||||
AllowedGroups.setStatus(groupId, 'allowed');
|
||||
|
||||
// TTL 7 días para forzar expiración
|
||||
process.env.REACTIONS_TTL_DAYS = '7';
|
||||
|
||||
const taskId = TaskService.createTask({
|
||||
description: 'Fuera TTL',
|
||||
due_date: null,
|
||||
group_id: groupId,
|
||||
created_by: '600111222'
|
||||
});
|
||||
|
||||
const msgId = 'MSG-OLD-1';
|
||||
const old = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000); // 8 días atrás
|
||||
memdb.prepare(`
|
||||
INSERT INTO task_origins (task_id, chat_id, message_id, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(taskId, groupId, msgId, toIsoSql(old));
|
||||
|
||||
const res = TaskService.completeTask(taskId, '600111222');
|
||||
expect(res.status).toBe('updated');
|
||||
|
||||
const cnt = memdb.prepare(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
|
||||
expect(Number(cnt.c)).toBe(0);
|
||||
});
|
||||
|
||||
it('idempotencia: completar dos veces encola solo un ✅', async () => {
|
||||
const groupId = 'grp-3@g.us';
|
||||
AllowedGroups.setStatus(groupId, 'allowed');
|
||||
|
||||
const taskId = TaskService.createTask({
|
||||
description: 'Idempotencia ✅',
|
||||
due_date: null,
|
||||
group_id: groupId,
|
||||
created_by: '600111222'
|
||||
});
|
||||
|
||||
const msgId = 'MSG-IDEMP-1';
|
||||
memdb.prepare(`
|
||||
INSERT INTO task_origins (task_id, chat_id, message_id, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(taskId, groupId, msgId, toIsoSql(new Date()));
|
||||
|
||||
const r1 = TaskService.completeTask(taskId, '600111222');
|
||||
const r2 = TaskService.completeTask(taskId, '600111222');
|
||||
expect(r1.status === 'updated' || r1.status === 'already').toBe(true);
|
||||
expect(r2.status === 'updated' || r2.status === 'already').toBe(true);
|
||||
|
||||
const rows = memdb.query(`SELECT metadata FROM response_queue`).all() as any[];
|
||||
expect(rows.length).toBe(1);
|
||||
const meta = JSON.parse(String(rows[0].metadata || '{}'));
|
||||
expect(meta.emoji).toBe('✅');
|
||||
});
|
||||
|
||||
it('enforce: grupo no allowed → no encola ✅', async () => {
|
||||
const groupId = 'grp-4@g.us';
|
||||
// Estado por defecto 'pending' (no allowed)
|
||||
|
||||
const taskId = TaskService.createTask({
|
||||
description: 'No allowed',
|
||||
due_date: null,
|
||||
group_id: groupId,
|
||||
created_by: '600111222'
|
||||
});
|
||||
|
||||
const msgId = 'MSG-NO-ALLOW-1';
|
||||
memdb.prepare(`
|
||||
INSERT INTO task_origins (task_id, chat_id, message_id, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(taskId, groupId, msgId, toIsoSql(new Date()));
|
||||
|
||||
const res = TaskService.completeTask(taskId, '600111222');
|
||||
expect(res.status === 'updated' || res.status === 'already').toBe(true);
|
||||
|
||||
const cnt = memdb.prepare(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
|
||||
expect(Number(cnt.c)).toBe(0);
|
||||
});
|
||||
});
|
||||
@ -1,194 +0,0 @@
|
||||
import { beforeEach, afterEach, describe, expect, it } from 'bun:test';
|
||||
import { createTempDb } from './helpers/db';
|
||||
// Los imports del handler y closeDb se hacen dinámicos dentro de cada test/teardown
|
||||
|
||||
function toIsoSql(d: Date): string {
|
||||
return d.toISOString().replace('T', ' ').replace('Z', '');
|
||||
}
|
||||
|
||||
describe('Web API - completar tarea encola reacción ✅', () => {
|
||||
let cleanup: () => void;
|
||||
let db: any;
|
||||
let path: string;
|
||||
|
||||
const USER = '34600123456';
|
||||
const GROUP_ID = '12345-67890@g.us';
|
||||
|
||||
beforeEach(() => {
|
||||
const tmp = createTempDb();
|
||||
cleanup = tmp.cleanup;
|
||||
db = tmp.db;
|
||||
path = tmp.path;
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.DB_PATH = path;
|
||||
process.env.REACTIONS_ENABLED = 'true';
|
||||
process.env.REACTIONS_SCOPE = 'groups';
|
||||
process.env.REACTIONS_TTL_DAYS = '14';
|
||||
process.env.GROUP_GATING_MODE = 'enforce';
|
||||
|
||||
// Sembrar usuario y grupo permitido + membresía activa
|
||||
db.prepare(`INSERT OR IGNORE INTO users (id) VALUES (?)`).run(USER);
|
||||
db.prepare(`INSERT OR IGNORE INTO groups (id, community_id, name, active) VALUES (?, 'comm-1', 'Group', 1)`).run(GROUP_ID);
|
||||
db.prepare(`INSERT OR REPLACE INTO allowed_groups (group_id, label, status) VALUES (?, 'Test', 'allowed')`).run(GROUP_ID);
|
||||
db.prepare(`INSERT OR REPLACE INTO group_members (group_id, user_id, is_admin, is_active) VALUES (?, ?, 0, 1)`).run(GROUP_ID, USER);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Cerrar la conexión singleton de la web antes de borrar el archivo
|
||||
try {
|
||||
const { closeDb } = await import('../../apps/web/src/lib/server/db.ts');
|
||||
closeDb();
|
||||
} catch {}
|
||||
if (cleanup) cleanup();
|
||||
// Limpiar env relevantes
|
||||
delete process.env.DB_PATH;
|
||||
delete process.env.REACTIONS_ENABLED;
|
||||
delete process.env.REACTIONS_SCOPE;
|
||||
delete process.env.REACTIONS_TTL_DAYS;
|
||||
delete process.env.GROUP_GATING_MODE;
|
||||
});
|
||||
|
||||
it('caso feliz: encola 1 reacción ✅ con metadata canónica', async () => {
|
||||
// Crear tarea en grupo (no completada)
|
||||
const ins = db.prepare(`
|
||||
INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at)
|
||||
VALUES ('Probar reacción', NULL, ?, ?, 0, NULL)
|
||||
`).run(GROUP_ID, USER) as any;
|
||||
const taskId = Number(ins.lastInsertRowid);
|
||||
|
||||
// Origen reciente con participant y from_me=1
|
||||
const messageId = 'MSG-abc-123';
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO task_origins (task_id, chat_id, message_id, created_at, participant, from_me)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(taskId, GROUP_ID, messageId, toIsoSql(new Date()), `${USER}@s.whatsapp.net`, 1);
|
||||
|
||||
// Ejecutar endpoint
|
||||
const event: any = {
|
||||
locals: { userId: USER },
|
||||
params: { id: String(taskId) },
|
||||
request: new Request('http://localhost', { method: 'POST' })
|
||||
};
|
||||
const { POST: completeHandler } = await import('../../apps/web/src/routes/api/tasks/[id]/complete/+server.ts');
|
||||
const res = await completeHandler(event);
|
||||
expect(res.status).toBe(200);
|
||||
const payload = await res.json();
|
||||
expect(payload.status).toBe('updated');
|
||||
|
||||
// Verificar encolado
|
||||
const row = db.prepare(`SELECT recipient, message, metadata FROM response_queue ORDER BY id DESC LIMIT 1`).get() as any;
|
||||
expect(row).toBeTruthy();
|
||||
expect(String(row.recipient)).toBe(GROUP_ID);
|
||||
expect(String(row.message)).toBe('');
|
||||
|
||||
const meta = JSON.parse(String(row.metadata || '{}'));
|
||||
expect(meta).toEqual({
|
||||
kind: 'reaction',
|
||||
emoji: '✅',
|
||||
chatId: GROUP_ID,
|
||||
messageId,
|
||||
fromMe: true,
|
||||
participant: `${USER}@s.whatsapp.net`
|
||||
});
|
||||
|
||||
// Idempotencia del endpoint: segunda llamada no crea nuevo job
|
||||
const res2 = await completeHandler(event);
|
||||
expect(res2.status).toBe(200);
|
||||
const body2 = await res2.json();
|
||||
expect(body2.status).toBe('already');
|
||||
|
||||
const cnt = db.prepare(`SELECT COUNT(*) AS c FROM response_queue WHERE metadata = ?`).get(JSON.stringify(meta)) as any;
|
||||
expect(Number(cnt.c || 0)).toBe(1);
|
||||
});
|
||||
|
||||
it('TTL vencido: no encola reacción', async () => {
|
||||
const ins = db.prepare(`
|
||||
INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at)
|
||||
VALUES ('Vieja', NULL, ?, ?, 0, NULL)
|
||||
`).run(GROUP_ID, USER) as any;
|
||||
const taskId = Number(ins.lastInsertRowid);
|
||||
|
||||
const messageId = 'MSG-old-001';
|
||||
const old = new Date(Date.now() - 20 * 24 * 60 * 60 * 1000); // 20 días
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO task_origins (task_id, chat_id, message_id, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(taskId, GROUP_ID, messageId, toIsoSql(old));
|
||||
|
||||
const event: any = {
|
||||
locals: { userId: USER },
|
||||
params: { id: String(taskId) },
|
||||
request: new Request('http://localhost', { method: 'POST' })
|
||||
};
|
||||
const { POST: completeHandler } = await import('../../apps/web/src/routes/api/tasks/[id]/complete/+server.ts');
|
||||
const res = await completeHandler(event);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.status).toBe('updated');
|
||||
|
||||
const cnt = db.prepare(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
|
||||
expect(Number(cnt.c || 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('scope=groups: origen DM no encola', async () => {
|
||||
const ins = db.prepare(`
|
||||
INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at)
|
||||
VALUES ('DM scope', NULL, ?, ?, 0, NULL)
|
||||
`).run(GROUP_ID, USER) as any;
|
||||
const taskId = Number(ins.lastInsertRowid);
|
||||
|
||||
const messageId = 'MSG-dm-001';
|
||||
const dmChat = `${USER}@s.whatsapp.net`; // no @g.us
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO task_origins (task_id, chat_id, message_id, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(taskId, dmChat, messageId, toIsoSql(new Date()));
|
||||
|
||||
const event: any = {
|
||||
locals: { userId: USER },
|
||||
params: { id: String(taskId) },
|
||||
request: new Request('http://localhost', { method: 'POST' })
|
||||
};
|
||||
const { POST: completeHandler } = await import('../../apps/web/src/routes/api/tasks/[id]/complete/+server.ts');
|
||||
const res = await completeHandler(event);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const cnt = db.prepare(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
|
||||
expect(Number(cnt.c || 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('sin participant/from_me: metadata no incluye claves opcionales', async () => {
|
||||
const ins = db.prepare(`
|
||||
INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at)
|
||||
VALUES ('Sin opcionales', NULL, ?, ?, 0, NULL)
|
||||
`).run(GROUP_ID, USER) as any;
|
||||
const taskId = Number(ins.lastInsertRowid);
|
||||
|
||||
const messageId = 'MSG-nopts-001';
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO task_origins (task_id, chat_id, message_id, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(taskId, GROUP_ID, messageId, toIsoSql(new Date()));
|
||||
|
||||
const event: any = {
|
||||
locals: { userId: USER },
|
||||
params: { id: String(taskId) },
|
||||
request: new Request('http://localhost', { method: 'POST' })
|
||||
};
|
||||
const { POST: completeHandler } = await import('../../apps/web/src/routes/api/tasks/[id]/complete/+server.ts');
|
||||
const res = await completeHandler(event);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = db.prepare(`SELECT metadata FROM response_queue ORDER BY id DESC LIMIT 1`).get() as any;
|
||||
const meta = JSON.parse(String(row.metadata || '{}'));
|
||||
expect(meta).toEqual({
|
||||
kind: 'reaction',
|
||||
emoji: '✅',
|
||||
chatId: GROUP_ID,
|
||||
messageId
|
||||
});
|
||||
expect('fromMe' in meta).toBe(false);
|
||||
expect('participant' in meta).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue