feat: agregar verificación de membresía y filtrado por snapshot

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
pull/1/head
borja 2 months ago
parent b7b4f0aabe
commit de544c42de

@ -21,6 +21,14 @@ TZ="Europe/Madrid" # Zona horaria usada para "hoy/mañana" y render de fe
# Intervalo en milisegundos; por defecto 86400000 (24h). En desarrollo puede bajarse (mínimo recomendable 10000ms).
# GROUP_SYNC_INTERVAL_MS=86400000
# Membresías (opcional)
# Edad máxima (ms) para considerar "fresca" la snapshot de miembros de un grupo. Por defecto 86400000 (24h).
# MAX_MEMBERS_SNAPSHOT_AGE_MS=86400000
# Si "true", se aplica validación estricta de membresía (solo con snapshot fresca). Por defecto false.
# GROUP_MEMBERS_ENFORCE=false
# Si "true", los recordatorios incluirán una sección de "sin responsable" filtrada por tus grupos con membresía activa (snapshot fresca). Por defecto false.
# REMINDERS_INCLUDE_UNASSIGNED_FROM_MEMBERSHIP=false
# Notificaciones (opcional)
# Si se pone a "true", el bot enviará un breve resumen al grupo al crear una tarea (por defecto false).
# NOTIFY_GROUP_ON_CREATE=false

@ -49,6 +49,9 @@ Un chatbot de WhatsApp para gestionar tareas en grupos, integrado con Evolution
- NOTIFY_GROUP_ON_CREATE: si “true”, envía resumen al grupo al crear (por defecto false).
- GROUP_SYNC_INTERVAL_MS: intervalo de sync de grupos; por defecto 24h (mín 10s en desarrollo).
- GROUP_MEMBERS_SYNC_INTERVAL_MS: intervalo de sync de miembros; por defecto 6h (mín 10s en desarrollo).
- MAX_MEMBERS_SNAPSHOT_AGE_MS: edad máxima (ms) para considerar "fresca" la snapshot de miembros; por defecto 24h.
- GROUP_MEMBERS_ENFORCE: si "true", aplica validación estricta de membresía cuando la snapshot es fresca; por defecto false.
- REMINDERS_INCLUDE_UNASSIGNED_FROM_MEMBERSHIP: si "true", añade sección "sin responsable" en recordatorios solo de tus grupos con membresía activa; por defecto false.
- RATE_LIMIT_PER_MIN: límite por usuario (tokens/min); por defecto 15.
- RATE_LIMIT_BURST: capacidad del bucket; por defecto = RATE_LIMIT_PER_MIN.
- Opcionales — cola de respuestas

@ -227,7 +227,7 @@ export class CommandService {
}];
}
// Ver todos: "tus tareas" + "sin dueño (grupo actual)" si estás en un grupo
// Ver todos: "tus tareas" + "sin responsable" de grupos donde eres miembro activo (snapshot fresca)
if (scope === 'todos') {
const sections: string[] = [];
@ -275,13 +275,16 @@ export class CommandService {
sections.push('No tienes tareas pendientes.');
}
// Si se invoca en un grupo activo, añadir "sin dueño" de ese grupo
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);
// Añadir "sin responsable" de grupos donde el usuario es miembro activo y snapshot fresca
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) {
sections.push(`${groupName} — Sin responsable`);
const renderedUnassigned = unassigned.map((t) => {
@ -291,16 +294,14 @@ export class CommandService {
});
sections.push(...renderedUnassigned);
const totalUnassigned = TaskService.countGroupUnassigned(context.groupId);
const totalUnassigned = TaskService.countGroupUnassigned(gid);
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: nota instructiva
// Si no hay snapshot fresca de membresía, mantenemos una nota instructiva mínima
sections.push(' Para ver tareas sin responsable de un grupo, usa “/t ver sin” desde ese grupo.');
}
@ -324,6 +325,16 @@ export class CommandService {
message: '⚠️ Este grupo no está activo.'
}];
}
// Enforcement opcional basado en membresía si la snapshot es fresca
const enforce = String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true';
const fresh = GroupSyncService.isSnapshotFresh(context.groupId);
if (enforce && fresh && !GroupSyncService.isUserActiveInGroup(context.sender, context.groupId)) {
return [{
recipient: context.sender,
message: 'No puedes ver las tareas de este grupo porque no apareces como miembro activo. Pide acceso a un admin si crees que es un error.'
}];
}
const items = TaskService.listGroupPending(context.groupId, LIMIT);
const groupName = GroupSyncService.activeGroupsCache.get(context.groupId) || context.groupId;
@ -410,7 +421,7 @@ export class CommandService {
}];
}
// Completar tarea
// Completar tarea (con validación opcional de membresía)
if (action === 'completar') {
const idToken = tokens[2];
const id = idToken ? parseInt(idToken, 10) : NaN;
@ -421,15 +432,29 @@ export class CommandService {
}];
}
const res = TaskService.completeTask(id, context.sender);
if (res.status === 'not_found') {
const task = TaskService.getTaskById(id);
if (!task) {
return [{
recipient: context.sender,
message: `⚠️ Tarea ${id} 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 completar esta tarea porque no apareces como miembro activo del grupo.'
}];
}
const res = TaskService.completeTask(id, context.sender);
const who = (await ContactsService.getDisplayName(context.sender)) || context.sender;
if (res.status === 'not_found') {
return [{
recipient: context.sender,
message: `⚠️ Tarea ${id} no encontrada.`
}];
}
if (res.status === 'already') {
const due = res.task?.due_date ? ` — 📅 ${formatDDMM(res.task?.due_date)}` : '';
return [{
@ -445,7 +470,7 @@ export class CommandService {
}];
}
// Tomar tarea
// Tomar tarea (con validación opcional de membresía)
if (action === 'tomar') {
const idToken = tokens[2];
const id = idToken ? parseInt(idToken, 10) : NaN;
@ -456,6 +481,21 @@ export class CommandService {
}];
}
const task = TaskService.getTaskById(id);
if (!task) {
return [{
recipient: context.sender,
message: `⚠️ Tarea ${id} 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: no apareces como miembro activo del grupo. Pide acceso a un admin si crees que es un error.'
}];
}
const res = TaskService.claimTask(id, context.sender);
const due = res.task?.due_date ? ` — 📅 ${formatDDMM(res.task?.due_date)}` : '';
@ -484,7 +524,7 @@ export class CommandService {
}];
}
// Soltar tarea
// Soltar tarea (con validación opcional de membresía)
if (action === 'soltar') {
const idToken = tokens[2];
const id = idToken ? parseInt(idToken, 10) : NaN;
@ -495,6 +535,21 @@ export class CommandService {
}];
}
const task = TaskService.getTaskById(id);
if (!task) {
return [{
recipient: context.sender,
message: `⚠️ Tarea ${id} 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 apareces como miembro activo del grupo.'
}];
}
const res = TaskService.unassignTask(id, context.sender);
const due = res.task?.due_date ? ` — 📅 ${formatDDMM(res.task?.due_date)}` : '';

@ -609,4 +609,71 @@ export class GroupSyncService {
this._membersTimer = null;
}
}
// ===== Helpers de membresía y snapshot (Etapa 3) =====
private static get MAX_SNAPSHOT_AGE_MS(): number {
const raw = Number(process.env.MAX_MEMBERS_SNAPSHOT_AGE_MS);
return Number.isFinite(raw) && raw > 0 ? raw : 24 * 60 * 60 * 1000; // 24h por defecto
}
/**
* Devuelve true si la snapshot de un grupo es "fresca" según MAX_SNAPSHOT_AGE_MS.
* Considera no fresca si no hay registro/fecha.
*/
public static isSnapshotFresh(groupId: string, nowMs: number = Date.now()): boolean {
try {
const row = this.dbInstance.prepare(`SELECT last_verified FROM groups WHERE id = ?`).get(groupId) as any;
const lv = row?.last_verified ? String(row.last_verified) : null;
if (!lv) return false;
// Persistimos 'YYYY-MM-DD HH:MM:SS[.mmm]'. Convertimos a ISO-like para Date.parse
const iso = lv.includes('T') ? lv : (lv.replace(' ', 'T') + 'Z');
const ms = Date.parse(iso);
if (!Number.isFinite(ms)) return false;
return (nowMs - ms) <= this.MAX_SNAPSHOT_AGE_MS;
} catch {
return false;
}
}
/**
* ¿El usuario figura como miembro activo del grupo?
*/
public static isUserActiveInGroup(userId: string, groupId: string): boolean {
if (!userId || !groupId) return false;
const row = this.dbInstance.prepare(`
SELECT 1
FROM group_members
WHERE group_id = ? AND user_id = ? AND is_active = 1
LIMIT 1
`).get(groupId, userId);
return !!row;
}
/**
* Devuelve todos los group_ids activos donde el usuario figura activo.
* Filtra también por grupos activos en la tabla groups.
*/
public static getActiveGroupIdsForUser(userId: string): string[] {
if (!userId) return [];
const rows = this.dbInstance.prepare(`
SELECT gm.group_id AS id
FROM group_members gm
JOIN groups g ON g.id = gm.group_id
WHERE gm.user_id = ? AND gm.is_active = 1 AND g.active = 1
`).all(userId) as any[];
const set = new Set<string>();
for (const r of rows) {
if (r?.id) set.add(String(r.id));
}
return Array.from(set);
}
/**
* Devuelve los group_ids donde el usuario es miembro activo y cuya snapshot es fresca.
*/
public static getFreshMemberGroupsForUser(userId: string): string[] {
const gids = this.getActiveGroupIdsForUser(userId);
return gids.filter(gid => this.isSnapshotFresh(gid));
}
}

@ -152,6 +152,32 @@ export class RemindersService {
sections.push(`… y ${total - items.length} más`);
}
// (Etapa 3) Sección opcional de "sin responsable" filtrada por membresía activa + snapshot fresca.
const includeUnassigned = String(process.env.REMINDERS_INCLUDE_UNASSIGNED_FROM_MEMBERSHIP || '').toLowerCase() === 'true';
if (includeUnassigned) {
const memberGroups = GroupSyncService.getFreshMemberGroupsForUser(pref.user_id);
for (const gid of memberGroups) {
const unassigned = TaskService.listGroupUnassigned(gid, 10);
if (unassigned.length > 0) {
const groupName =
(gid && GroupSyncService.activeGroupsCache.get(gid)) ||
gid;
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 `- ${t.id}) _${t.description || '(sin descripción)'}_${datePart}${ICONS.unassigned} sin responsable`;
});
sections.push(...renderedUnassigned);
const totalUnassigned = TaskService.countGroupUnassigned(gid);
if (totalUnassigned > unassigned.length) {
sections.push(`… y ${totalUnassigned - unassigned.length} más`);
}
}
}
}
await ResponseQueue.add([{
recipient: pref.user_id,
message: sections.join('\n')

@ -417,4 +417,63 @@ export class TaskService {
now_unassigned: remaining === 0,
};
}
// ===== Helpers adicionales para consumidores (Etapa 3) =====
// Devuelve datos básicos de una tarea, o null si no existe
static getTaskById(taskId: number): {
id: number;
description: string;
due_date: string | null;
group_id: string | null;
completed: number;
completed_at: string | null;
} | null {
const row = this.dbInstance.prepare(`
SELECT
id,
description,
due_date,
group_id,
COALESCE(completed, 0) as completed,
completed_at
FROM tasks
WHERE id = ?
`).get(taskId) as any;
if (!row) return null;
return {
id: Number(row.id),
description: String(row.description || ''),
due_date: row.due_date ? String(row.due_date) : null,
group_id: row.group_id ? String(row.group_id) : null,
completed: Number(row.completed || 0),
completed_at: row.completed_at ? String(row.completed_at) : null,
};
}
// Lista tareas sin responsable para múltiples grupos.
// Implementación simple: reutiliza el método existente por grupo.
static listUnassignedByGroups(groupIds: string[], limitPerGroup: number = 10): Map<string, Array<{
id: number;
description: string;
due_date: string | null;
group_id: string | null;
assignees: string[];
}>> {
const out = new Map<string, Array<{
id: number;
description: string;
due_date: string | null;
group_id: string | null;
assignees: string[];
}>>();
if (!Array.isArray(groupIds) || groupIds.length === 0) return out;
for (const gid of groupIds) {
const rows = this.listGroupUnassigned(gid, limitPerGroup);
if (rows.length > 0) {
out.set(gid, rows);
}
}
return out;
}
}

Loading…
Cancel
Save