|
|
import type { Database } from 'bun:sqlite';
|
|
|
import { db, ensureUserExists } from '../db';
|
|
|
import { normalizeWhatsAppId, isGroupId } from '../utils/whatsapp';
|
|
|
import { TaskService } from '../tasks/service';
|
|
|
import { GroupSyncService } from './group-sync';
|
|
|
import { ContactsService } from './contacts';
|
|
|
import { ICONS } from '../utils/icons';
|
|
|
import { padTaskId, codeId, formatDDMM, bold, italic, code, section } from '../utils/formatting';
|
|
|
import { getQuickHelp, getFullHelp } from './messages/help';
|
|
|
import { IdentityService } from './identity';
|
|
|
import { AllowedGroups } from './allowed-groups';
|
|
|
import { Metrics } from './metrics';
|
|
|
import { ResponseQueue } from './response-queue';
|
|
|
import { randomTokenBase64Url, sha256Hex } from '../utils/crypto';
|
|
|
|
|
|
type CommandContext = {
|
|
|
sender: string; // normalized user id (digits only), but accept raw too
|
|
|
groupId: string; // full JID (e.g., xxx@g.us)
|
|
|
message: string; // raw message text
|
|
|
mentions: string[]; // array of raw JIDs mentioned
|
|
|
messageId?: string; // id del mensaje origen (para task_origins y reacciones)
|
|
|
participant?: string; // JID del autor del mensaje origen (en grupos)
|
|
|
fromMe?: boolean; // si el mensaje origen fue enviado por la instancia
|
|
|
};
|
|
|
|
|
|
export type CommandResponse = {
|
|
|
recipient: string;
|
|
|
message: string;
|
|
|
mentions?: string[]; // full JIDs to mention in the outgoing message
|
|
|
};
|
|
|
|
|
|
export type CommandOutcome = {
|
|
|
responses: CommandResponse[];
|
|
|
ok: boolean;
|
|
|
createdTaskIds?: number[];
|
|
|
};
|
|
|
|
|
|
export class CommandService {
|
|
|
static dbInstance: Database = db;
|
|
|
private static readonly CTA_HELP: string = 'ℹ️ Tus tareas: /t mias · Todas: /t todas · Info: /t info · Web: /t web';
|
|
|
|
|
|
private static parseNueva(message: string, mentionsNormalized: string[]): {
|
|
|
action: string;
|
|
|
description: string;
|
|
|
dueDate: string | null;
|
|
|
} {
|
|
|
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>();
|
|
|
|
|
|
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;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
const token = parts[i];
|
|
|
if (isMentionToken(token)) continue;
|
|
|
descriptionTokens.push(token);
|
|
|
}
|
|
|
|
|
|
const description = descriptionTokens.join(' ').trim();
|
|
|
|
|
|
return { action, description, dueDate };
|
|
|
}
|
|
|
|
|
|
private static resolveTaskIdFromInput(n: number): number | null {
|
|
|
const byCode = TaskService.getActiveTaskByDisplayCode(n);
|
|
|
return byCode ? byCode.id : null;
|
|
|
}
|
|
|
|
|
|
private static async processTareaCommand(
|
|
|
context: CommandContext
|
|
|
): Promise<CommandResponse[]> {
|
|
|
const trimmed = (context.message || '').trim();
|
|
|
const tokens = trimmed.split(/\s+/);
|
|
|
const rawAction = (tokens[1] || '').toLowerCase();
|
|
|
const ACTION_ALIASES: Record<string, string> = {
|
|
|
'n': 'nueva',
|
|
|
'nueva': 'nueva',
|
|
|
'crear': 'nueva',
|
|
|
'+': 'nueva',
|
|
|
'ver': 'ver',
|
|
|
'mostrar': 'ver',
|
|
|
'listar': 'ver',
|
|
|
'ls': 'ver',
|
|
|
'mias': 'ver',
|
|
|
'mías': 'ver',
|
|
|
'todas': 'ver',
|
|
|
'todos': 'ver',
|
|
|
'x': 'completar',
|
|
|
'hecho': 'completar',
|
|
|
'completar': 'completar',
|
|
|
'done': 'completar',
|
|
|
'tomar': 'tomar',
|
|
|
'claim': 'tomar',
|
|
|
'asumir': 'tomar',
|
|
|
'asumo': 'tomar',
|
|
|
'soltar': 'soltar',
|
|
|
'unassign': 'soltar',
|
|
|
'dejar': 'soltar',
|
|
|
'liberar': 'soltar',
|
|
|
'renunciar': 'soltar',
|
|
|
'ayuda': 'ayuda',
|
|
|
'help': 'ayuda',
|
|
|
'info': 'ayuda',
|
|
|
'?': 'ayuda',
|
|
|
'config': 'configurar',
|
|
|
'configurar': 'configurar'
|
|
|
};
|
|
|
const action = ACTION_ALIASES[rawAction] || rawAction;
|
|
|
// Métrica: uso de alias (info/mias/todas)
|
|
|
try {
|
|
|
if (rawAction === 'info') {
|
|
|
Metrics.inc('commands_alias_used_total', 1, { action: 'info' });
|
|
|
} else 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 {}
|
|
|
// Refrescar métricas agregadas de onboarding tras cualquier comando (para conversión)
|
|
|
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
|
|
|
|
|
|
// Usar formatDDMM desde utils/formatting
|
|
|
|
|
|
// TZ y "hoy" en TZ para marcar vencidas en listados
|
|
|
const TZ = process.env.TZ && process.env.TZ.trim() ? process.env.TZ : 'Europe/Madrid';
|
|
|
const ymdInTZ = (d: Date): string => {
|
|
|
const parts = new Intl.DateTimeFormat('en-GB', {
|
|
|
timeZone: TZ,
|
|
|
year: 'numeric',
|
|
|
month: '2-digit',
|
|
|
day: '2-digit',
|
|
|
}).formatToParts(d);
|
|
|
const get = (t: string) => parts.find(p => p.type === t)?.value || '';
|
|
|
return `${get('year')}-${get('month')}-${get('day')}`;
|
|
|
};
|
|
|
const todayYMD = ymdInTZ(new Date());
|
|
|
|
|
|
if (!action || action === 'ayuda') {
|
|
|
const feature = String(process.env.FEATURE_HELP_V2 ?? 'true').toLowerCase();
|
|
|
const helpV2Enabled = !['false', '0', 'no'].includes(feature);
|
|
|
|
|
|
const isAdvanced = (tokens[2] || '').toLowerCase() === 'avanzada';
|
|
|
|
|
|
// Fallback legacy (Help v1)
|
|
|
if (!helpV2Enabled) {
|
|
|
if (isAdvanced) {
|
|
|
const adv = [
|
|
|
'*Ayuda avanzada:*',
|
|
|
'Comandos y alias:',
|
|
|
' · Crear: `n`, `nueva`, `crear`, `+`',
|
|
|
' · Ver: `ver`, `mostrar`, `listar`, `ls` (opciones: `grupo` | `mis` | `todos` | `sin`)',
|
|
|
' · Completar: `x`, `hecho`, `completar`, `done` (acepta varios IDs: "`/t x 14 19 24`" o "`/t x 14,19,24`"; máximo 10)',
|
|
|
' · Tomar: `tomar`, `claim` (acepta varios IDs: "`/t tomar 12 19 50`" o "`/t tomar 12,19,50`"; máximo 10)',
|
|
|
' · Soltar: `soltar`, `unassign`',
|
|
|
'Preferencias:',
|
|
|
' · `/t configurar diario|l-v|semanal|off [HH:MM]` (por defecto _08:30_; semanal: _lunes_; l-v: lunes a viernes)',
|
|
|
'Notas:',
|
|
|
' · En grupos, el bot responde por DM (no publica en el grupo).',
|
|
|
' · Si creas una tarea en un grupo y no mencionas a nadie → “sin responsable”; en DM → se asigna a quien la crea.',
|
|
|
' · Fechas dd/MM con ⚠️ si está vencida.',
|
|
|
' · Mostramos los IDs de las tareas con 4 dígitos, pero puedes escribirlos sin ceros (p. ej., 26).',
|
|
|
].join('\n');
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: adv
|
|
|
}];
|
|
|
}
|
|
|
const help = [
|
|
|
'Guía rápida:',
|
|
|
'- Crear: `/t n Descripción 2028-11-26 @Ana`',
|
|
|
'- Ver grupo: `/t ver` (en el grupo)',
|
|
|
'- Ver mis tareas: `/t ver mis` (por DM)',
|
|
|
'- Ver todas: `/t ver todas` (por DM)',
|
|
|
'- Completar: `/t x 123` (máx. 10)',
|
|
|
'- Tomar: `/t tomar 12` (máx. 10)',
|
|
|
'- Configurar recordatorios: `/t configurar diario|l-v|semanal|off [HH:MM]`',
|
|
|
'- Ayuda avanzada: `/t ayuda avanzada`'
|
|
|
].join('\n');
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: help
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
// Help v2
|
|
|
if (isAdvanced) {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: getFullHelp()
|
|
|
}];
|
|
|
}
|
|
|
const quick = getQuickHelp();
|
|
|
const msg = [quick, '', `Ayuda avanzada: ${code('/t ayuda avanzada')}`].join('\n');
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: msg
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
// Listar pendientes
|
|
|
if (action === 'ver') {
|
|
|
const scopeRaw = (tokens[2] || '').toLowerCase();
|
|
|
const SCOPE_ALIASES: Record<string, 'mis' | 'todos'> = {
|
|
|
'todo': 'todos',
|
|
|
'todos': 'todos',
|
|
|
'todas': 'todos',
|
|
|
'mis': 'mis',
|
|
|
'mias': 'mis',
|
|
|
'mías': 'mis',
|
|
|
'yo': 'mis',
|
|
|
};
|
|
|
const scope = scopeRaw
|
|
|
? (SCOPE_ALIASES[scopeRaw] || scopeRaw)
|
|
|
: ((rawAction === 'mias' || rawAction === 'mías') ? 'mis' : ((rawAction === 'todas' || rawAction === 'todos') ? 'todos' : 'todos'));
|
|
|
const LIMIT = 10;
|
|
|
|
|
|
// En grupos: no listamos; responder por DM con transición
|
|
|
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'
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
|
|
|
// Ver todos: "tus tareas" + "sin responsable" de grupos donde eres miembro activo (snapshot fresca)
|
|
|
if (scope === 'todos') {
|
|
|
const sections: string[] = [];
|
|
|
|
|
|
// Encabezado fijo para la sección de tareas del usuario
|
|
|
sections.push(bold('Tus tareas'));
|
|
|
|
|
|
// Tus tareas (mis)
|
|
|
const myItems = TaskService.listUserPending(context.sender, LIMIT);
|
|
|
if (myItems.length > 0) {
|
|
|
// Agrupar por grupo como en "ver mis"
|
|
|
const byGroup = new Map<string, typeof myItems>();
|
|
|
for (const t of myItems) {
|
|
|
const key = t.group_id || '(sin grupo)';
|
|
|
const arr = byGroup.get(key) || [];
|
|
|
arr.push(t);
|
|
|
byGroup.set(key, arr);
|
|
|
}
|
|
|
|
|
|
for (const [groupId, arr] of byGroup.entries()) {
|
|
|
const groupName =
|
|
|
(groupId && GroupSyncService.activeGroupsCache.get(groupId)) ||
|
|
|
(groupId && groupId !== '(sin grupo)' ? groupId : 'Sin grupo');
|
|
|
|
|
|
sections.push(groupName);
|
|
|
const rendered = await Promise.all(arr.map(async (t) => {
|
|
|
const names = await Promise.all(
|
|
|
(t.assignees || []).map(async (uid) => (await ContactsService.getDisplayName(uid)) || uid)
|
|
|
);
|
|
|
const owner =
|
|
|
(t.assignees?.length || 0) === 0
|
|
|
? `${ICONS.unassigned} sin responsable`
|
|
|
: `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`;
|
|
|
const isOverdue = t.due_date ? t.due_date < todayYMD : false;
|
|
|
const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : '';
|
|
|
return `- ${codeId(t.id, t.display_code)} ${t.description || '(sin descripción)'}${datePart} — ${owner}`;
|
|
|
}));
|
|
|
sections.push(...rendered);
|
|
|
sections.push('');
|
|
|
}
|
|
|
|
|
|
// Quitar línea en blanco final si procede
|
|
|
if (sections.length > 0 && sections[sections.length - 1] === '') {
|
|
|
sections.pop();
|
|
|
}
|
|
|
|
|
|
const totalMy = TaskService.countUserPending(context.sender);
|
|
|
if (totalMy > myItems.length) {
|
|
|
sections.push(`… y ${totalMy - myItems.length} más`);
|
|
|
}
|
|
|
} else {
|
|
|
sections.push(italic('_No tienes tareas pendientes._'));
|
|
|
}
|
|
|
|
|
|
// En contexto de grupo: mantener compatibilidad mostrando solo "sin responsable" del grupo actual
|
|
|
if (isGroupId(context.groupId)) {
|
|
|
if (!GroupSyncService.isGroupActive(context.groupId)) {
|
|
|
sections.push('⚠️ _Este grupo no está activo._');
|
|
|
} else {
|
|
|
const groupName = GroupSyncService.activeGroupsCache.get(context.groupId) || context.groupId;
|
|
|
const unassigned = TaskService.listGroupUnassigned(context.groupId, LIMIT);
|
|
|
if (unassigned.length > 0) {
|
|
|
if (sections.length && sections[sections.length - 1] !== '') sections.push('');
|
|
|
sections.push(`${groupName} — Sin responsable`);
|
|
|
const renderedUnassigned = unassigned.map((t) => {
|
|
|
const isOverdue = t.due_date ? t.due_date < todayYMD : false;
|
|
|
const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : '';
|
|
|
return `- ${codeId(t.id, t.display_code)} ${t.description || '(sin descripción)'}${datePart} — ${ICONS.unassigned}`;
|
|
|
});
|
|
|
sections.push(...renderedUnassigned);
|
|
|
|
|
|
const totalUnassigned = TaskService.countGroupUnassigned(context.groupId);
|
|
|
if (totalUnassigned > unassigned.length) {
|
|
|
sections.push(`… y ${totalUnassigned - unassigned.length} más`);
|
|
|
}
|
|
|
} else {
|
|
|
sections.push(`${groupName} — Sin responsable\n_(no hay tareas sin responsable)_`);
|
|
|
}
|
|
|
}
|
|
|
} else {
|
|
|
// En DM: usar membresía real (snapshot fresca) para incluir "sin responsable" por grupo
|
|
|
const memberGroups = GroupSyncService.getFreshMemberGroupsForUser(context.sender);
|
|
|
if (memberGroups.length > 0) {
|
|
|
const perGroup = TaskService.listUnassignedByGroups(memberGroups, LIMIT);
|
|
|
for (const gid of perGroup.keys()) {
|
|
|
const unassigned = perGroup.get(gid)!;
|
|
|
const groupName =
|
|
|
(gid && GroupSyncService.activeGroupsCache.get(gid)) ||
|
|
|
gid;
|
|
|
|
|
|
if (unassigned.length > 0) {
|
|
|
if (sections.length && sections[sections.length - 1] !== '') sections.push('');
|
|
|
sections.push(`${groupName} — Sin responsable`);
|
|
|
const renderedUnassigned = unassigned.map((t) => {
|
|
|
const isOverdue = t.due_date ? t.due_date < todayYMD : false;
|
|
|
const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : '';
|
|
|
return `- ${codeId(t.id, t.display_code)} ${t.description || '(sin descripción)'}${datePart} — ${ICONS.unassigned}`;
|
|
|
});
|
|
|
sections.push(...renderedUnassigned);
|
|
|
|
|
|
const totalUnassigned = TaskService.countGroupUnassigned(gid);
|
|
|
if (totalUnassigned > unassigned.length) {
|
|
|
sections.push(`… y ${totalUnassigned - unassigned.length} más`);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
} else {
|
|
|
// Si no hay snapshot fresca de membresía, mantenemos una nota instructiva mínima
|
|
|
sections.push('ℹ️ Para ver tareas sin responsable, escribe por privado `/t todas` o usa `/t web`.');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: sections.join('\n')
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
|
|
|
// Ver mis
|
|
|
const items = TaskService.listUserPending(context.sender, LIMIT);
|
|
|
if (items.length === 0) {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: italic('No tienes tareas pendientes.')
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
const total = TaskService.countUserPending(context.sender);
|
|
|
|
|
|
// Agrupar por grupo
|
|
|
const byGroup = new Map<string, typeof items>();
|
|
|
for (const t of items) {
|
|
|
const key = t.group_id || '(sin grupo)';
|
|
|
const arr = byGroup.get(key) || [];
|
|
|
arr.push(t);
|
|
|
byGroup.set(key, arr);
|
|
|
}
|
|
|
|
|
|
const sections: string[] = [bold('Tus tareas')];
|
|
|
for (const [groupId, arr] of byGroup.entries()) {
|
|
|
const groupName =
|
|
|
(groupId && GroupSyncService.activeGroupsCache.get(groupId)) ||
|
|
|
(groupId && groupId !== '(sin grupo)' ? groupId : 'Sin grupo');
|
|
|
|
|
|
sections.push(groupName);
|
|
|
const rendered = await Promise.all(arr.map(async (t) => {
|
|
|
const names = await Promise.all(
|
|
|
(t.assignees || []).map(async (uid) => (await ContactsService.getDisplayName(uid)) || uid)
|
|
|
);
|
|
|
const owner =
|
|
|
(t.assignees?.length || 0) === 0
|
|
|
? `${ICONS.unassigned}`
|
|
|
: `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`;
|
|
|
const isOverdue = t.due_date ? t.due_date < todayYMD : false;
|
|
|
const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : '';
|
|
|
return `- ${codeId(t.id, t.display_code)} ${t.description || '(sin descripción)'}${datePart} — ${owner}`;
|
|
|
}));
|
|
|
sections.push(...rendered);
|
|
|
sections.push('');
|
|
|
}
|
|
|
|
|
|
// Quitar línea en blanco final si procede
|
|
|
if (sections.length > 0 && sections[sections.length - 1] === '') {
|
|
|
sections.pop();
|
|
|
}
|
|
|
|
|
|
if (total > items.length) {
|
|
|
sections.push(`… y ${total - items.length} más`);
|
|
|
}
|
|
|
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: sections.join('\n')
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
// Completar tarea (con validación opcional de membresía)
|
|
|
if (action === 'completar') {
|
|
|
// Soportar múltiples IDs separados por espacios y/o comas
|
|
|
const rawIds = (tokens.slice(2).join(' ') || '').trim();
|
|
|
const parsedList = Array.from(new Set(
|
|
|
rawIds
|
|
|
.split(/[,\s]+/)
|
|
|
.map(t => t.trim())
|
|
|
.filter(Boolean)
|
|
|
.map(t => parseInt(t, 10))
|
|
|
.filter(n => Number.isFinite(n) && n > 0)
|
|
|
));
|
|
|
const MAX_IDS = 10;
|
|
|
const truncated = parsedList.length > MAX_IDS;
|
|
|
const ids = parsedList.slice(0, MAX_IDS);
|
|
|
|
|
|
// Sin IDs: ayuda de uso
|
|
|
if (ids.length === 0) {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: 'ℹ️ Uso: `/t x 26` o múltiples: `/t x 14 19 24` o `/t x 14,19,24` (máx. 10)'
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
// Caso de 1 ID: mantener comportamiento actual
|
|
|
if (ids.length === 1) {
|
|
|
const idInput = ids[0];
|
|
|
const resolvedId = this.resolveTaskIdFromInput(idInput);
|
|
|
if (!resolvedId) {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: `⚠️ Tarea ${codeId(idInput)} no encontrada.`
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
const task = TaskService.getTaskById(resolvedId);
|
|
|
if (!task) {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.`
|
|
|
}];
|
|
|
}
|
|
|
const enforce = String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true';
|
|
|
if (task && task.group_id && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: 'No puedes completar esta tarea porque no eres de este grupo.'
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
const res = TaskService.completeTask(resolvedId, context.sender);
|
|
|
const who = (await ContactsService.getDisplayName(context.sender)) || context.sender;
|
|
|
if (res.status === 'not_found') {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.`
|
|
|
}];
|
|
|
}
|
|
|
if (res.status === 'already') {
|
|
|
const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '';
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} _Ya estaba completada_ — ${res.task?.description || '(sin descripción)'}${due}`
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '';
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: `${ICONS.complete} ${codeId(resolvedId, res.task?.display_code)} _completada_ — ${res.task?.description || '(sin descripción)'}${due}`
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
// Modo múltiple
|
|
|
const enforce = String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true';
|
|
|
let cntUpdated = 0, cntAlready = 0, cntNotFound = 0, cntBlocked = 0;
|
|
|
const lines: string[] = [];
|
|
|
|
|
|
if (truncated) {
|
|
|
lines.push('⚠️ Se procesarán solo los primeros 10 IDs.');
|
|
|
}
|
|
|
|
|
|
for (const idInput of ids) {
|
|
|
const resolvedId = this.resolveTaskIdFromInput(idInput);
|
|
|
if (!resolvedId) {
|
|
|
lines.push(`⚠️ ${codeId(idInput)} no encontrada.`);
|
|
|
cntNotFound++;
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
const task = TaskService.getTaskById(resolvedId);
|
|
|
if (task && task.group_id && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) {
|
|
|
lines.push(`🚫 ${codeId(resolvedId)} — no permitido (no eres miembro activo).`);
|
|
|
cntBlocked++;
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
const res = TaskService.completeTask(resolvedId, context.sender);
|
|
|
const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '';
|
|
|
if (res.status === 'already') {
|
|
|
lines.push(`ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}`);
|
|
|
cntAlready++;
|
|
|
} else if (res.status === 'updated') {
|
|
|
lines.push(`${ICONS.complete} ${codeId(resolvedId, res.task?.display_code)} completada — ${res.task?.description || '(sin descripción)'}${due}`);
|
|
|
cntUpdated++;
|
|
|
} else if (res.status === 'not_found') {
|
|
|
lines.push(`⚠️ ${codeId(resolvedId)} no encontrada.`);
|
|
|
cntNotFound++;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Resumen final
|
|
|
const summary: string[] = [];
|
|
|
if (cntUpdated) summary.push(`completadas ${cntUpdated}`);
|
|
|
if (cntAlready) summary.push(`ya estaban ${cntAlready}`);
|
|
|
if (cntNotFound) summary.push(`no encontradas ${cntNotFound}`);
|
|
|
if (cntBlocked) summary.push(`bloqueadas ${cntBlocked}`);
|
|
|
if (summary.length) {
|
|
|
lines.push('');
|
|
|
lines.push(`Resumen: ${summary.join(', ')}.`);
|
|
|
}
|
|
|
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: lines.join('\n')
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
// Tomar tarea (con validación opcional de membresía)
|
|
|
if (action === 'tomar') {
|
|
|
// Soportar múltiples IDs separados por espacios y/o comas
|
|
|
const rawIds = (tokens.slice(2).join(' ') || '').trim();
|
|
|
const parsedList = Array.from(new Set(
|
|
|
rawIds
|
|
|
.split(/[,\s]+/)
|
|
|
.map(t => t.trim())
|
|
|
.filter(Boolean)
|
|
|
.map(t => parseInt(t, 10))
|
|
|
.filter(n => Number.isFinite(n) && n > 0)
|
|
|
));
|
|
|
const MAX_IDS = 10;
|
|
|
const truncated = parsedList.length > MAX_IDS;
|
|
|
const ids = parsedList.slice(0, MAX_IDS);
|
|
|
|
|
|
// Sin IDs: ayuda de uso
|
|
|
if (ids.length === 0) {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: 'ℹ️ Uso: `/t tomar 26` o múltiples: `/t tomar 12 19 50` o `/t tomar 12,19,50` (máx. 10)'
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
// Caso de 1 ID: mantener comportamiento actual
|
|
|
if (ids.length === 1) {
|
|
|
const idInput = ids[0];
|
|
|
|
|
|
const resolvedId = this.resolveTaskIdFromInput(idInput);
|
|
|
if (!resolvedId) {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: `⚠️ Tarea ${codeId(idInput)} no encontrada.`
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
const task = TaskService.getTaskById(resolvedId);
|
|
|
if (!task) {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.`
|
|
|
}];
|
|
|
}
|
|
|
const enforce = String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true';
|
|
|
if (task.group_id && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: 'No puedes tomar esta tarea porque no eres de este grupo.'
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
const res = TaskService.claimTask(resolvedId, context.sender);
|
|
|
const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '';
|
|
|
|
|
|
if (res.status === 'not_found') {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.`
|
|
|
}];
|
|
|
}
|
|
|
if (res.status === 'completed') {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}`
|
|
|
}];
|
|
|
}
|
|
|
if (res.status === 'already') {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya la tenías — ${res.task?.description || '(sin descripción)'}${due}`
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
const lines = [
|
|
|
italic(`${ICONS.take} Has tomado ${codeId(resolvedId, res.task?.display_code)}`),
|
|
|
`${res.task?.description || '(sin descripción)'}`,
|
|
|
res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''
|
|
|
].filter(Boolean);
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: lines.join('\n')
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
// Modo múltiple
|
|
|
const enforce = String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true';
|
|
|
let cntClaimed = 0, cntAlready = 0, cntCompleted = 0, cntNotFound = 0, cntBlocked = 0;
|
|
|
const lines: string[] = [];
|
|
|
|
|
|
if (truncated) {
|
|
|
lines.push('⚠️ Se procesarán solo los primeros 10 IDs.');
|
|
|
}
|
|
|
|
|
|
for (const idInput of ids) {
|
|
|
const resolvedId = this.resolveTaskIdFromInput(idInput);
|
|
|
if (!resolvedId) {
|
|
|
lines.push(`⚠️ ${codeId(idInput)} no encontrada.`);
|
|
|
cntNotFound++;
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
const task = TaskService.getTaskById(resolvedId);
|
|
|
if (task && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) {
|
|
|
lines.push(`🚫 ${codeId(resolvedId)} — no permitido (no eres miembro activo).`);
|
|
|
cntBlocked++;
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
const res = TaskService.claimTask(resolvedId, context.sender);
|
|
|
const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '';
|
|
|
if (res.status === 'already') {
|
|
|
lines.push(`ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya la tenías — ${res.task?.description || '(sin descripción)'}${due}`);
|
|
|
cntAlready++;
|
|
|
} else if (res.status === 'claimed') {
|
|
|
lines.push(`${ICONS.take} ${codeId(resolvedId, res.task?.display_code)} tomada — ${res.task?.description || '(sin descripción)'}${due}`);
|
|
|
cntClaimed++;
|
|
|
} else if (res.status === 'completed') {
|
|
|
lines.push(`ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}`);
|
|
|
cntCompleted++;
|
|
|
} else if (res.status === 'not_found') {
|
|
|
lines.push(`⚠️ ${codeId(resolvedId)} no encontrada.`);
|
|
|
cntNotFound++;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Resumen final
|
|
|
const summary: string[] = [];
|
|
|
if (cntClaimed) summary.push(`tomadas ${cntClaimed}`);
|
|
|
if (cntAlready) summary.push(`ya las tenías ${cntAlready}`);
|
|
|
if (cntCompleted) summary.push(`ya completadas ${cntCompleted}`);
|
|
|
if (cntNotFound) summary.push(`no encontradas ${cntNotFound}`);
|
|
|
if (cntBlocked) summary.push(`bloqueadas ${cntBlocked}`);
|
|
|
if (summary.length) {
|
|
|
lines.push('');
|
|
|
lines.push(`Resumen: ${summary.join(', ')}.`);
|
|
|
}
|
|
|
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: lines.join('\n')
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
// Soltar tarea (con validación opcional de membresía)
|
|
|
if (action === 'soltar') {
|
|
|
const idToken = tokens[2];
|
|
|
const idInput = idToken ? parseInt(idToken, 10) : NaN;
|
|
|
if (!idInput || Number.isNaN(idInput)) {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: 'ℹ️ Uso: `/t soltar 26`'
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
const resolvedId = this.resolveTaskIdFromInput(idInput);
|
|
|
if (!resolvedId) {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: `⚠️ Tarea ${codeId(idInput)} no encontrada.`
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
const task = TaskService.getTaskById(resolvedId);
|
|
|
if (!task) {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.`
|
|
|
}];
|
|
|
}
|
|
|
const enforce = String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true';
|
|
|
if (task.group_id && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: '⚠️ No puedes soltar esta tarea porque no eres de este grupo.'
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
const res = TaskService.unassignTask(resolvedId, context.sender);
|
|
|
const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '';
|
|
|
|
|
|
if (res.status === 'forbidden_personal') {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: '⚠️ No puedes soltar una tarea personal. Márcala como completada para eliminarla'
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
if (res.status === 'not_found') {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.`
|
|
|
}];
|
|
|
}
|
|
|
if (res.status === 'completed') {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}`
|
|
|
}];
|
|
|
}
|
|
|
if (res.status === 'not_assigned') {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} no la tenías asignada — ${res.task?.description || '(sin descripción)'}${due}`
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
if (res.now_unassigned) {
|
|
|
const lines = [
|
|
|
`${ICONS.unassigned} ${codeId(resolvedId, res.task?.display_code)}`,
|
|
|
`${res.task?.description || '(sin descripción)'}`,
|
|
|
res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '',
|
|
|
italic('queda sin responsable.')
|
|
|
].filter(Boolean);
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: lines.join('\n')
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
const lines = [
|
|
|
`${ICONS.unassign} ${codeId(resolvedId, res.task?.display_code)}`,
|
|
|
`${res.task?.description || '(sin descripción)'}`,
|
|
|
res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''
|
|
|
].filter(Boolean);
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: lines.join('\n')
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
if (action === 'configurar') {
|
|
|
const optRaw = (tokens[2] || '').toLowerCase();
|
|
|
const map: Record<string, 'daily' | 'weekly' | 'off' | 'weekdays'> = {
|
|
|
'daily': 'daily',
|
|
|
'diario': 'daily',
|
|
|
'diaria': 'daily',
|
|
|
'l-v': 'weekdays',
|
|
|
'lv': 'weekdays',
|
|
|
'laborables': 'weekdays',
|
|
|
'weekdays': 'weekdays',
|
|
|
'semanal': 'weekly',
|
|
|
'weekly': 'weekly',
|
|
|
'off': 'off',
|
|
|
'apagar': 'off',
|
|
|
'ninguno': 'off'
|
|
|
};
|
|
|
const freq = map[optRaw];
|
|
|
|
|
|
// Hora opcional HH:MM
|
|
|
const timeRaw = tokens[3] || '';
|
|
|
let timeNorm: string | null = null;
|
|
|
if (timeRaw) {
|
|
|
const m = /^(\d{1,2}):([0-5]\d)$/.exec(timeRaw);
|
|
|
if (!m) {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: 'ℹ️ Uso: `/t configurar diario|l-v|semanal|off [HH:MM]`'
|
|
|
}];
|
|
|
}
|
|
|
const hh = Math.max(0, Math.min(23, parseInt(m[1], 10)));
|
|
|
timeNorm = `${String(hh).padStart(2, '0')}:${m[2]}`;
|
|
|
}
|
|
|
|
|
|
if (!freq) {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: 'ℹ️ Uso: `/t configurar diario|l-v|semanal|off [HH:MM]`'
|
|
|
}];
|
|
|
}
|
|
|
const ensured = ensureUserExists(context.sender, this.dbInstance);
|
|
|
if (!ensured) {
|
|
|
throw new Error('No se pudo asegurar el usuario');
|
|
|
}
|
|
|
this.dbInstance.prepare(`
|
|
|
INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at)
|
|
|
VALUES (?, ?, COALESCE(?, COALESCE((SELECT reminder_time FROM user_preferences WHERE user_id = ?), '08:30')), NULL, strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
|
ON CONFLICT(user_id) DO UPDATE SET
|
|
|
reminder_freq = excluded.reminder_freq,
|
|
|
reminder_time = CASE WHEN ? IS NOT NULL THEN excluded.reminder_time ELSE reminder_time END,
|
|
|
updated_at = excluded.updated_at
|
|
|
`).run(ensured, freq, timeNorm, ensured, timeNorm);
|
|
|
|
|
|
let label: string;
|
|
|
if (freq === 'daily') {
|
|
|
label = timeNorm ? `diario (${timeNorm})` : 'diario';
|
|
|
} else if (freq === 'weekdays') {
|
|
|
label = timeNorm ? `laborables (lunes a viernes ${timeNorm})` : 'laborables (lunes a viernes)';
|
|
|
} else if (freq === 'weekly') {
|
|
|
label = timeNorm ? `semanal (lunes ${timeNorm})` : 'semanal (lunes 08:30)';
|
|
|
} else {
|
|
|
label = 'apagado';
|
|
|
}
|
|
|
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: `✅ Recordatorios: ${label}`
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
// Enlace de acceso a la web (/t web)
|
|
|
if (action === 'web') {
|
|
|
// Solo por DM
|
|
|
if (isGroupId(context.groupId)) {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: 'ℹ️ Este comando se usa por privado. Envíame `/t web` por DM.'
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
const base = (process.env.WEB_BASE_URL || '').trim();
|
|
|
if (!base) {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: '⚠️ La web no está configurada todavía. Contacta con el administrador (falta WEB_BASE_URL).'
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
const ensured = ensureUserExists(context.sender, this.dbInstance);
|
|
|
if (!ensured) {
|
|
|
throw new Error('No se pudo asegurar el usuario');
|
|
|
}
|
|
|
|
|
|
const toIso = (d: Date) => d.toISOString().replace('T', ' ').replace('Z', '');
|
|
|
const now = new Date();
|
|
|
const nowIso = toIso(now);
|
|
|
const expiresIso = toIso(new Date(now.getTime() + 10 * 60 * 1000)); // 10 minutos
|
|
|
|
|
|
// Invalidar tokens vigentes (uso único)
|
|
|
this.dbInstance.prepare(`
|
|
|
UPDATE web_tokens
|
|
|
SET used_at = ?
|
|
|
WHERE user_id = ?
|
|
|
AND used_at IS NULL
|
|
|
AND expires_at > ?
|
|
|
`).run(nowIso, ensured, nowIso);
|
|
|
|
|
|
// Generar nuevo token y guardar solo el hash
|
|
|
const token = randomTokenBase64Url(32);
|
|
|
const tokenHash = await sha256Hex(token);
|
|
|
|
|
|
this.dbInstance.prepare(`
|
|
|
INSERT INTO web_tokens (user_id, token_hash, expires_at, metadata)
|
|
|
VALUES (?, ?, ?, NULL)
|
|
|
`).run(ensured, tokenHash, expiresIso);
|
|
|
|
|
|
try { Metrics.inc('web_tokens_issued_total'); } catch { }
|
|
|
|
|
|
const url = new URL(`/login?token=${encodeURIComponent(token)}`, base).toString();
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: `Acceso web: ${url}\nVálido durante 10 minutos. Si caduca, vuelve a enviar "/t web".`
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
if (action !== 'nueva') {
|
|
|
const feature = String(process.env.FEATURE_HELP_V2 ?? 'true').toLowerCase();
|
|
|
const helpV2Enabled = !['false', '0', 'no'].includes(feature);
|
|
|
|
|
|
try { Metrics.inc('commands_unknown_total'); } catch { }
|
|
|
if (!helpV2Enabled) {
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: `Acción ${rawAction || '(vacía)'} no implementada aún`
|
|
|
}];
|
|
|
}
|
|
|
const header = `❓ ${section('Comando no reconocido')}`;
|
|
|
const cta = `Prueba ${code('/t ayuda')}`;
|
|
|
const help = getQuickHelp();
|
|
|
return [{
|
|
|
recipient: context.sender,
|
|
|
message: [header, cta, '', help].join('\n')
|
|
|
}];
|
|
|
}
|
|
|
|
|
|
// Parseo específico de "nueva"
|
|
|
// 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(/^\+/, ''));
|
|
|
const normalizedFromAtTokens = Array.from(new Set(
|
|
|
atTokenCandidates.map((v) => {
|
|
|
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
|
|
|
]));
|
|
|
|
|
|
if (process.env.NODE_ENV !== 'test') {
|
|
|
console.log('[A0] /t nueva menciones', {
|
|
|
context_mentions: context.mentions || [],
|
|
|
mentions_normalized: mentionsNormalizedFromContext,
|
|
|
at_tokens: atTokenCandidates,
|
|
|
at_normalized: normalizedFromAtTokens,
|
|
|
combined: combinedAssigneeCandidates
|
|
|
});
|
|
|
}
|
|
|
|
|
|
const { description, dueDate } = this.parseNueva(trimmed, mentionsNormalizedFromContext);
|
|
|
|
|
|
// Asegurar creador
|
|
|
const createdBy = ensureUserExists(context.sender, this.dbInstance);
|
|
|
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(
|
|
|
combinedAssigneeCandidates
|
|
|
.filter(id => !botNumber || id !== botNumber)
|
|
|
));
|
|
|
|
|
|
// Asegurar usuarios asignados
|
|
|
const ensuredAssignees = assigneesNormalized
|
|
|
.map(id => ensureUserExists(id, this.dbInstance))
|
|
|
.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 (Fase 1)
|
|
|
// Registrar interacción del usuario (last_command_at)
|
|
|
try {
|
|
|
const ensured = ensureUserExists(context.sender, this.dbInstance);
|
|
|
if (ensured) {
|
|
|
try {
|
|
|
this.dbInstance.prepare(`UPDATE users SET last_command_at = strftime('%Y-%m-%d %H:%M:%f','now') WHERE id = ?`).run(ensured);
|
|
|
} catch {}
|
|
|
}
|
|
|
} catch {}
|
|
|
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 {
|
|
|
this.dbInstance.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 {
|
|
|
this.dbInstance.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 creatorName = await ContactsService.getDisplayName(createdBy);
|
|
|
const creatorJid = `${createdBy}@s.whatsapp.net`;
|
|
|
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: CommandResponse[] = [];
|
|
|
|
|
|
// 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'), '', CommandService.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' + CommandService.CTA_HELP,
|
|
|
mentions: [creatorJid]
|
|
|
});
|
|
|
}
|
|
|
|
|
|
// A4: DM JIT al asignador si quedaron menciones/tokens irrecuperables
|
|
|
{
|
|
|
const unresolvedList = Array.from(new Set(unresolvedAssigneeDisplays.filter(Boolean)));
|
|
|
if (unresolvedList.length > 0) {
|
|
|
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 = isGroupId(context.groupId) ? String(context.groupId) : 'dm';
|
|
|
if (!enabled) {
|
|
|
try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupLabel, source: 'jit_assignee_failure', reason: 'disabled' }); } catch { }
|
|
|
} else {
|
|
|
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 { }
|
|
|
} else {
|
|
|
const list = unresolvedList.join(', ');
|
|
|
let groupCtx = '';
|
|
|
if (isGroupId(context.groupId)) {
|
|
|
const name = GroupSyncService.activeGroupsCache.get(context.groupId) || context.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 { }
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Fase 2: disparar paquete de onboarding (2 DMs) tras crear tarea en grupo
|
|
|
try {
|
|
|
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 = groupIdToUse || (isGroupId(context.groupId) ? context.groupId : null);
|
|
|
|
|
|
if (!enabled) {
|
|
|
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'disabled', group_id: String(gid || '') }); } catch {}
|
|
|
} else if (!gid) {
|
|
|
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'no_group', group_id: String(context.groupId || '') }); } catch {}
|
|
|
} else {
|
|
|
// Gating enforce
|
|
|
let allowed = true;
|
|
|
try {
|
|
|
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
|
|
|
if (mode === 'enforce') {
|
|
|
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {}
|
|
|
allowed = AllowedGroups.isAllowed(gid);
|
|
|
}
|
|
|
} catch {}
|
|
|
if (!allowed) {
|
|
|
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'not_allowed', group_id: String(gid) }); } catch {}
|
|
|
} else {
|
|
|
const displayCode = createdTask?.display_code;
|
|
|
if (!(typeof displayCode === 'number' && Number.isFinite(displayCode))) {
|
|
|
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'no_display_code', group_id: String(gid) }); } catch {}
|
|
|
} else {
|
|
|
// Candidatos
|
|
|
let members = GroupSyncService.listActiveMemberIds(gid);
|
|
|
const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim();
|
|
|
const exclude = new Set<string>([createdBy, ...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 {}
|
|
|
} else {
|
|
|
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); // 5–10s por defecto
|
|
|
|
|
|
const groupLabel = GroupSyncService.activeGroupsCache.get(gid) || gid;
|
|
|
const codeStr = String(displayCode);
|
|
|
const desc = (description || '(sin descripción)').trim();
|
|
|
const shortDesc = desc.length > 100 ? (desc.slice(0, 100) + '…') : desc;
|
|
|
|
|
|
const codeInline = codeId(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 = this.dbInstance.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: taskId, display_code: displayCode }, 0);
|
|
|
ResponseQueue.enqueueOnboarding(rcpt, msg2, { variant, part: 2, bundle_id: bundleId, group_id: gid, task_id: taskId, display_code: displayCode }, delay2);
|
|
|
try { Metrics.inc('onboarding_bundle_sent_total', 1, { variant, group_id: String(gid) }); } catch {}
|
|
|
} catch {}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
} catch {}
|
|
|
|
|
|
return responses;
|
|
|
}
|
|
|
|
|
|
static async handle(context: CommandContext): Promise<CommandResponse[]> {
|
|
|
const outcome = await this.handleWithOutcome(context);
|
|
|
return outcome.responses;
|
|
|
}
|
|
|
|
|
|
static async handleWithOutcome(context: CommandContext): Promise<CommandOutcome> {
|
|
|
const msg = (context.message || '').trim();
|
|
|
if (!/^\/(tarea|t)\b/i.test(msg)) {
|
|
|
return { responses: [], ok: true };
|
|
|
}
|
|
|
|
|
|
// Registrar interacción del usuario (last_command_at) para cualquier comando /t …
|
|
|
try {
|
|
|
const ensured = ensureUserExists(context.sender, this.dbInstance);
|
|
|
if (ensured) {
|
|
|
try {
|
|
|
this.dbInstance.prepare(`UPDATE users SET last_command_at = strftime('%Y-%m-%d %H:%M:%f','now') WHERE id = ?`).run(ensured);
|
|
|
} catch {}
|
|
|
}
|
|
|
} catch {}
|
|
|
|
|
|
// Gating de grupos en modo 'enforce' (cuando CommandService se invoca directamente)
|
|
|
if (isGroupId(context.groupId)) {
|
|
|
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch { }
|
|
|
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
|
|
|
if (mode === 'enforce') {
|
|
|
try {
|
|
|
if (!AllowedGroups.isAllowed(context.groupId)) {
|
|
|
try { Metrics.inc('commands_blocked_total'); } catch { }
|
|
|
return { responses: [], ok: true };
|
|
|
}
|
|
|
} catch {
|
|
|
// Si falla el check, ser permisivos
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
const responses = await this.processTareaCommand(context);
|
|
|
|
|
|
// Clasificación explícita del outcome (evita lógica en server)
|
|
|
const tokens = msg.split(/\s+/);
|
|
|
const rawAction = (tokens[1] || '').toLowerCase();
|
|
|
const ACTION_ALIASES: Record<string, string> = {
|
|
|
'n': 'nueva',
|
|
|
'nueva': 'nueva',
|
|
|
'crear': 'nueva',
|
|
|
'+': 'nueva',
|
|
|
'ver': 'ver',
|
|
|
'mostrar': 'ver',
|
|
|
'listar': 'ver',
|
|
|
'ls': 'ver',
|
|
|
'mias': 'ver',
|
|
|
'mías': 'ver',
|
|
|
'todas': 'ver',
|
|
|
'todos': 'ver',
|
|
|
'x': 'completar',
|
|
|
'hecho': 'completar',
|
|
|
'completar': 'completar',
|
|
|
'done': 'completar',
|
|
|
'tomar': 'tomar',
|
|
|
'claim': 'tomar',
|
|
|
'asumir': 'tomar',
|
|
|
'asumo': 'tomar',
|
|
|
'soltar': 'soltar',
|
|
|
'unassign': 'soltar',
|
|
|
'dejar': 'soltar',
|
|
|
'liberar': 'soltar',
|
|
|
'renunciar': 'soltar',
|
|
|
'ayuda': 'ayuda',
|
|
|
'help': 'ayuda',
|
|
|
'info': 'ayuda',
|
|
|
'?': 'ayuda',
|
|
|
'config': 'configurar',
|
|
|
'configurar': 'configurar',
|
|
|
'web': 'web'
|
|
|
};
|
|
|
const action = ACTION_ALIASES[rawAction] || rawAction;
|
|
|
|
|
|
// Casos explícitos considerados éxito
|
|
|
if (!action || action === 'ayuda' || action === 'web') {
|
|
|
return { responses, ok: true };
|
|
|
}
|
|
|
|
|
|
const lowerMsgs = (responses || []).map(r => String(r?.message || '').toLowerCase());
|
|
|
|
|
|
const isOkException = (m: string) =>
|
|
|
m.includes('ya estaba completada') ||
|
|
|
m.includes('ya la tenías') ||
|
|
|
m.includes('no la tenías');
|
|
|
|
|
|
const isErrorMsg = (m: string) =>
|
|
|
m.startsWith('ℹ️ uso:'.toLowerCase()) ||
|
|
|
m.includes('uso:') ||
|
|
|
m.includes('no puedes') ||
|
|
|
m.includes('no permitido') ||
|
|
|
m.includes('no encontrada') ||
|
|
|
m.includes('comando no reconocido');
|
|
|
|
|
|
let hasError = false;
|
|
|
for (const m of lowerMsgs) {
|
|
|
if (isErrorMsg(m) && !isOkException(m)) {
|
|
|
hasError = true;
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return { responses, ok: !hasError };
|
|
|
} catch (error) {
|
|
|
return {
|
|
|
responses: [{
|
|
|
recipient: context.sender,
|
|
|
message: 'Error processing command'
|
|
|
}],
|
|
|
ok: false
|
|
|
};
|
|
|
}
|
|
|
}
|
|
|
}
|