Skip to content
Oloyede Al-amin Oladaposmurftyy
Work index

W-02 · Case study

Whispr

Parsing “tomorrow at 11pm” is the easy part. The real work is making sure a reminder fires exactly once — through webhook retries, restarts and failed deliveries.

Status
Live — bot on Telegram, Mini App on Vercel, backend on Render
Role
Author
Data
MongoDB (messages, reminders, users, event log)
Queue
Bull on Redis — delayed jobs, retries, dead-letter queue
Stack
Node.js · Express · MongoDB · Redis + Bull · chrono-node · Zod · React 19 Mini App
Whispr Mini App journal screen: a "Hello, smurftyy" greeting, counters for 1 total, 0 upcoming and 0 done reminders, the start of a reminder card, and a bottom bar with Journal, Insights, Settings and Archive tabs.
Fig. 01 — Whispr — the Mini App journal screen inside Telegram.

Thesis

A reminder is a state machine, not a setTimeout.

§ 01

The problem

Reminders usually fail at the moment of capture. Opening an app, picking a date, filling a form — that friction is enough for most intentions to stay intentions. Chat is where people already write things down, so that is where scheduling should happen.

§ 02

What makes it hard

Telegram can deliver the same update more than once. Processes restart. Sends fail. And natural-language time is ambiguous: “tomorrow night” depends on where you are. Each of these, handled carelessly, produces the worst outcome for a reminder app — a reminder that fires twice, or never.

§ 03

The governing idea

Every reminder moves through explicit states — parsed → scheduled → firing → fired | failed — and workers only ever move it forward. Two components never call each other directly; a queue always sits between them. That makes every transition durable and every failure visible.

The extraction service never computes delays and the scheduler never interprets language. Keeping those apart is what makes each one testable.

§ 04

The system

A message arrives at a secret-verified webhook, is checked against the update id, and is parsed by a rule-based extractor built on chrono-node (capped at 500 characters, resolved forward in the user's timezone). The result must pass a Zod schema before a reminder is stored. A delayed Bull job is then scheduled to fire it; the notifier retries with exponential backoff and, after five attempts, hands the job to a dead-letter queue and marks the reminder failed with a reason.

A repair sweep runs every five minutes and recovers anything stalled in parsed, scheduled or firing.

Diagram 01

From a sentence to a delivered reminder

Input“remind me to drink water in 10 minutes”

Step 1 of 7 · Receive

Telegram delivers the update. The secret header is verified before anything else happens.

LegendNumbered squares are stages; filled means reached in this scenario, dashed means not reached. ◆ marks a stage that is scaffolded, planned or blocked rather than built. Diagram drawn for this site from the Whispr source (webhook.controller.js, ai.service.js, reminder.service.js, scheduler.service.js, queues/). Not an original sketch.
Every stage, in words
  1. 01Receive (POST /webhook/telegram) — Telegram delivers the update. The secret header is verified before anything else happens.
  2. 02Deduplicate (update_id → jobId) — Seen update ids are dropped. The processing job uses the update id as its Bull jobId, so racing requests collapse into one.
  3. 03Interpret (ai.service.js · chrono-node) — Rule-based extraction: normalise “10mins”, find the time expression, resolve it forward in the user's timezone, strip it from the task text.
  4. 04Validate (Zod · AIOutputSchema) — The extracted intent must match the schema. Unrecognised input is rejected here, before anything is stored.
  5. 05Persist (MongoDB · state: parsed) — The reminder is saved. A unique index on (user, platform message id) prevents a second copy.
  6. 06Schedule (Bull delayed job · state: scheduled) — A delayed job is added with jobId = reminder id. If scheduling fails, the reminder stays parsed for the repair sweep.
  7. 07Deliver (notifier · state: fired | failed) — The job fires and the message is sent. Failed sends back off exponentially; after five attempts the job goes to the dead-letter queue.

§ 05

Why duplicate scheduling matters

A duplicate here isn't cosmetic: it is a second notification at 11pm. So deduplication happens at three layers. The webhook drops an update id it has already seen. The processing job is enqueued with the update id as its Bull jobId, so two requests racing each other still produce one job. And the reminder model has a unique index on user and platform message id, so even a third path can't store the same reminder twice.

The fire job uses the reminder's own id as its jobId too. When the repair sweep re-schedules a stalled reminder, it can't create a second job alongside the first.

§ 06

Current state

Live on Telegram, with the Mini App for listing, editing and deleting reminders and setting timezone and locale. Health and readiness endpoints check MongoDB and Redis.

§ 07

Next

  • Asking a clarifying question when a time is ambiguous, instead of guessing.
  • Structured logs per state transition and queue metrics — depth, retry rate, dead-letter count.
  • Recurring reminders, voice-note input and WhatsApp, as listed on the roadmap.