Skip to content

feat(webhook): report WhatsApp event RSVPs as event.response (Baileys) - #1788

Merged
rmyndharis merged 6 commits into
rmyndharis:mainfrom
jelasus:feat/event-response-webhook
Oct 14, 2026
Merged

rmyndharis merged 6 commits into
rmyndharis:mainfrom
jelasus:feat/event-response-webhook

Conversation

@jelasus

@jelasus jelasus commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Baileys already decrypts each RSVP to a WhatsApp event (encEventResponseMessage) from the stored creation message's messageSecret and re-emits it as a messages.update carrying update.eventResponses. The adapter only read update.status there, so every RSVP was lost.

Map those entries to a new engine-neutral EventResponseEvent and publish it as the event.response webhook and WebSocket event: event message id, chat, responder, fromMe, response (going | not_going | maybe | unknown), extra guest count, event name when the creation message is still stored, the RSVP's own message id and its timestamp. The idempotency key is the RSVP id, which is unique per answer.

whatsapp-web.js has no callback behind it, so it accepts the subscription and never fires, like presence.update and the call outcomes.

Also lists the event in the dashboard (15 locales), the JavaScript, Python, Go and Java SDKs, the regenerated openapi.json, docs/06, docs/05, docs/22 and the changelog.

Claude-Session: https://claude.ai/code/session_01MKa6QBXzdC1LHFVs5nBgZd

Description

Brief description of changes

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Checklist

  • Tests added/updated
  • Documentation updated
  • Lint passes
  • Self-reviewed

Screenshots (if applicable)

Related Issues

Closes #

jelasus and others added 2 commits October 8, 2026 03:10
Baileys already decrypts each RSVP to a WhatsApp event (encEventResponseMessage)
from the stored creation message's messageSecret and re-emits it as a
messages.update carrying update.eventResponses. The adapter only read
update.status there, so every RSVP was lost.

Map those entries to a new engine-neutral EventResponseEvent and publish it
as the event.response webhook and WebSocket event: event message id, chat,
responder, fromMe, response (going | not_going | maybe | unknown), extra
guest count, event name when the creation message is still stored, the
RSVP's own message id and its timestamp. The idempotency key is the RSVP id,
which is unique per answer.

whatsapp-web.js has no callback behind it, so it accepts the subscription and
never fires, like presence.update and the call outcomes.

Also lists the event in the dashboard (15 locales), the JavaScript, Python,
Go and Java SDKs, the regenerated openapi.json, docs/06, docs/05, docs/22 and
the changelog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKa6QBXzdC1LHFVs5nBgZd
Look the responder up through engine.getContactById, the lookup behind
GET /contacts/{contactId}, and attach `contact { name?, pushName? }` like
message.received does, so an attendance list can be built from the RSVPs
alone. The lookup is best-effort: an unknown contact or a failed read
publishes the RSVP without `contact`.

RSVPs are chained per engine so a slow lookup cannot reorder a guest's
"going" and "not going".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKa6QBXzdC1LHFVs5nBgZd

@rmyndharis rmyndharis left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, @jelasus. The event wiring is thorough: the webhook and WebSocket catalogs, the idempotency key, the OpenAPI snapshot, the dashboard list with all 15 locales, the four SDK enums and the docs are all in step, and the branch still merges cleanly onto current main. A few things need to change before it can land.

Blocking

  • npx tsc --noEmit -p tsconfig.json, which the Lint job runs, fails on the new spec: src/modules/session/session-event-response-wiring.spec.ts:96, (call[2] as EventResponseEvent) is TS2352. ts-jest does not type-check that cast, so the unit tests pass. (call[2] as unknown as EventResponseEvent) fixes it.
  • 1:1 chats do not deliver RSVPs. Baileys rc14 hands getMessage the event creation key as the responder sees it (lib/Utils/process-message.js:424-433; unlike reaction and poll keys, cleanMessage does not rewrite eventCreationMessageKey). In a 1:1 chat that key's remoteJid is our own account, so the chat cross-check in the getMessage hook (baileys-lifecycle.ts, the differentWaIds(stored.key.remoteJid..., key.remoteJid) guard) rejects the lookup and Baileys emits nothing. Baileys also derives the creator from participant || remoteJid of that same key, so relaxing the guard alone is not enough. The 1:1 test passes because it uses a receiver-view key ({ id: 'EVT2', remoteJid: chat }); a sender-view key reproduces the gap. Either normalise the creation key before the lookup (a small scripts/patch-baileys-* style patch is fine here), or scope the feature and the docs to groups for now and say so.
  • chatId is taken from the creation key (baileys-events.ts, chatId: this.host.toNeutralJid(eventKey.remoteJid)), which is the responder's view. Use the RSVP envelope instead, envelope.remoteJid ?? eventKey.remoteJid, so chat-scoped sockets and chat-restricted keys receive it in the right chat.

Should fix

  • The ordering chain in session-engine-event-wiring.ts (eventResponses = eventResponses.then(...)) has no rejection handler: one throw rejects the chain and silently drops every later RSVP for that engine. Add a .catch that logs inside the chained step.
  • docs/06 says answers given while offline are replayed on reconnect. Baileys buffers updates until the offline marker and merges them per message key (lib/Utils/event-buffer.js:425-444), so several buffered answers to one event collapse to the last. Please state that, and tell consumers to order by timestamp rather than delivery order.
  • Add a case for the new rsvp_{sessionId}_{responseMessageId} key in src/modules/webhook/utils/idempotency.util.spec.ts, like the sibling event keys there.
  • The getContactById enrichment runs for every RSVP even when nobody subscribes, and on Baileys it can trigger a blocklist query; findContact alone is enough for the name.
  • Small docs gaps: the available_events list in docs/12-troubleshooting-faq.md, the event families comment in docs/04-security-design.md, and the third (payload) cell of the new row in the docs/06 catalog table.

Fork CI has not run yet; I will approve the workflows once the branch is updated. When this merges I will tighten the CHANGELOG line to the house style and add your credit, so there is no need to touch that.

An RSVP to an event the account posted in a 1:1 chat names the event
by the guest's key, whose remoteJid is the account itself. The
getMessage chat check refused that lookup, so Baileys could not decrypt
any such RSVP. It now answers for the account's own 1:1 message asked
for under the account's own jid, and the chat is taken from the RSVP's
own key for the same reason.

The responder's name is read from the session's contact store in the
adapter instead of through getContactById, which could query the
blocklist on every RSVP. With no async lookup left in the wiring, the
per-engine promise chain goes too.
Order answers by timestamp: Baileys merges replayed offline updates per
event, so several answers given while offline arrive as the last one.
Also fills the payload cell of the catalog row, states the 1:1 gap, and
lists the event in the FAQ and the security design catalog comment.
@rmyndharis

Copy link
Copy Markdown
Owner

@jelasus I merged current main into your branch and pushed the changes from my review on top, so this can move forward.

  • 1:1 chats, with one correction to what I wrote: when the account posted the event, the guest's creation key carries the account as remoteJid, and Baileys derives the creator from it correctly. Only the getMessage chat check was in the way. It now answers for the account's own 1:1 message asked for under the account's own jid (phone number or lid), and nothing else, so no Baileys patch is needed. The gap that remains is a guest answering an event they posted themselves in a 1:1 chat: Baileys derives the creator from their side and decryption fails. docs/06 states it.
  • chatId and the 1:1 responder now come from the RSVP's own key.
  • The responder's name is read in the adapter from the session's contact store (findContact), so there is no blocklist query per RSVP. With no async lookup left in the wiring, the promise chain is gone, and its missing rejection handler with it.
  • tsc --noEmit passes. The wiring spec now covers the simpler wiring, and the naming cases moved to baileys-events.spec.ts.
  • Added the rsvp_ case to idempotency.util.spec.ts.
  • docs/06: the payload cell is filled in, consumers are told to order by timestamp, and it notes that answers to one event given while offline (even from different guests) collapse to the last one. docs/12 and docs/04 list the event.

Locally the Lint job's gates, test:docs and the full unit suite pass. Fork CI is approved and running. If anything here does not match what you saw in your own testing, please say so.

@rmyndharis
rmyndharis merged commit e965685 into rmyndharis:main Oct 14, 2026
18 checks passed
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.

2 participants