// 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 = {
Monday: 'Write down the one task that would make this week a good one.',
Tuesday: 'Reply to the email you have been putting off.',
Wednesday: 'Take a five-minute walk after lunch.',
Thursday: 'Stand up and stretch every hour.',
Friday: 'Plan Monday before you close your laptop.',
Saturday: 'Call someone you have not talked to in a while.',
Sunday: 'Rest. The digest will be here tomorrow.',
}
// 1. The digest of one day, built from the slot the task covers (the same text on a re-run)
export function digestFor(date) {
const weekday = date.toLocaleDateString('en-US', { timeZone: TIME_ZONE, weekday: 'long' })
const day = date.toLocaleDateString('en-US', { timeZone: TIME_ZONE, month: 'long', day: 'numeric' })
return `☀️ Your digest for ${weekday}, ${day}\n💡 ${TIPS[weekday]}\nReply STOP to unsubscribe.`
}
const createBot = defineBot((provider) => {
mkdirSync(dirname(DB_FILE), { recursive: true })
const store = new SQLiteStore({ file: DB_FILE })
const bot = new Bot({
provider,
store,
// 2. Durable schedules live in the same SQLite file; cron fields are read in TIME_ZONE
scheduler: { store: store.schedules(), timeZone: TIME_ZONE },
})
bot.plug(slashCommandsPlugin)
// 3. Opt in and opt out: the choice is stored in the user's profile (ctx.user)
bot.when('subscribe', async (ctx) => {
await ctx.user.set('digest', { subscribed: true })
await ctx.reply(`✅ Subscribed! You will get the digest every day at 08:00 (${TIME_ZONE}). Reply STOP to unsubscribe.`)
})
bot.when(['stop', 'unsubscribe'], async (ctx) => {
await ctx.user.set('digest.subscribed', false)
await ctx.reply('👋 Unsubscribed. Send "subscribe" to come back.')
})
bot.onMessage((ctx) => ctx.reply('Send "subscribe" to get a daily digest at 08:00.'))
// 4. A durable cron task: stored, run once per day across restarts and replicas
bot.schedule('0 8 * * *', { id: 'daily-digest', missed: 'run-once' }, async (run) => {
const chatIds = await pendingSubscribers(bot, run.occurrenceKey)
const saved = []
// 5. A paced broadcast: 20 chats per batch, 2 s between batches, back-off on rate limits
const result = await bot.broadcast(chatIds, digestFor(run.scheduledAt), {
batchSize: 20,
delayBetween: '2s',
retries: 3,
signal: run.signal,
// 6. Record each chat as soon as its digest is sent, not at the end
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. Stopped mid-broadcast (a deploy at 08:00): the pending chats miss today's digest
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. Subscribed private chats that have not received this occurrence yet.
// In a private chat the chat id is the user id, so it reads that user's profile.
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()
}