Pure-JS bcrypt for password hashing. Avoids the native-build pain
of node-bcrypt in our Alpine Docker images. Login is a rare event
so the perf gap is irrelevant for our scale.
Replaces the single-threaded, 1.5s-sleep-per-part loop with a
concurrency model that:
* Wraps inner work in PerKeyMutex(accountId) so two reminders on the
SAME account take turns (running them concurrently would double the
effective send rate and risk a WhatsApp ban). Different accounts run
in parallel.
* Bumps pg-boss localConcurrency to BOT_FIRE_CONCURRENCY (default 8),
so up to 8 different-account reminders can fire simultaneously.
* Bulk-loads groups + media in 2 queries (drops ~3000 round-trips to
~3 for a 1000-group run) and pre-creates run_target rows so the
Activity tab shows progress mid-run.
* Pre-uploads each unique media via MediaUploadCache (one
generateWAMessageContent call per mediaId, then relayMessage to
every group). For 1000 groups × 5 MB image, this turns 5 GB of
upload into 5 MB.
* Runs BOT_GROUP_CONCURRENCY (default 3) groups in parallel within
one account; parts within a group stay serial so chat order is
preserved.
* Gates every send on a per-account TokenBucket
(BOT_MAX_SEND_PER_MINUTE, default 40).
* Replaces the rigid 1.5s inter-part sleep with 200..499 ms jitter.
Adds a unit test verifying accountMutex.run is called keyed by
accountId for active reminders, and skipped for inactive / missing.
Window enforcement, paused/resume, and ETA preview are deferred to
later phases.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The web app is now installable on a phone home screen with offline
fallback for static assets and the navigation shell.
Pieces
------
- `src/app/manifest.webmanifest/route.ts` — dynamic manifest route.
Standalone display mode, portrait orientation, dark theme matching
the app, "any maskable" icons so the same PNG works for both
regular launchers and Android adaptive icons.
- `src/pwa/sw.ts` — service worker entry. Uses serwist's stock
recipe: skipWaiting + clientsClaim so a new worker takes over on
the next navigation, navigationPreload to race the network with
the worker boot, and `defaultCache` for HTML-network-first /
static-cache-first / image+font cache TTLs.
- `next.config.ts` — wraps the existing config with `withSerwistInit`.
Disabled in development (`NODE_ENV !== "production"`) because a
service worker on every dev reload makes hot-reload extremely
flaky.
- `package.json` build script switched to `next build --webpack`.
`@serwist/next` doesn't yet support Turbopack (it logs a warning
and silently skips emitting `sw.js`), and Next 16 defaults the
build to Turbopack. The dev server still uses Turbopack — only
production builds switch to webpack.
- `src/app/layout.tsx` metadata gains `manifest`, `icons.icon` (192
+ 512 PNG), and `icons.apple` (180 PNG). The existing
`appleWebApp.capable` already opts iOS into standalone mode.
Icons
-----
Generated by a tiny one-shot script (`scripts/gen-pwa-icons.ts`)
that uses the workspace's already-installed sharp to render an SVG
wordmark at 512 / 192 / 180 px. Placeholder branding (dark square
with "cm" wordmark) — swap in real artwork later by editing the SVG
in the script and re-running `pnpm --filter @cmbot/web run gen:icons`.
Build artefacts
---------------
- `apps/web/public/icon-512.png`, `icon-192.png`,
`apple-touch-icon.png` ARE committed (stable input).
- `apps/web/public/sw.js` and `swe-worker-*.js` are NOT — they're
regenerated on every production build. Added to `.gitignore`.
Verification
------------
- Production build emits `[serwist] Bundling the service worker
script with the URL '/sw.js' and the scope '/'...` and `sw.js`
shows up in `public/`.
- `/manifest.webmanifest` is in the build's static-route table.
- 249 web tests still passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The custom RRULE panel covers most patterns but can't express "every
weekday at 9, 12, and 18" or "every 15 minutes within working hours"
in a single rule. RRULE's BYxxx fields can technically combine, but
the picker UX gets unwieldy fast. Cron expressions cover everything
in one line.
Storage / dispatch
- Cron rules live in the same `reminders.rrule` column with a sentinel
prefix: `CRON:0 9 * * 1-5`. No schema change.
- `@cmbot/shared` now exports:
CRON_PREFIX, isCronRule, stripCronPrefix, validateCronExpression
- `nextOccurrence(rule, tz, after)` and `validateMinInterval(rule, tz)`
detect the prefix and dispatch to `cron-parser`; non-cron rules
continue to flow through rrule unchanged.
- `cron-parser@^5.5.0` added as an explicit dep on @cmbot/shared
(it was already transitively present via pg-boss).
Server actions
- `createReminderAction` / `updateReminderAction`: when rrule has the
CRON: prefix, the user's date+time inputs are ignored — the action
validates the cron, runs the min-interval check (5 min between
fires), and computes scheduledAt as the next match of the cron
expression after now. The bot's existing fire-reminder loop
re-arms via `nextOccurrence` after each fire, which already speaks
cron via the dispatch above.
Picker
- New "Cron expression…" preset at the bottom of the radio list:
"Full sec/min/hour/day/month/dow combinational power"
Selecting it reveals a CronPanel:
* font-mono cron input (5- or 6-field accepted)
* inline examples: 0 9 * * 1-5, */15 * * * *, 0 9,12,18 * * *,
0 0 1 * *
* note that the Date+Time controls above are ignored once a cron
expression is set
- RecurrenceSpec gains an optional `cron` string and a new `kind: "cron"`.
- buildRrule emits `CRON:<expr>` for cron specs.
- specFromRrule round-trips a CRON-prefixed rule back into the spec.
- describeRecurrence renders "Cron: <expr>" so the list view and
review steps show the expression.
Tests (+10; 17 shared + 26 bot + 138 web = 181 total)
- packages/shared rrule.test.ts (+8):
* CRON_PREFIX / isCronRule / stripCronPrefix
* nextOccurrence on a CRON rule returns the right next match in the
operator timezone (e.g. weekday 9 AM KL ↔ exact UTC instant)
* RRULE rules still flow through unchanged
* validateMinInterval on cron: hourly OK, every-minute rejected,
malformed string returns a useful error
* validateCronExpression positive + negative cases
- recurrence.test.ts (+5): cron preset round-trip, label assertions,
`buildRrule`/`specFromRrule` for cron specs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small build-time fixes surfaced when the Docker images first ran
their full production build (previously only dev mode via tsx):
- packages/shared: exclude *.test.ts from tsc (vitest types not needed
for shipped output), add @types/node dep so node:crypto resolves
- packages/db: add @types/node dep for the same reason
- apps/web: pin Next.js Turbopack root to the workspace root via
next.config.ts so the bundler doesn't fail to detect the monorepo
layout from inside the Docker image
The 6.17.x line was returning 406 not-acceptable from WhatsApp's pre-key
endpoint when distributing sender keys to per-device JIDs (e.g.
40471728529510:18@s.whatsapp.net). This blocked every group send
regardless of group size.
Baileys 7.0.0-rc series tracks WhatsApp's current protocol. API is
drop-in compatible — typecheck clean, no source changes needed.
Re-pair required: 6.x signal session files are not portable to 7.x.