feat: añadir recordatorios por DM diarios/semanales y configuración

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

@ -126,5 +126,26 @@ export const migrations: Migration[] = [
ON response_queue (status, updated_at);
`);
}
},
{
version: 4,
name: 'user-preferences-reminders',
checksum: 'v4-user-preferences-2025-09-07',
up: (db: Database) => {
db.exec(`
CREATE TABLE IF NOT EXISTS user_preferences (
user_id TEXT PRIMARY KEY,
reminder_freq TEXT NOT NULL DEFAULT 'off' CHECK (reminder_freq IN ('off','daily','weekly')),
reminder_time TEXT NOT NULL DEFAULT '08:30',
last_reminded_on TEXT NULL,
updated_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_user_prefs_freq_time
ON user_preferences (reminder_freq, reminder_time);
`);
}
}
];

@ -10,6 +10,7 @@ import { ensureUserExists, db } from './db';
import { ContactsService } from './services/contacts';
import { Migrator } from './db/migrator';
import { RateLimiter } from './services/rate-limit';
import { RemindersService } from './services/reminders';
// Bun is available globally when running under Bun runtime
declare global {
@ -313,6 +314,8 @@ export class WebhookServer {
// Start cleanup scheduler (daily retention)
ResponseQueue.startCleanupScheduler();
console.log('✅ ResponseQueue cleanup scheduler started');
RemindersService.start();
console.log('✅ RemindersService started');
} catch (e) {
console.error('❌ Failed to start ResponseQueue worker or cleanup scheduler:', e);
}

@ -157,7 +157,8 @@ export class CommandService {
'- Crear: /t n Descripción mañana @Ana',
'- Ver grupo: /t ver grupo',
'- Ver mis: /t ver mis',
'- Completar: /t x 123'
'- Completar: /t x 123',
'- Configurar recordatorios: /t configurar daily|weekly|off'
].join('\n');
return [{
recipient: context.sender,
@ -509,6 +510,43 @@ export class CommandService {
}];
}
if (action === 'configurar') {
const optRaw = (tokens[2] || '').toLowerCase();
const map: Record<string, 'daily' | 'weekly' | 'off'> = {
'daily': 'daily',
'diario': 'daily',
'diaria': 'daily',
'semanal': 'weekly',
'weekly': 'weekly',
'off': 'off',
'apagar': 'off',
'ninguno': 'off'
};
const freq = map[optRaw];
if (!freq) {
return [{
recipient: context.sender,
message: 'Uso: /t configurar daily|weekly|off'
}];
}
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((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,
updated_at = excluded.updated_at
`).run(ensured, freq, ensured);
const label = freq === 'daily' ? 'diario' : freq === 'weekly' ? 'semanal (lunes 08:30)' : 'apagado';
return [{
recipient: context.sender,
message: `✅ Recordatorios: ${label}`
}];
}
if (action !== 'nueva') {
return [{
recipient: context.sender,

@ -0,0 +1,173 @@
import type { Database } from 'bun:sqlite';
import { db } from '../db';
import { TaskService } from '../tasks/service';
import { ResponseQueue } from './response-queue';
import { ContactsService } from './contacts';
import { GroupSyncService } from './group-sync';
type UserPreference = {
user_id: string;
reminder_freq: 'daily' | 'weekly' | 'off';
reminder_time: string; // 'HH:MM'
last_reminded_on: string | null; // 'YYYY-MM-DD'
};
export class RemindersService {
static dbInstance: Database = db;
private static _running = false;
private static _timer: any = null;
private static get TZ() {
return process.env.TZ && process.env.TZ.trim() ? process.env.TZ : 'Europe/Madrid';
}
private static ymdInTZ(d: Date): string {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: this.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')}`;
}
private static hmInTZ(d: Date): string {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: this.TZ,
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).formatToParts(d);
const get = (t: string) => parts.find(p => p.type === t)?.value || '';
return `${get('hour')}:${get('minute')}`;
}
private static weekdayShortInTZ(d: Date): string {
return new Intl.DateTimeFormat('en-GB', {
timeZone: this.TZ,
weekday: 'short',
}).format(d); // e.g., 'Mon', 'Tue', ...
}
static start() {
if (process.env.NODE_ENV === 'test') return;
if (this._running) return;
this._running = true;
// Arranca un tick cada minuto
this._timer = setInterval(() => {
this.runOnce().catch(err => {
console.error('RemindersService runOnce error:', err);
});
}, 60_000);
// Primer tick diferido para no bloquear el arranque
setTimeout(() => this.runOnce().catch(() => {}), 5_000);
}
static stop() {
this._running = false;
if (this._timer) {
clearInterval(this._timer);
this._timer = null;
}
}
static async runOnce(now: Date = new Date()): Promise<void> {
const todayYMD = this.ymdInTZ(now);
const nowHM = this.hmInTZ(now);
const weekday = this.weekdayShortInTZ(now); // 'Mon'..'Sun'
const rows = this.dbInstance.prepare(`
SELECT user_id, reminder_freq, reminder_time, last_reminded_on
FROM user_preferences
WHERE reminder_freq IN ('daily', 'weekly')
`).all() as UserPreference[];
for (const pref of rows) {
// Evitar duplicado el mismo día
if (pref.last_reminded_on === todayYMD) continue;
// Verificar hora alcanzada
if (!pref.reminder_time || nowHM < pref.reminder_time) continue;
// Semanal: solo lunes (Mon)
if (pref.reminder_freq === 'weekly' && weekday !== 'Mon') continue;
try {
const items = TaskService.listUserPending(pref.user_id, 10);
const total = TaskService.countUserPending(pref.user_id);
if (!items || items.length === 0 || total === 0) {
// No enviar si no hay tareas; no marcamos last_reminded_on para permitir enviar si aparecen más tarde hoy
continue;
}
// Construir mensaje similar a "/t ver mis"
const formatDDMM = (ymd?: string | null): string | null => {
if (!ymd) return null;
const parts = String(ymd).split('-');
if (parts.length >= 3) {
const [Y, M, D] = parts;
if (D && M) return `${D}/${M}`;
}
return String(ymd);
};
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[] = [];
sections.push(pref.reminder_freq === 'weekly' ? 'Resumen semanal — tus tareas' : 'Resumen diario — 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
? '👥 sin dueño'
: `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`;
const datePart = t.due_date ? ` — 📅 ${formatDDMM(t.due_date)}` : '';
return `- ${t.id}) “*${t.description || '(sin descripción)'}*”${datePart}${owner}`;
}));
sections.push(...rendered);
}
if (total > items.length) {
sections.push(`… y ${total - items.length} más`);
}
await ResponseQueue.add([{
recipient: pref.user_id,
message: sections.join('\n')
}]);
// Marcar como enviado hoy
this.dbInstance.prepare(`
INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at)
VALUES (?, COALESCE((SELECT reminder_freq FROM user_preferences WHERE user_id = ?), 'daily'),
COALESCE((SELECT reminder_time FROM user_preferences WHERE user_id = ?), '08:30'),
?, strftime('%Y-%m-%d %H:%M:%f', 'now'))
ON CONFLICT(user_id) DO UPDATE SET
last_reminded_on = excluded.last_reminded_on,
updated_at = excluded.updated_at
`).run(pref.user_id, pref.user_id, pref.user_id, todayYMD);
} catch (e) {
console.error('RemindersService: error al procesar usuario', pref.user_id, e);
}
}
}
}
Loading…
Cancel
Save