Skip to content

feat(whatsapp): Evolution Go provider, connection reliability, and inbox UX - #528

Open
karlmarxmint wants to merge 17 commits into
ArnasDon:mainfrom
karlmarxmint:feat/evolution-go-reliability-and-inbox-ux
Open

feat(whatsapp): Evolution Go provider, connection reliability, and inbox UX#528
karlmarxmint wants to merge 17 commits into
ArnasDon:mainfrom
karlmarxmint:feat/evolution-go-reliability-and-inbox-ux

Conversation

@karlmarxmint

Copy link
Copy Markdown

Summary

  • Evolution Go as an alternative WhatsApp provider — self-hosted, whatsmeow-based, no Meta Business account needed. QR pairing, its own inbound webhook, admin-gated instance lifecycle, and a danger-zone conversation reset for switching numbers/providers cleanly. send-message.ts and the automation/flow senders branch on whatsapp_config.provider so messages are indistinguishable in the CRM regardless of transport.
  • Evolution Go connection reliability — a polling health-check (/api/whatsapp/evolution/health-check) detects and recovers a silently-dropped whatsmeow session, backing off between reconnect attempts. Explicit read receipts (sent only when an agent opens the conversation) and inline media upload for outbound sends.
  • Message templates on Evolution Go — no Meta approval step; templates render down to plain text for the send.
  • Inbox UX — "send to pipeline" (create a deal from the current thread), sound/desktop notification for new messages, ordering fixes.
  • i18n — full pt-BR locale (new), a pre-login language switcher, and ko.json parity for all of the above.
  • Three unrelated fixes picked up along the way: time_of_day automation condition now evaluates in the account's business timezone instead of the container's; account-scoped ownership checks on automations that were previously author-scoped; the OTP-based password reset flow (superseded upstream — see note below).

Notes for the reviewer

  • This branch sat unpushed for a while, so it was rebased onto current main (38 commits happened here in the meantime, several overlapping the same files). Two migrations collided on number with ones already merged upstream and were renumbered: 037_whatsapp_provider.sql040_whatsapp_provider.sql, 038_evolution_health_check.sql041_evolution_health_check.sql.
  • A couple of independent fixes converged on the same bugs already fixed upstream (the nested-condition-branch step path in automation-builder.tsx, PR [bug] Send Buttons Issue in Automations Section #474) — the upstream version was kept, this branch's duplicate work was dropped in the rebase.
  • The password-reset-link-to-OTP commit here predates upstream's own auth fixes on main; kept it since Evolution accounts still hit the same Gmail-link-scanner issue, but flagging in case upstream already addressed this differently.
  • npm run typecheck, npm run lint, and npm test (835/835) all pass post-rebase.

Test plan

  • npm run typecheck && npm run lint && npm test
  • Connect an account via Evolution Go (QR pairing) and confirm send/receive both directions
  • Kill the Evolution Go session externally and confirm the health-check reconnects it
  • Send a message template on an Evolution-provider account
  • "Send to pipeline" from an inbox thread creates a deal
  • Switch UI language pre-login via the new language switcher

🤖 Generated with Claude Code

https://claude.ai/code/session_01DP3Ua7KpuW7g4yZ9Gm1eAE

Karl Marx Vieira Pai and others added 15 commits August 26, 2026 13:57
…tive

Lets an account connect via Evolution Go (self-hosted, whatsmeow-based,
unofficial WhatsApp API) instead of Meta — QR pairing, its own inbound
webhook, admin-gated instance lifecycle, and a danger-zone conversation
reset for switching numbers/providers cleanly. send-message.ts and the
automation/flow senders branch on whatsapp_config.provider so messages
are indistinguishable in the CRM regardless of transport.

Also adds a pt-BR locale (full parity with en.json) and a UI language
switcher, independent of account settings so it works pre-login too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…, not container TZ

new Date().getHours() reads the container's own clock — in production
wacrm-app-1 runs with no TZ set (defaults to UTC) while the business
operates on America/Sao_Paulo, so every out-of-office condition was
shifted 3 hours and silently skipped sending during part of the real
off-hours window (confirmed live: automation_logs showed branch=no at
09:56 BRT for a 22:05-10:59 schedule, with no error surfaced anywhere).

Also labels the automation log detail with the real send provider
(Evolution vs Meta) instead of a hardcoded "sent via Meta", since that
made this incident harder to read.
- Add readBodyWithLimit: the Meta and Evolution Go inbound webhooks are
  unauthenticated until after the body is buffered (signature/token
  check needs the raw bytes). Without a cap, an anonymous POST with a
  multi-GB body exhausts the single Node process serving every tenant
  before any auth check runs. Meta payloads cap at 5MB; Evolution Go
  gets 25MB since it inlines media as base64.
- Cap /api/whatsapp/broadcast (dashboard route) at the same
  MAX_RECIPIENTS the public /api/v1/broadcasts already enforces — it
  only rate-limited how often a campaign starts, not how many messages
  one call fans out, so an agent-role account could blast an unbounded
  list and risk the WhatsApp number getting banned for spam.
…ole/ownership checks

- /api/automations/[id] (GET/PATCH/DELETE) and .../duplicate filtered
  by user_id, which is more restrictive than the actual
  automations_update/_delete RLS policy (agent+ on the account, not
  just the creator) — a teammate who didn't author an automation got a
  404 trying to open/edit/duplicate/delete it even though it shows up
  in their own account's list. Switched all four to account_id, matching
  how flows/quick-replies/templates already behave.
- /api/whatsapp/config POST/DELETE had no requireRole('admin'), unlike
  every sibling settings route (templates/sync, evolution/qr, ...).
  RLS already blocks non-admin writes, but the service-role client used
  here to detect cross-user phone_number_id conflicts bypasses RLS, so
  the app needs its own gate too — added canEditSettings() check.
- /api/whatsapp/media/[mediaId] resolved account_id but never checked
  that the requested mediaId actually belongs to a message in one of
  that account's conversations before spending the account's Meta
  token on it. Added a lookup against messages/conversations before
  calling Meta — defense in depth, since relying on Meta's own token
  scoping for cross-tenant isolation was never a documented contract.
- header_media_url (message template validator) accepted http:// and
  https://; the rest of the app (webhook_endpoints) is https-only for
  any server-side fetch of a user-supplied URL. Tightened to https-only
  and fixed the validator's try/catch swallowing its own scheme error
  message.
…h to 8

forgot-password/page.tsx has always redirected to /auth/callback?next=
/reset-password, but neither route existed — every reset-email link
404'd and password recovery didn't actually work. Added:
  - src/app/auth/callback/route.ts: exchanges the emailed PKCE code for
    a session (standard @supabase/ssr Next.js pattern), then redirects
    to `next` (validated as an in-app path, not trusted as-is since it
    rides in a public email link).
  - src/app/(auth)/reset-password/page.tsx: plain updateUser({password})
    form, same call password-form.tsx already uses for the logged-in
    change-password flow.

Also raised the signup minimum password length from 6 to 8, matching
MIN_PASSWORD already used by the settings change-password form —
6 was inconsistent with the rest of the app and had no complexity
backstop. Note: this is a client-side/UX gate only — the authoritative
minimum lives in this Supabase project's Auth settings, which isn't
something this repo controls.
Live-tested the reset flow end-to-end (admin-generated recovery link
-> verify -> session -> password update -> sign-in with new password).
The server-only route.ts committed earlier only handled ?code=... (PKCE)
and would silently fail on this project: a real recovery link comes
back as #access_token=...&refresh_token=...&type=recovery in the URL
*fragment*, which a Route Handler can never see (fragments never reach
the server). Replaced it with a client page that tries the PKCE
exchange first and falls back to reading the fragment + setSession()
when there's no code — covers whichever shape actually fires without
having to trust which flow Supabase picks for a given link.
…s burn links)

Live-tested the link-based flow against a real account after fixing
Site URL / Redirect URLs in Supabase: recovery_sent_at and
last_sign_in_at were 42 seconds apart — far too fast to be the human
opening the email — meaning some automated scanner (common with
Gmail/Outlook/corporate mail security) followed the single-use
recovery link before the user could, burning the token. The user's
own click then correctly got "invalid or expired", with no way to
tell why.

forgot-password/page.tsx now does a two-step flow: request a code,
then verifyOtp({ email, token, type: 'recovery' }) with the code from
the same email. A scanner has nothing to click; the code only works
for whoever actually reads the email and types it in. The link stays
in the email too (redirectTo unchanged) as a fallback via
/auth/callback, but the code is what the UI leads with.

Also caught live: this project's configured OTP length is 8 digits,
not the commonly-assumed 6 — confirmed via admin API's `email_otp`
field before shipping, otherwise the maxLength={6} input would have
silently truncated every real code.
… handling

Evolution Go's whatsmeow session can go silently dead (network blip,
stale websocket) without ever firing a webhook event — confirmed live,
a session sat disconnected ~3h with whatsapp_config.status still
reporting "connected". Adds a polling health-check
(/api/whatsapp/evolution/health-check, cron-secret gated like
/api/automations/cron) that asks Evolution Go's own GET /instance/status
directly and resumes a dropped session with backoff (migration 038
tracks the attempt count/timestamp so a prolonged outage settles into a
steady poll instead of a retry storm).

Also, in the same reliability pass:
- Disable Evolution's auto read-receipt and always-online flags, which
  were marking messages "read" and suppressing the linked phone's push
  notifications before any human looked at anything; wacrm now sends
  the read receipt explicitly when an agent opens a thread
  (/api/whatsapp/evolution/mark-read).
- Upload inbound media (Evolution inlines it as base64, never a URL)
  to the chat-media bucket so it gets a real fetchable URL, including a
  fix for voice notes being rejected outright by the bucket's strict
  mime-type match.
- Handle messages the agent sent from the linked phone directly
  (previously dropped entirely), resolve LID-addressed chats to a real
  phone number instead of fragmenting the conversation, and catch
  group messages that arrive without IsGroup set.
- Cap the webhook body at 25MB against the inlined-media payload shape.
…validation errors

Evolution Go has no template-approval concept — it's a paired WhatsApp
session, not the Cloud API, and any template renders down to plain
text. /submit and PATCH now skip Meta entirely for an Evolution-provider
account (no waba_id check, no resumable-upload header handle, no
Meta network call) and mark the row APPROVED immediately so it's
usable right away, instead of forcing every account through checks
only a Meta account can satisfy.

Also moves template validation errors from thrown plain-English
Error(message) to a TemplateValidationError carrying an i18n code +
values, so a validation failure renders in the user's own language via
Settings.templates instead of always in English. Status labels and
category labels (template-status.ts) move to the same pattern.
…erts

- "Send to pipeline" button on a message opens a dialog to create a
  deal for that contact directly from the thread (send-to-pipeline-dialog.tsx).
- Wires the explicit Evolution read-receipt call (added in the
  reliability commit) into MessageThread: fires when an agent actually
  views a thread, gated on tab visibility rather than document.hasFocus()
  (confirmed live that hasFocus() misreports and can permanently wedge
  the send). Also skips the Meta-only 24h session-timer badge for
  Evolution accounts, which have no such Cloud API restriction.
- New-message chime (synthesized via Web Audio, no asset to ship) plus
  a native desktop notification when the inbox tab isn't the visible
  one, both toggleable and persisted per device. Alerts only on
  customer-sent messages, not an agent's own or a bot reply.
- Conversation list now re-sorts after realtime patches (position was
  frozen at initial fetch order) and sorts a message-less conversation
  to the bottom instead of Postgres's default NULLS FIRST.
- Pipeline deal card's "open conversation" now find-or-creates via
  /api/contacts/[id]/conversation (new) instead of erroring when a
  contact has no conversation left after a reset — race-safe on
  migration 036's unique index.
…eatures

Covers Settings.templates (Evolution-specific copy, validation-error
keys, status/category labels) and Inbox.sendToPipeline /
Inbox.conversationList.*Sound added across the last three commits.
ko.json still needs these keys — messages.test.ts currently fails on it.
… features

messages.test.ts's catalogue-parity check was failing — ko.json was
missing the 76 keys added alongside the Evolution template support and
inbox send-to-pipeline/sound features. Translates them to match.
Covers the Evolution Go reliability, template support, and inbox
features from the last four commits — includes the migration-required
and cron-required notes self-hosters need before/after pulling this.
037_whatsapp_provider.sql and 038_evolution_health_check.sql collided
with unrelated upstream migrations that landed under the same numbers
(037_webhook_broadcast_reliability.sql, 038_broadcast_resume.sql) while
this branch sat unpushed. Renumbered ours to 040/041 (continuing after
upstream's 039) and updated the code comments that pointed at the old
numbers by filename.
- broadcast-resume.ts still built BroadcastPlan with the old
  phoneNumberId/accessToken fields; switch it to resolveEngineProviderCreds
  like broadcast-core.ts and the broadcast route already do post-merge.
- webhook/route.test.ts's fake Request only implemented .text() — the new
  readBodyWithLimit() reads the real body stream, so every test silently
  saw an empty body and never reached the insert/upsert logic. Give the
  fixture a real ReadableStream body.
- pt-BR.json (new in this branch) predates upstream's AccountAccess,
  broadcast-resume, and inbound-media-mirror strings; translate the 19
  keys the catalogue-parity test flagged as missing.
@karlmarxmint
karlmarxmint requested a review from ArnasDon as a code owner August 26, 2026 17:19
Karl Marx Vieira Pai added 2 commits August 26, 2026 16:15
…coded strings

The Flow Builder editor toolbar (header.tsx: Back, flow name, status
chip, Runs/Delete/Pause/Activate/Save, description) had zero i18n —
100% hardcoded English regardless of locale. Wired it through
next-intl under the existing Flows.builder namespace.

Also swept the rest of the flow editor for strings that looked
translated (NODE_META/NODE_CATEGORIES in shared.tsx use dynamic
t(`nodes.${type}.label`) lookups at their call sites, so those were
already fine) but weren't:
- edges.ts's "Next"/"true"/"false" edge labels were hardcoded in a
  framework-free lib module; it now takes an optional translator
  function (defaults to the old English strings, so existing
  callers/tests are unaffected) that flow-canvas.tsx supplies from
  its own next-intl hook.
- node-config-form.tsx: default seed titles for new buttons/list rows,
  the "Remove section" button, the duplicated "Pick a tag…"
  placeholder (now reusing the Flows.builder.form.pickTag key that
  already existed but was never wired to this one), SendListForm's
  body-text label, and the contact-field picker's four raw English
  option words (name/email/phone/company).
- flow-editor-shell.tsx's Canvas/List toggle group aria-label.

New keys added to en.json, ko.json, and pt-BR.json identically so the
catalogue-parity test stays green.
…tent

The three starter flow templates (Welcome menu, FAQ bot, Lead capture)
were plain hardcoded-English data — both the gallery card text (name/
description, shown when picking a template) and, worse, every piece of
customer-facing copy in the flow a template clones (message bodies,
button/row titles, collect_input prompts, handoff notes): cloning a
template always produced a flow that would message customers in
English regardless of the account's locale.

Mirrors the existing src/lib/automations/templates.ts pattern (which
already solved this for automation templates): each template is now a
resolve(t) function built from a translator instead of a literal
object, with the copy living in messages/*.json under
`Flows.templates.<slug>.*`. Both call sites —
GET /api/flows/templates (gallery) and POST /api/flows with
template_slug (clone) — resolve it server-side via
getTranslations('Flows.templates') so the result already matches the
caller's locale.

Two fields carry the flow engine's own {{vars.x}} interpolation syntax
(lead_capture's ask-email prompt and handoff note) — read those with
t.raw() instead of t(), since a plain t() call would try to parse the
double braces as ICU and fail.
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