// bot.js
import { mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
import { Bot, defineBot, isMainModule, slashCommandsPlugin } from '@botmesh/core'
import { BaileysProvider } from '@botmesh/baileys'
import { SQLiteStore } from '@botmesh/sqlite-store'
const DB_FILE = process.env.DB_FILE ?? './data/digest.db'
const TIME_ZONE = process.env.DIGEST_TZ ?? 'America/Bogota'
const TIPS = {
lunes: 'Apunta la única tarea que haría de esta semana una buena semana.',
martes: 'Responde ese correo que llevas días aplazando.',
miércoles: 'Da un paseo de cinco minutos después de comer.',
jueves: 'Levántate y estírate cada hora.',
viernes: 'Planifica el lunes antes de cerrar el portátil.',
sábado: 'Llama a alguien con quien no hablas hace tiempo.',
domingo: 'Descansa. Mañana vuelve el resumen.',
}
// 1. El resumen de un día, hecho a partir del hueco que cubre la tarea (el mismo texto si se repite)
export function digestFor(date) {
const weekday = date.toLocaleDateString('es-ES', { timeZone: TIME_ZONE, weekday: 'long' })
const day = date.toLocaleDateString('es-ES', { timeZone: TIME_ZONE, month: 'long', day: 'numeric' })
return `☀️ Tu resumen del ${weekday}, ${day}\n💡 ${TIPS[weekday]}\nResponde BAJA para darte de baja.`
}
const createBot = defineBot((provider) => {
mkdirSync(dirname(DB_FILE), { recursive: true })
const store = new SQLiteStore({ file: DB_FILE })
const bot = new Bot({
provider,
store,
lang: 'es',
// 2. Las tareas durables viven en el mismo archivo SQLite; los campos cron se leen en TIME_ZONE
scheduler: { store: store.schedules(), timeZone: TIME_ZONE },
})
bot.plug(slashCommandsPlugin)
// 3. Alta y baja: la elección se guarda en el perfil del usuario (ctx.user)
bot.when('suscribir', async (ctx) => {
await ctx.user.set('digest', { subscribed: true })
await ctx.reply(`✅ ¡Suscrito! Recibirás el resumen cada día a las 08:00 (${TIME_ZONE}). Responde BAJA para darte de baja.`)
})
bot.when(['baja', 'stop'], async (ctx) => {
await ctx.user.set('digest.subscribed', false)
await ctx.reply('👋 Te diste de baja. Envía "suscribir" para volver.')
})
bot.onMessage((ctx) => ctx.reply('Envía "suscribir" para recibir un resumen diario a las 08:00.'))
// 4. Una tarea cron durable: guardada, una vez al día entre reinicios y réplicas
bot.schedule('0 8 * * *', { id: 'daily-digest', missed: 'run-once' }, async (run) => {
const chatIds = await pendingSubscribers(bot, run.occurrenceKey)
const saved = []
// 5. Un broadcast pausado: 20 chats por lote, 2 s entre lotes, reintentos ante límites de envío
const result = await bot.broadcast(chatIds, digestFor(run.scheduledAt), {
batchSize: 20,
delayBetween: '2s',
retries: 3,
signal: run.signal,
// 6. Registra cada chat en cuanto se le envía el resumen, no al final
onProgress: (_done, _total, last) => {
if (!last.ok) return
saved.push(bot.user(last.chatId).set('digest.last', run.occurrenceKey)
.catch((err) => bot.logger.warn({ err }, 'digest.last not saved')))
},
})
await Promise.all(saved)
// 7. Parado a mitad del broadcast (un despliegue a las 08:00): los pendientes se quedan sin el de hoy
if (result.cancelled) bot.logger.warn({ pending: result.pending.length }, 'daily digest cut short')
bot.logger.info({ sent: result.sent, failed: result.failed }, 'daily digest')
})
return bot
})
export default createBot
// 6. Chats privados suscritos que aún no recibieron esta ocurrencia.
// En un chat privado el id del chat es el del usuario, así que lee el perfil de ese usuario.
async function pendingSubscribers(bot, occurrenceKey) {
const ids = []
for (const chat of await bot.getStore().listChats()) {
if (chat.kind !== 'dm') continue
const digest = await bot.user(chat.id).get('digest')
if (digest?.subscribed && digest.last !== occurrenceKey) ids.push(chat.id)
}
return ids
}
if (isMainModule(import.meta)) {
const bot = await createBot(new BaileysProvider({ session: 'digest-bot' }))
process.once('SIGTERM', () => void bot.stop({ drain: true }))
await bot.start()
}