Skip to main content
Level: Advanced · Tags: Scheduler, Broadcast, Time zones, SQLite, WhatsApp Features shown: Durable scheduler (bot.schedule with id, missed, run.occurrenceKey, run.signal) · Time zones (scheduler.timeZone) · Broadcasts (bot.broadcast with batches, pauses and retries) · User data (ctx.user, bot.user()) · Persistent store (SQLiteStore, store.schedules()) · Rate limiting

Prerequisites

Node.js 22.12 or newer and an ESM project ("type": "module"), as in Getting started.

The complete bot

How it works

1. The same text on a re-run

The digest is built from run.scheduledAt, the slot the run covers, not from the wall clock. If the task runs again for the same slot (see step 6), it produces the same message. Dates are formatted in TIME_ZONE with toLocaleDateString, so the weekday is the one in that zone, not the server’s. There is one zone for the whole bot (DIGEST_TZ): every subscriber gets the digest at 08:00 in that zone. For per-user zones, store each user’s zone in ctx.user and register one task per zone.

2. A durable scheduler in SQLite

scheduler.store: store.schedules() stores each durable task’s next run in the same SQLite file as the rest of the bot. scheduler.timeZone makes every cron task read its fields in that IANA zone: 0 8 * * * is 08:00 in Bogotá wherever the server runs, and daylight-saving changes follow the documented policy.

3. Opt in, opt out

subscribe and stop write the user’s choice with ctx.user, the per-user profile kept in the store. Nobody gets the digest without asking for it: unsolicited bulk messages are the fastest way to get a WhatsApp number banned.

4. A durable cron task

The id makes the task durable: its next run is stored, a restart keeps the schedule, and with several replicas only one runs each occurrence (claimed under a fenced lease). missed: 'run-once' sends one digest if the bot was down at 08:00 instead of skipping the day. Register durable tasks at boot, like here: only the schedule is stored, never the handler.

5. A paced broadcast

bot.broadcast() sends one content to many chats without blocking anyone’s inbound queue: 20 chats per batch, a 2-second pause between batches, and up to 3 retries per chat after a RateLimitedError (which pauses every worker). A failed chat never stops the rest; the result counts sent, failed and the chats still pending if run.signal aborted it (the bot is stopping).

6. At least once, without duplicates

A durable task runs at least once per occurrence: if the process dies mid-broadcast, the occurrence runs again. run.occurrenceKey (daily-digest@2026-03-02T13:00:00.000Z) is stable per slot. onProgress runs after each chat settles, so the bot stores the key as digest.last as soon as that chat’s digest is sent, and a re-run skips the users who already got that day’s digest. Only a chat whose send went out right before the crash, but whose digest.last was not written yet, can get it twice. In a private chat the chat id is the user id, so bot.user(chat.id) reads the right profile; group chats are skipped.

7. Stopping during the broadcast

A crash makes the occurrence run again, but a graceful stop does not: bot.stop() aborts run.signal, the broadcast returns the chats it did not reach in pending, the handler returns and the scheduler commits the slot and moves on to tomorrow. Those chats miss that day’s digest, which the bot logs as daily digest cut short. Deploy outside the 08:00 window, or send the pending chats yourself if missing a day matters.

Sample transcript

Captured with npx botmesh dev bot.js, with the clock a few seconds before 08:00 in Bogotá on Monday, March 2, 2026. The digest arrives on its own at 08:00:

Run it on WhatsApp or Telegram

node bot.js connects to WhatsApp: scan the QR with the phone that will be the bot. Set DIGEST_TZ (for example Europe/Madrid) to change the zone. For Telegram, install @botmesh/telegram and pass new TelegramProvider({ token: process.env.TELEGRAM_TOKEN }) instead.

Next steps