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
parent
dd0ba41830
commit
5c49f16c4e
@ -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…
Reference in New Issue