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