Skip to content

feat(webhook): smart pre-dispatch filters + WaId-aware contact matching#2

Open
tobiasstrebitzer wants to merge 5 commits into
feat/baileys-sync-hardeningfrom
feat/smart-webhooks
Open

feat(webhook): smart pre-dispatch filters + WaId-aware contact matching#2
tobiasstrebitzer wants to merge 5 commits into
feat/baileys-sync-hardeningfrom
feat/smart-webhooks

Conversation

@tobiasstrebitzer

Copy link
Copy Markdown
Owner

Smart webhook filters (+ WaId-aware contact matching)

Optional, additive pre-dispatch filters for webhook triggers. A webhook with no filters behaves exactly as before (zero extra clicks, zero behaviour change). When filters are present, every condition must match (AND) for the webhook to fire.

Stacked PR. Based on feat/baileys-sync-hardening (upstream rmyndharis#375), which stacks on feat/typed-waid-349 (upstream rmyndharis#374). Merge order: rmyndharis#374 -> rmyndharis#375 -> this, then retarget onto main. The base is set to feat/baileys-sync-hardening so this diff is just the webhook work (3 commits, 24 files).

Why

Webhook triggers fire on event type only. There are legitimate reasons to pre-filter before delivery (Gmail-forwarding-style rules): only fire when sender = A, body contains "invoice", it is a group, and so on. This cuts webhook congestion and over-emitting and, in the age of AI, controls token/cost downstream. message.* is the natural start; the design is event-family aware so session/group can slot in later without touching the evaluator.

A second motivation is correctness of who a filter matches. WhatsApp addresses the same person through several dialects (@c.us, @s.whatsapp.net, @lid), and a privacy-id (@lid) sender's number is not a phone at all. A naive JID compare silently misses the same contact across dialects, and misses a lid-addressed sender entirely. This resolves ids through the typed-WaId layer + the persistent lid -> phone table from rmyndharis#374, so a phone filter matches the person everywhere.

What ships

  • Fields (message family): sender, recipient, body, type, mentions, isGroup, fromMe, hasMedia. Operators: is/isNot (ids, enums), contains/equals/matches (text, regex), optional caseSensitive. Message-only conditions are skipped on non-message events, so a *-subscribed webhook still fires on session events.
  • Engine-neutral contact matching (WaId). sender/recipient/mentions match by the neutral WaId key, not the raw JID. An engine-emitted id (any dialect, optional :device, a lid the table resolves to its phone) and a user-typed value (bare digits or a JID) collapse to one neutral key, so a phone filter matches across @c.us/@s.whatsapp.net and any @lid resolving to that phone - previously a silent miss.
  • Persistence: nullable filters JSON column on webhooks (portable migration: jsonb on Postgres, text on SQLite, idempotent guard). Exposed as filters on create/update DTOs and the response.
  • Dashboard: a FilterBuilder UI on the Webhooks page (contact picker backed by a session-chats query, enum chips, regex/case-sensitive text). The list-page "N filters" badge opens a hover/focus popover summarising the configured conditions.

Commits

  1. fix(dashboard): scope .modal-body label to direct children - unrelated CSS fix carried along.
  2. feat(webhook): smart pre-dispatch filters - filters subsystem (family-aware field registry, evaluator, class-validator validation), entity column + DTO + migration, dispatch wiring, FilterBuilder UI + badge popover + i18n, unit coverage + a new webhooks e2e suite.
  3. feat(webhook): WaId-aware id matching via the lid->phone table - replaces raw-JID compare with WaId-neutral keys; injects the cross-session LidMappingStore into WebhookService and threads a lid resolver into the evaluator (which stays a pure function, resolver optional).

Design notes

Testing

  • npm run build, npm run lint: clean.
  • npm test: 1010 unit tests. New coverage includes cross-dialect / bare-number / :device matching and a lid-resolution hit + no-resolver miss control (evaluator and dispatch level).
  • npm run test:e2e: 26 (incl. the new webhooks.e2e-spec.ts: CRUD, auth, SSRF reject, HMAC on the wire, header sanitisation, filter behaviour).
  • Dashboard: tsc -b, eslint src, vite build clean.
  • CHANGELOG [Unreleased] entry added.

…essage from-filter

Implements rmyndharis#349 (the typed-WaId + resolution-table follow-up to rmyndharis#342).

- WaId value object (src/engine/identity/wa-id.value.ts): in-memory only, serializes
  byte-identically to today's neutral JID; three-valued refersToSamePerson.
- lid_mappings table on the data connection (global, last-write-wins, nullable phone for
  a negative cache) + portable pg/sqlite migration; LidMappingStore loads on boot and
  writes through, keeping resolvePhone synchronous.
- Back resolvePhone with the table; populate it at runtime from the lid<->phone pairs the
  Baileys engine actually carries: inbound senderPn/participantPn, chats.phoneNumberShare,
  contacts (jid), and history sync.
- from-filter on GET messages that resolves a phone to its lids, so a lid-resolved match
  becomes a hit (closes the silent-miss gap); covered by a test.
- On-demand POST :chatId/history/sync (Baileys fetchMessageHistory) to backfill mappings
  on an already-authed session.
- Canonicalize Baileys contact/chat listing ids to @c.us (read-back folds the neutral id
  back to the engine dialect so send/mark-read round-trip).

RESOLVE_LID_TO_PHONE resolves internally into the table and gates only what is exposed
(privacy flag, not a correctness toggle), per maintainer guidance on rmyndharis#349.
…debug logging

Downstream Baileys-engine hardening, stacked on the typed-WaId work (rmyndharis#349).

- Initial sync: pass shouldSyncHistoryMessage: () => true. Baileys defaults it to
  () => !!syncFullHistory, so with syncFullHistory unset it silently disabled the entire
  initial sync - no contacts, chat list, recent messages, or lid->phone mappings ever
  arrived. The full-archive download stays opt-in via BAILEYS_SYNC_FULL_HISTORY.
- Message history chatId filter resolves across dialects (reuses the from-filter's
  resolveJidCandidates), so a chat keyed @c.us also matches messages stored under
  @s.whatsapp.net - fixes the empty conversation view after contact/chat canonicalization.
- BAILEYS_LOG_LEVEL surfaces the Baileys library's own logs (trace dumps the decoded WA
  wire frames), plus contacts/chats/history-set receipt instrumentation, for diagnosing
  sync issues. Silent by default.
The descendant selector `.modal-body label` leaked into nested labels (e.g.
the filter builder's inputs), restyling controls it shouldn't. Scope it to
`.modal-body > label` so only top-level modal field labels are affected.
Add optional smart filters that refine WHETHER a subscribed webhook fires,
evaluated per event before delivery. Conditions (AND-combined) match on
sender/recipient/body/type/mentions/fromMe/hasMedia/isGroup with
is/isNot/contains/equals/matches operators; message-only conditions are
skipped for non-message events, so a webhook subscribed to several families
behaves sanely without per-event filter sets. A webhook with no filters
behaves exactly as before (additive, zero-config).

- filters/: family-aware field registry, evaluator, class-validator validation
- entity column + DTO field + migration for the persisted filters
- wire evaluateFilters into dispatch matching
- dashboard FilterBuilder UI on the webhooks page (sender picker backed by a
  session-chats query) + i18n strings
- unit coverage for the evaluator/validation and dispatch, plus a webhooks e2e suite
Match id filters (sender/recipient/mentions) by the engine-neutral WaId key
instead of the raw JID, reusing the same identity primitives as the engine and
MessageService. An engine-emitted id (any of @c.us / @s.whatsapp.net / @lid,
optional :device suffix, a lid resolved to its phone) and a user-typed filter
value (bare digits or a JID) both collapse to one neutral key, so:

- a phone filter matches the same person across user dialects, and
- a lid-addressed actor (e.g. an unresolved @lid group participant) now matches
  a phone filter once the persistent lid->phone table knows the mapping -
  previously a silent miss.

WebhookService takes the cross-session LidMappingStore (optional) and threads a
resolver into evaluateFilters at dispatch; the evaluator stays a pure function
(resolver optional) so unit contexts need no store. No new filter fields, no UI
change - the dashboard keeps storing the neutral <digits>@c.us value.
@rmyndharis rmyndharis force-pushed the feat/baileys-sync-hardening branch from 50ccff7 to b5961fa Compare June 20, 2026 09:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant