feat: añadir conteos de pendientes y mostrar… y X más en /t ver

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

@ -109,7 +109,6 @@ Estado: la tabla response_queue ya está creada e incluida en los tests de DB.
- **Database isolation in unit tests**: Using in-memory instances to avoid conflicts.
### Incomplete / Missing Core Functionality
- Additional commands: `/tarea mostrar` (list) y `/tarea completar`.
- ResponseQueue reliability: reintentos con backoff, recuperación de `processing`, métricas y limpieza/retención.
- ContactsService improvements (optional): refine caching policy and endpoints; basic friendly-name resolution is already implemented and used in outgoing texts.
- Database migrations system (beyond current lightweight on-boot checks).

@ -35,7 +35,7 @@
## ⚠️ Funcionalidades Pendientes
- **Gestión de Tareas**
- Listar (`/tarea mostrar`) y completar (`/tarea completar`) tareas; eliminación opcional
- Eliminación opcional de tareas y mejoras de edición
- **Cola de Respuestas**
- Reintentos con backoff y jitter
- Recuperación de ítems en estado `processing` tras caídas
@ -48,9 +48,9 @@
- Sistema de migraciones de esquema
## ➡️ Próximos Pasos Prioritarios
1. Implementar `/tarea mostrar` y `/tarea completar`.
1. Añadir tests de Fase 3 (ver/x, entradas extendidas) y paginación “… y X más”.
2. Mejorar UX de menciones: resolver nombres y formateo de “asignados”.
3. Extender soporte de entrada: `extendedTextMessage` y captions de media.
3. Unificar y pulir formatos de mensajes (dd/MM en todos, compacidad).
4. Mejoras de fiabilidad de la cola: reintentos con backoff y recuperación de `processing`.
5. Métricas/observabilidad básicas y plan de migraciones de DB.

@ -184,6 +184,11 @@ export class CommandService {
return `- ${t.id}) “*${t.description || '(sin descripción)'}*”${datePart}${owner}`;
}));
const total = TaskService.countGroupPending(context.groupId);
if (total > items.length) {
rendered.push(`… y ${total - items.length} más`);
}
return [{
recipient: context.sender,
message: [groupName, ...rendered].join('\n')
@ -199,6 +204,8 @@ export class CommandService {
}];
}
const total = TaskService.countUserPending(context.sender);
// Agrupar por grupo
const byGroup = new Map<string, typeof items>();
for (const t of items) {
@ -229,6 +236,10 @@ export class CommandService {
sections.push(...rendered);
}
if (total > items.length) {
sections.push(`… y ${total - items.length} más`);
}
return [{
recipient: context.sender,
message: sections.join('\n')
@ -386,7 +397,7 @@ export class CommandService {
responses.push({
recipient: uid,
message: [
`🔔 ${taskId}${dueDate ? ` — 📅 ${dueDate}` : ''}`,
`🔔 ${taskId}${formatDDMM(dueDate) ? ` — 📅 ${formatDDMM(dueDate)}` : ''}`,
`“*${description || '(sin descripción)'}*”`,
groupName ? `Grupo: ${groupName}` : null,
`Completar: /t x ${taskId}`

@ -146,6 +146,33 @@ export class TaskService {
});
}
// Contar pendientes del grupo (sin límite)
static countGroupPending(groupId: string): number {
const row = this.dbInstance
.prepare(`
SELECT COUNT(*) as cnt
FROM tasks
WHERE group_id = ?
AND (completed = 0 OR completed_at IS NULL)
`)
.get(groupId) as any;
return Number(row?.cnt || 0);
}
// Contar pendientes asignadas al usuario (sin límite)
static countUserPending(userId: string): number {
const row = this.dbInstance
.prepare(`
SELECT COUNT(*) as cnt
FROM tasks t
INNER JOIN task_assignments a ON a.task_id = t.id
WHERE a.user_id = ?
AND (t.completed = 0 OR t.completed_at IS NULL)
`)
.get(userId) as any;
return Number(row?.cnt || 0);
}
// Completar tarea: registra quién completó e idempotente
static completeTask(taskId: number, completedBy: string): {
status: 'updated' | 'already' | 'not_found';

@ -273,6 +273,46 @@ describe('WebhookServer', () => {
expect(SimulatedResponseQueue.getQueue().length).toBe(0);
});
test('should process command from extendedTextMessage', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net'
},
message: {
extendedTextMessage: { text: '/t n Test ext' }
}
}
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.getQueue().length).toBeGreaterThan(0);
});
test('should process command from image caption when caption starts with a command', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net'
},
message: {
imageMessage: { caption: '/t n From caption' }
}
}
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.getQueue().length).toBeGreaterThan(0);
});
test('should handle requests on configured port', async () => {
const originalPort = process.env.PORT;
process.env.PORT = '3007';

@ -3,6 +3,7 @@ import { Database } from 'bun:sqlite';
import { initializeDatabase } from '../../../src/db';
import { CommandService } from '../../../src/services/command';
import { TaskService } from '../../../src/tasks/service';
import { GroupSyncService } from '../../../src/services/group-sync';
let memDb: Database;
const testContextBase = {
@ -18,6 +19,132 @@ beforeEach(() => {
(TaskService as any).dbInstance = memDb;
});
test('listar grupo por defecto con /t ver en grupo e incluir “… y X más”', async () => {
// Insert group and cache it as active
memDb.exec(`
INSERT OR IGNORE INTO groups (id, community_id, name, active)
VALUES ('test-group@g.us', 'test-community', 'Test Group', 1)
`);
GroupSyncService.activeGroupsCache.clear();
GroupSyncService.activeGroupsCache.set('test-group@g.us', 'Test Group');
// Crear 12 tareas sin asignados en el grupo
for (let i = 1; i <= 12; i++) {
TaskService.createTask({
description: `Task ${i}`,
due_date: '2025-12-31',
group_id: 'test-group@g.us',
created_by: '1234567890',
});
}
const responses = await CommandService.handle({
sender: '1234567890',
groupId: 'test-group@g.us',
mentions: [],
message: '/t ver'
});
expect(responses.length).toBe(1);
expect(responses[0].recipient).toBe('1234567890');
expect(responses[0].message).toContain('Test Group');
// Debe indicar que hay 2 más (límite 10)
expect(responses[0].message).toContain('… y 2 más');
// Debe mostrar “sin dueño”
expect(responses[0].message).toContain('sin dueño');
});
test('listar “mis” por defecto en DM con /t ver', async () => {
// Insert groups and cache them
memDb.exec(`
INSERT OR REPLACE INTO groups (id, community_id, name, active) VALUES
('test-group@g.us', 'test-community', 'Test Group', 1),
('group-2@g.us', 'test-community', 'Group 2', 1)
`);
GroupSyncService.activeGroupsCache.clear();
GroupSyncService.activeGroupsCache.set('test-group@g.us', 'Test Group');
GroupSyncService.activeGroupsCache.set('group-2@g.us', 'Group 2');
// Crear 2 tareas asignadas al usuario en distintos grupos
const t1 = TaskService.createTask({
description: 'G1 Task',
due_date: '2025-11-20',
group_id: 'test-group@g.us',
created_by: '1111111111',
}, [{ user_id: '1234567890', assigned_by: '1111111111' }]);
const t2 = TaskService.createTask({
description: 'G2 Task',
due_date: '2025-11-25',
group_id: 'group-2@g.us',
created_by: '2222222222',
}, [{ user_id: '1234567890', assigned_by: '2222222222' }]);
const responses = await CommandService.handle({
sender: '1234567890',
// Contexto de DM: usar un JID que NO sea de grupo
groupId: '1234567890@s.whatsapp.net',
mentions: [],
message: '/t ver'
});
expect(responses.length).toBe(1);
expect(responses[0].recipient).toBe('1234567890');
const msg = responses[0].message;
expect(msg).toContain('Test Group');
expect(msg).toContain('Group 2');
expect(msg).toMatch(/- \d+\) “\*G1 Task\*”/);
expect(msg).toMatch(/- \d+\) “\*G2 Task\*”/);
});
test('completar tarea: camino feliz, ya completada y no encontrada', async () => {
// Insertar grupo y cache
memDb.exec(`
INSERT OR IGNORE INTO groups (id, community_id, name, active)
VALUES ('test-group@g.us', 'test-community', 'Test Group', 1)
`);
GroupSyncService.activeGroupsCache.clear();
GroupSyncService.activeGroupsCache.set('test-group@g.us', 'Test Group');
const taskId = TaskService.createTask({
description: 'Completar yo',
due_date: '2025-10-10',
group_id: 'test-group@g.us',
created_by: '1111111111',
});
// 1) Camino feliz
let responses = await CommandService.handle({
sender: '1234567890',
groupId: 'test-group@g.us',
mentions: [],
message: `/t x ${taskId}`
});
expect(responses.length).toBe(1);
expect(responses[0].recipient).toBe('1234567890');
expect(responses[0].message).toContain(`✔️ ${taskId} completada`);
// 2) Ya completada
responses = await CommandService.handle({
sender: '1234567890',
groupId: 'test-group@g.us',
mentions: [],
message: `/t x ${taskId}`
});
expect(responses.length).toBe(1);
expect(responses[0].message).toContain('ya estaba completada');
// 3) No encontrada
responses = await CommandService.handle({
sender: '1234567890',
groupId: 'test-group@g.us',
mentions: [],
message: `/t x 999999`
});
expect(responses.length).toBe(1);
expect(responses[0].message).toContain('no encontrada');
});
afterEach(() => {
try { memDb.close(); } catch {}
});

Loading…
Cancel
Save