Skip to content

chore: sync fork with upstream/develop (114 commits) - #76

Open
YJack0000 wants to merge 116 commits into
developfrom
chore/sync-upstream-2026-10
Open

YJack0000 wants to merge 116 commits into
developfrom
chore/sync-upstream-2026-10

Conversation

@YJack0000

Copy link
Copy Markdown
Contributor

Brings the fork up to date with chatwoot/chatwoot develop as of 2026-10-05 (114 upstream commits since the 2026-09-11 sync, upstream ed7b122d2). The biggest pieces for us are two upstream security advisories, email forwarding and richer email replies, account-level two-factor enforcement, bearer authentication for API tokens, contact leads, WhatsApp campaign batch sending, and a batch of conversation-view features (reading mode, collapsible reply box, bulk macros, sorting filtered lists).

What upstream brought that matters to us

Security

  • Two GHSA fixes merged from upstream's private fork: sign-in pre-authentication checks now also run on header-supplied credentials, the SAML password guard normalizes email (7d581dc8c), and macros authorize each conversation before running (aad2791b4).
  • Bulk assignment is restricted to the current account (#16098). Conversation create validates assignee and team (#16104).
  • XSS sanitizing of HTML / markdown output (#14050), escaped mention labels and sanitized article diff panel (#15965).
  • rack-proxy 1.0.3 (#16059), which clears the rack-proxy advisory we were ignoring.

Auth / login (please look)

  • Authorization: Bearer <api token> now authenticates API-token requests the same way the api_access_token header does (#15941). Our SSO deep links and Pathors calls keep working, but anything that sends a Bearer header to /api/v1 with a non-token credential now goes through token auth.
  • Accounts can enforce two-factor authentication (#15877, #16013). When an account turns it on, sign-in runs an MFA-setup step, and API-token requests from users who have not enrolled get 403 mfa_enrollment_required. It is off by default and needs MFA configured on the installation. A Pathors integration user without 2FA would be locked out of an account that enables it.
  • Device verification for password sign-ins (#15879, #15897) is a no-op in CE (the guard is an Enterprise override). It is also off by default.
  • Super admin impersonation is attributed and audited (#15976, #15992, #16079). Auth throttles read JSON bodies (#15939). MAX_USER_SESSIONS is read dynamically (#15062).
  • Login now carries upstream's redirect_url (Shopify install / billing) next to our sso_route_path and resume-after-login path. See the conflict notes.

Tickets / help center portal

  • Unknown portal slugs return 404 instead of 500 (#15798). Article slugs are editable (#15896). No upstream change touched our ticket model, ticket tasks or the portal ticket pages.

Voice / calls

  • The Twilio voice fixes (#15961, and the EE half of #15958) live in the EE voice stack we removed in fix(calls): make CE Call model authoritative and remove EE voice stack #10, so they are not taken.
  • Taken: Twilio API-key credentials for recording and media downloads in Channel::TwilioSms / Twilio::MediaDownloadService (#15958). The voice-call bubble also registers a ringing call before joining it (#15795). That path is gated off for Pathors calls, so Pathors takeover is unchanged.

Inbox channels

  • Email: forward emails from email conversations (#15871), tables in replies (#15830), a better quoted-content experience (#15746), email subjects shown and searchable (#16046), SMTP without IMAP (#15783), blank Reply-To skipped on IMAP (#16020), and a translated continuity email body (#16103). The IMAP reauthorization change (#15812) was reverted upstream (#15823), so the net effect is zero.
  • WhatsApp: attachments go through the media endpoint (#15702), contact-info requests work again for BSUID conversations (#15998, #15826), the HUMAN_AGENT tag applies only outside the 24-hour window (#15932), and one-off campaigns send in cursor batches (#15854).
  • Facebook Messenger postbacks (#14115) and Graph profile names (#15841). Twilio keeps quoted replies (#15838). Widget DirectUpload CSRF is fixed (#13447), and crawlers no longer create widget contacts (#15875).

Contacts / companies

  • Contacts are classified as leads on create and update (#16077). contact_type is in contact webhooks and live updates (#16089). Merge keeps the higher contact type. Conversation create marks a visitor contact as a lead.
  • The contacts list defaults to most-recent activity first (#15827) and history ordering is restored (#15950). You can navigate between a contact's conversations from the thread (#15427).
  • Companies is now a core, non-premium feature (#15963), and its pages were redesigned (#16015). features.yml marks it enabled: true and drops premium. Before this sync it was premium, so chore(ee): hide Chatwoot Enterprise features from the white-label install #51 hid it from super admin. Now super admins can see and toggle it. Whether new accounts start with it on depends on how ConfigLoader reconciles the stored ACCOUNT_LEVEL_FEATURE_DEFAULTS: reconcile_only_new keeps the stored false. No migration flips it on existing accounts. Please check the deployed defaults if we do not want Companies on.

Integrations

  • Shopify app-store onboarding and billing (#13550, #16084, #15899) touch the login router and auth helpers. Slack thread avatars and sender labels (#16140, #16141). Captain toolset manifests (#15995 to #15997) are EE.

Conversations

  • Reading mode for older conversations (#15938), a collapsible reply box (#16074), a macro applied to many conversations (#16027), sorting of filtered conversations (#15753), timezone-aware date filters (#15843), label and priority suggestions (#15964, CE controller at conversations/:id/suggestions), AI reply takeover (#15438), and dashboard alerts on bot handoff (#15763).

i18n

  • Crowdin sync (#15848) and widget ET files (#14701). New upstream keys land in zh_TW as English until Crowdin fills them.

Not visible in our build

  • Conversation monitors and monitor-triggered automations (#15966, #16011, #16030, #16055, #16132), campaign analytics, company enrichment and the Captain classifier are premium flags or need isEnterprise. chore(ee): hide Chatwoot Enterprise features from the white-label install #51 keeps premium flags hidden. captain_classifier is not marked premium, so it does appear in the super admin feature list.

Conflicts and how they were resolved

50 files conflicted (35 content, 15 modify/delete).

  • EE voice stack (12 modify/delete). conference_controller, twilio/voice_controller, Voice::CallStatus::Manager, the Twilio adapter, conference and recording services, and their specs stay deleted, as in fix(calls): make CE Call model authoritative and remove EE voice stack #10. Upstream's new voice/call_status/manager_spec.rb is dropped too, because it tests a class we do not ship.
  • spec/lib/chatwoot_app_spec.rb (modify/delete). Stays deleted (test: drop the EE licensing spec that cannot run in the FOSS build #49). It stubs EE licensing that the FOSS build cannot load.
  • Campaigns (campaigns.routes.js, CampaignCard.vue, CampaignList.vue, and modify/delete on WhatsAppCampaignsPage.vue / WhatsAppCampaignAnalyticsPage.vue). We keep our broadcast / proactive / templates pages (feat(campaigns): restructure outbound campaigns into broadcast, proactive and templates #22). Upstream's full-page WhatsApp campaign form (#15409), WhatsApp campaign list and analytics pages (#15854) are not taken. Neither are the eight components-next/Campaigns/WhatsAppCampaign/* components and the specs that only those pages used. This is the same call as the last sync: the analytics API is enterprise/-only. CampaignMessage.vue still links to the analytics route behind canViewAnalytics, which requires isEnterprise, so the link never renders here.
  • Whatsapp::OneoffCampaignService (core and EE). Takes upstream's cursor batching and keeps our blocked-contact filter (fix(campaigns): skip blocked contacts in WhatsApp one-off campaigns #66) on the batch query. Upstream removed the EE create_recipients / process_recipients, and we follow. Our two blocked-contact specs now drain the enqueued batch job.
  • spec/models/account_spec.rb / config/features.yml. feature_flags_ext_1 keeps our bits: tickets is 6 and audit_log_ip_address is 7. Upstream's captain_classifier, conversation_monitors, campaign_analytics and company_enrichment take bits 8 to 11, where upstream uses 7 to 10. That is fine because our production data only has bits we own set.
  • db/schema.rb. Our header (Schema[7.2], version 2026_09_30_120000) is kept. Both foreign-key sets are kept (ours: campaign_templates; upstream's: conversation_monitor_*).
  • config/routes.rb. Our ticket / tasks routes and upstream's suggestions routes are both kept.
  • app/listeners/action_cable_listener.rb. Our broadcast_ticket and upstream's broadcast_to_inbox_members are both kept.
  • app/models/conversation.rb. Our prioritize_vip_sender / apply_sender_triage_labels and upstream's mark_contact_as_lead are both kept.
  • spec/actions/contact_merge_action_spec.rb. Our calls-move context and upstream's contact-type context are both kept.
  • .rubocop.yml. Both ClassLength exclusions are kept.
  • .bundler-audit.yml. Takes upstream's block, which ignores CVE-2026-67991, CVE-2026-67987 and CVE-2026-67989 (ruby_llm). Our GHSA-42qh-8mx8-7wqm (rack-proxy) entry is gone. Its own comment said to remove it once the lock resolves rack-proxy >= 1.0.3, and it now resolves 1.0.3. Nothing else in the file was edited.
  • package.json. Keeps our sorted @chatwoot/pico-search and takes upstream's @chatwoot/prosemirror-schema 1.4.6. pnpm install --frozen-lockfile passes.
  • Login (routes/index.js, v3/api/auth.js, v3/helpers/AuthHelper.js, v3/views/login/Index.vue, constants/sessionStorage.js). Our ssoRoutePath and resume-after-login path sit next to upstream's redirectUrl / Shopify / MFA-setup flow. An unauthenticated visit to a resumeAfterLogin route stores its path first, then goes to upstream's computed login URL.
  • store/modules/conversations/actions.js. Both import sets are kept.
  • ReplyBoxBanner.vue. Takes upstream's assign-first-then-reopen logic (#15438) and keeps hiding the bot-handoff banner while a Pathors call is live (feat(voice): 列表釘選 AI 通話中、泡泡即時逐字稿與警示,接手後拿掉「交回 AI」 #74). Upstream's new spec gets a Pinia instance plus two cases that cover the live-call rule.
  • Switch.vue, TabBar.vue, ConversationBasicFilter.vue, CompanySortMenu.vue. These keep our reka-ui primitives (feat(ui): headless primitives (reka-ui) inside the base components, motion tokens #53) and teleporting Popover (feat(contacts): quick filter bar, and popovers that never hide under the sidebar #68). Upstream's changes are ported in: the disabled switch, tab icons and truncation, and the showStatusFilter toggle. Upstream's absolute-positioned dropdowns and the hand-rolled switch button are dropped. CompanySortMenu.vue ends up identical to ours, because upstream only moved the dropdown anchor, which our Popover already does.
  • Sidebar (Sidebar.vue, SidebarGroup.vue, SidebarGroupHeader.vue). These keep our multi-open animated groups and badges (feat(dashboard): attention badges in the sidebar and a needs-attention strip on tickets #35, feat(sidebar): keep several groups open, animate them, and show category counts #50). Upstream's open-in-new-tab links (#15833) and the Monitors entry under Reports (#15966) are added. The group header is now a real <a href>. Cmd/Ctrl-click opens a new tab, and a plain click still navigates in-app and expands the group. The chevron still only folds. The collapsed rail icon now links to the group's first child.
  • zh_TW locales (calls, campaign, generalSettings, inboxMgmt, whatsappTemplateMgmt) and en/campaign.json. These were merged three-way at the JSON key level. Our translations win where both sides changed a key (95 keys in inboxMgmt, all upstream English or mainland wording). Upstream's CAMPAIGN.WHATSAPP / CAMPAIGN.HISTORY keys are added because CampaignMessage.vue reads them. Six INBOX_MGMT keys that upstream reset to English after rewording the en source were retranslated: SEARCH_PLACEHOLDER, MESSAGING_LIMIT_TIER.LABEL, and the PENDING_REVIEW, AVAILABLE_WITHOUT_REVIEW, REJECTED and DECLINED statuses.

A second commit adapts three of our frontend specs to upstream changes. The new integrations.routes.spec mocks our Pathors.vue. The resume-after-login spec passes a route query. The contact quick-filter spec expects the timezone that upstream now attaches to last_activity_at conditions.

New migrations (apply by hand with 30-migrate-job.yaml)

All 11 are upstream's. Their versions are older than our latest (20260930120000), and db:migrate runs them as pending because production schema_migrations does not list them. None of them collides with a fork migration version.

  • 20260907092035_add_assistant_to_captain_custom_tools
  • 20260907092039_backfill_captain_custom_tool_assistants (data backfill, a no-op without Captain custom tools)
  • 20260917000000_add_device_trust_version_to_users
  • 20260922000000_create_conversation_monitors
  • 20260922090000_add_contact_inbox_to_campaign_recipients
  • 20260924000000_add_icon_to_conversation_monitors
  • 20260924100000_add_headers_to_captain_custom_tools
  • 20260924120000_add_source_metadata_to_captain_custom_tools
  • 20260925000000_add_monitor_automation_events (adds automation_rules.monitor_id / monitor_event_activated_at and a monitor deliveries table)
  • 20260925000001_add_monitor_index_to_automation_rules
  • 20260929100000_add_announcement_fields_to_platform_banners

security-scan

The ruby_llm advisories CVE-2026-67987 and CVE-2026-67989 (and CVE-2026-67991) are not fixed by this sync. Upstream still pins ai-agents 0.12.0, which pins ruby_llm ~> 1.14, and the lock resolves ruby_llm 1.15.0. Upstream's own .bundler-audit.yml, merged here unchanged, now ignores all three. Locally, bundle-audit update && bundle-audit check against the advisory DB of 2026-10-05 reports "No vulnerabilities found", so the bundle-audit step of security-scan should go green on this branch. That is green because the advisories are suppressed, not because they are patched. Brakeman stays non-blocking.

Verification

Ruby ran in the chatwoot:development image (Ruby 3.4.4) with pgvector pg16 and Redis, and bundle install for the new aws-sdk-sesv2 and rack-proxy 1.0.3.

  • db:schema:load of the merged schema works. Starting from develop's schema, running the 11 upstream migrations and dumping gives the same schema.rb as committed, apart from column order in two monitor tables. That order is upstream's own.
  • RSpec, CE (DISABLE_ENTERPRISE=true), every spec for a conflicted or fork-touched file: 865 examples, 0 failures, 4 pending (MFA not configured).
  • RSpec, the remaining 49 fork-owned spec files (Pathors, tickets, portal, voice, campaign templates, sender lists, Twenty CRM and more): 866 examples. 40 portal HTML examples failed only with ViteRuby::MissingEntrypointError. With a prebuilt vite build --mode test the two portal files pass (62 examples, 0 failures).
  • RSpec, super admin and dashboard controllers with prebuilt assets: 99 examples, 0 failures.
  • RSpec, upstream's two security fixes (sessions controller, macros job) plus the conversations controller: 153 examples, 0 failures, 21 pending.
  • RSpec, EE campaign specs with enterprise enabled (WhatsApp one-off service, campaign recipient, the EE listener, account, conversation and message specs): 113 examples, 0 failures.
  • RuboCop on the conflicted Ruby files: clean.
  • pnpm install --frozen-lockfile (Node 24.13, pnpm 10): passes.
  • ESLint, whole repo: 0 errors.
  • Vitest, full suite, TZ=UTC: 519 files, 5348 tests passed.
  • vite build: succeeds.

Not verified

  • No browser click-through of the merged UI (sidebar header links, reading mode, collapsible reply box, login with sso_route_path and with redirect_url).
  • No end-to-end run against a live Pathors backend (voice takeover, SSO deep link).
  • No full backend suite. CI's backend-tests shards cover the rest.
  • Repo-wide RuboCop in the container reports 54 Rails/Output / Rails/Exit offenses. They are all in files this merge does not touch (script/, spec/rails_helper.rb, docker/entrypoints/), so I left them alone. CI's lint-backend is the judge.
  • Commits were made with --no-verify. Lint and tests were run by hand as listed above.

🤖 Generated with Claude Code

scmmishra and others added 30 commits September 14, 2026 13:20
Fixes the backend FOSS CI failures caused by the paid self-hosted plan
specs added in #15760. PRs containing those specs can fail before the
plan checks even run.

The FOSS job removes Enterprise code. These tests then pretend
Enterprise is installed, and loading `ChatwootHub` for the first time
tries to load the missing Enterprise extension. That crashes with
`undefined method 'const_defined?' for false`.

The fix explicitly loads `ChatwootHub` before the tests override the
edition checks. This lets it load using the actual installation state,
then allows each test to simulate its plan as intended.
Date filters now keep conversations on the calendar day the user
expects. Applying or saving a filter records the browser's timezone, so
a conversation started late in the evening is no longer excluded just
because its UTC date is already the next day.

### The bug

A conversation created on **September 7 at 9:49 PM in Brasília** is
stored as **September 8 at 00:49 UTC**. A folder with `Created at >
September 6` and `Created at < September 8` compared the UTC date and
excluded it. The September 7 report used the viewer's timezone and
included it, leaving the folder with three conversations while the
report showed four.

### The fix

- Save the browser's IANA timezone with `Created at` and `Last activity
at` filter conditions. Named timezones account for daylight-saving
changes on the date being filtered.
- Convert stored timestamps to that timezone before comparing calendar
dates, and use the same timezone for `Days before`.
- Apply the same date comparison in frontend filtering. Saved-folder
unread counts use the same backend conditions.
- Preserve the saved timezone when editing folders or contact segments.
The shared date-filter path also fixes contact timestamp filters.

Existing folders and API filters without a timezone retain their
previous UTC behavior. **Open and update an existing folder once to
adopt timezone-aware filtering.** Date-only custom attributes are not
timezone-converted, and automation payload generation keeps its existing
behavior.

### How to reproduce

1. Use a browser in `America/Sao_Paulo` and a conversation created at
`2026-09-08 00:49 UTC`, which appears as September 7 at 9:49 PM locally.
2. Apply `Status = All`, `Created at > September 6`, and `Created at <
September 8`, then save the folder.
3. Before this fix, the conversation is missing even though it appears
in the September 7 report. With this fix, it appears in the folder and
stays there after reloading.
4. Edit the saved folder and update its name. Its timezone and matching
conversations should stay the same.

### Closes

Support report:
https://app.chatwoot.com/app/accounts/1/conversations/91709
…#15812)

Google, Microsoft, and legacy Google email inboxes now prompt for
reconnection after repeated IMAP authentication rejections. Previously,
these failures were only logged, leaving affected inboxes unable to
receive email without showing a reconnection prompt.

Counts explicit authentication failures, Gmail's invalid-credentials
response, and Microsoft's code-less authentication rejection toward the
existing 10-error threshold. Temporary failures and other IMAP providers
retain their existing behavior.
Date filters return to their previous UTC-based behavior. This reverts
the timezone-aware calendar-date handling introduced in #15784 across
saved filters, live filtering, unread counts, and the filter API schema.

Reverts #15784
Email inboxes can now configure custom SMTP while using forwarding for
incoming mail. Disabling IMAP also preserves the existing SMTP
configuration, so changing the inbound method does not disable outbound
delivery.

## What changed

- Show SMTP settings for email inboxes independently of IMAP.
- Keep SMTP settings untouched when saving IMAP configuration.
- Replace the IMAP prerequisite message with guidance about receiving
mail through IMAP or forwarding.
- Add regression coverage for SMTP visibility and preserving SMTP when
disabling IMAP.

## How to test

1. Open an email inbox that receives mail through forwarding, with IMAP
disabled.
2. Open Configuration and confirm SMTP settings are available.
3. Configure a valid SMTP provider, save, and reload. Confirm IMAP stays
disabled and SMTP stays enabled.
4. Send a reply and confirm delivery through the configured SMTP
provider.
5. In an inbox with both IMAP and SMTP enabled, disable IMAP and save.
Reload and confirm SMTP remains enabled.
Fixes advanced-filter totals reverting to open-conversation counts when
requests finish out of order. Count updates now follow request order
within the active view, preserving newer event refreshes and allowing
useful metadata updates when a list refresh fails.

Adds regression coverage for request ordering, failures, pagination, and
cached assignee-tab switches.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
…g (#15826)

Temporarily hide the WhatsApp Request Contact Info action in
conversation responses to remove message-history lookups from routine
inbox browsing. Conversation payloads keep the existing fields but
return `available: false`, `reason: null`, and `delivery_mode: null`.

This is an alternative to #15825. Instead of displaying an action that
can later fail because of a pending request in older history, this
mitigation hides the dedicated action entirely while a cheaper
pending-state approach is considered.

## What changed

- Return disabled contact-info availability from the shared conversation
partial without invoking eligibility checks.
- Disconnect the batch lookup from list rendering while retaining the
service code for re-enabling the feature.
- Preserve validation for direct API requests and manually selected
contact-info templates. This disables the dedicated UI action, not the
backend capability.

## How to reproduce

Open a WhatsApp Cloud conversation for a BSUID contact without a phone
number. Before this change, conversation rendering checks message
history to determine whether to show or disable Request Contact Info.
After this change, the dedicated action is hidden and rendering no
longer runs that lookup.

Check conversation lists, filtered lists, contact conversation lists,
and individual conversation responses. Each should return the disabled
contact-info payload.
…t (#15827)

The Contacts page could fail to load on larger accounts. When a user has
never picked a sort, the list asked for contacts ordered by **oldest**
activity first. No index can serve that order, so every page load read
every contact in the account just to return 15. When those rows aren't
cached, or the database disk is under load, that runs past the 14s
statement timeout and the page never loads. The page now defaults to
**most recent** activity first. The existing `(account_id,
last_activity_at DESC NULLS LAST)` index serves that order, so a page
load reads about 20 pages whatever the account's size. Users who picked
a sort keep it.

## Closes

- No existing issue. Found via recurring `PG::QueryCanceled` /
`Rack::Timeout` errors on `contacts#index`.

## How to reproduce

1. On a large account, open **Contacts** as a user who has never changed
the sort.
2. The page sends `GET
/api/v1/accounts/:id/contacts?sort=last_activity_at`, which becomes
`ORDER BY last_activity_at ASC NULLS LAST LIMIT 15`.
3. Postgres fetches and sorts every listed contact of the account. When
those pages come from disk, the request hits the statement timeout.

## What changed

- `ContactsIndex.vue`: when no `contacts_sort_by` UI setting is saved,
the default sort is now `-last_activity_at` instead of
`last_activity_at`.
- Measured on production for a ~57k-contact account:
- old default: 57,768 pages read per load (56 ms cached, about 24s from
disk at the measured ~0.42 ms per page);
  - new default: an index scan that stops after 15 rows.
- Contact search uses the same plan for either sort direction (trigram
bitmap scan, then a sort of the matches), so it is unaffected.
- Known trade-off: on accounts made up mostly of anonymous visitors, the
new order walks more of the activity index before finding 15 listed
contacts. A partial index restricted to listed contacts removes that
cost if it shows up.

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
# Pull Request Template

## Description

Sign-in and sign-out write one audit row per account the user belongs to
using `insert_all!`, which bypasses the `after_create_commit` that
enqueues the IP geolocation lookup added in #15455. Session audits were
the only rows that never got a city or country.

Restoring the callback would undo #15572, which took a 500-account
sign-in from 2016 queries to 519. Since every row from one sign-in
shares a `remote_address` and `request_uuid`, this enqueues a single job
per sign-in that resolves the address once and applies it to that
request's rows in one `update_all`, scoped to accounts with `ip_lookup`
enabled. Constant cost regardless of membership count.

The second commit fixes a defect found while testing. `audit_event_rows`
used `::Audited.store[:current_request_uuid] || SecureRandom.uuid`.
Rails strips an inbound `X-Request-Id` to an empty string when it holds
only punctuation, and `""` is truthy, so those rows were written with a
blank uuid and the new job would skip the batch. Now uses `.presence`.

Fixes https://linear.app/chatwoot/issue/CW-8135

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

New job spec: one lookup applied to every row of a request, isolation
from other requests, a batch spanning eligible and ineligible accounts,
no lookup for ineligible or empty scopes, error swallowing.

Session controller spec: exactly one job enqueued for a user in four
accounts, and rows still share a generated uuid when the request id
sanitises to blank. Verified that spec fails without the `.presence`
fix.

27 examples, 0 failures locally. Rubocop clean.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
## Description

Agents can now use the existing conversation sort menu while viewing
conversations narrowed by advanced filters or saved folders. Changing
the order refetches the filtered result set before pagination, so
oldest/newest, activity, unread, priority, and waiting-time ordering
remain consistent across loaded pages.

## Closes

-
[CW-7862](https://linear.app/chatwoot/issue/CW-7862/lack-of-sorting-options-when-using-filters-in-conversations-tab)

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [x] This change requires a documentation update

## What changed

- Keep the existing order menu available for ad-hoc filtered and
saved-folder conversation lists while hiding the conflicting status
selector.
- Share the server-side sort whitelist between standard and filtered
conversation queries, preserving existing defaults and legacy aliases.
- Send the selected sort through filtered fetches, pagination, and
reconnect refreshes, with client-side ordering aligned to the server.
- Document the optional `sort_by` parameter for the conversation filter
API.

## How to test

1. Open Conversations and apply an advanced filter.
2. Open the sort menu and confirm the existing order options are
available while the status selector is hidden.
3. Change between oldest/newest, unread, priority, and waiting-time
options and confirm the filtered list refetches in the selected order.
4. Open a saved conversation folder and confirm the same sorting
behavior, including after loading another page.

## Checklist

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Incoming WhatsApp replies through Twilio lose their quoted-message
context, leaving agents unable to tell which earlier message the
customer means. This change displays the referenced message when it
exists in the same conversation.

The Twilio callback now accepts `OriginalRepliedMessageSid` and passes
it to the existing message reply resolver as `in_reply_to_external_id`.

### Things to know

- Applies to incoming quoted replies. Sending quoted replies through
Twilio remains unchanged.
- Uses the existing same-conversation lookup; references to unavailable
messages retain the existing unresolved-reply behavior.
- Previously received messages are not backfilled.

### How to reproduce

1. In a Twilio WhatsApp conversation, reply to an earlier message using
WhatsApp's reply action.
2. Before this fix, Chatwoot displays the reply without the quoted
message even when Twilio supplies its SID.

### How to test

1. Reply to a recent message in an existing Twilio WhatsApp
conversation.
2. Confirm the incoming reply displays the original message in its quote
preview.
3. Click the quote preview to navigate to the original message.

Locally verified by posting a mock Twilio webhook to the callback
endpoint: the incoming message persisted both reply identifiers and
displayed the original message in the conversation UI. Regression
coverage checks that the controller forwards the quoted SID and the
incoming-message service persists both reply identifiers. The focused
Twilio suite passed with 52 examples and no failures; RuboCop and diff
checks also passed.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
… is not in the store (#15795)

Answering an incoming WhatsApp call from the call bubble in the
conversation could show "Internal Server Error", and the caller then saw
the call as missed. This happened when the call wasn't in the agent's
calls store: it rang while the agent wasn't online, or it was dismissed
earlier. In that case the dashboard fell back to the Twilio flow and
requested a Twilio token for the WhatsApp inbox.

## Closes

- https://linear.app/chatwoot/issue/CW-8174

## How to reproduce

1. Set your availability to Busy or Offline and receive a WhatsApp call
on an inbox with calling enabled.
2. Open the conversation and click Answer on the ringing call bubble.
3. Before: "Internal Server Error" and the call ends as missed. After:
the call connects.

## What changed

- The call bubble adds the call to the calls store from its own message
data before joining, so the WhatsApp answer path is used.
- `ConferenceController` only resolves Twilio inboxes, so the Twilio
token and conference endpoints return 404 for other inboxes instead of
500.
# Pull Request Template

## Description

This PR fixes sidebar navigation items not behaving like real links.
Middle-click, Cmd/Ctrl-click, and the browser’s “Open link in new tab”
action now work as expected.

Fixes
https://linear.app/chatwoot/issue/CW-7868/impossibility-to-open-navigation-menu-items-in-a-new-window-or-new-tab

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

### Screenshots

**Before**
<img width="369" height="387" alt="image"
src="https://github.com/user-attachments/assets/280b540a-1c4c-4f7c-b459-17ea8823fd67"
/>


**After**
<img width="369" height="387" alt="image"
src="https://github.com/user-attachments/assets/87a02578-7064-41ae-abd9-2f6bd4cb5a5e"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
## Description

Restore the dependency-audit CI check by adding a documented exception
for RubyLLM's CVE-2026-67991 on Chatwoot's supported runtime. The change
adds only this advisory to `.bundler-audit.yml`, with the applicability
rationale and removal condition. RubyLLM remains at 1.15.0 and every
other advisory remains subject to the existing audit configuration.

### Advisory verification and applicability

[CVE-2026-67991 /
GHSA-42r3-x6vx-x49x](https://github.com/rubysec/ruby-advisory-db/blob/44784c295391577f25d198a9205eae4ba73ec4da/gems/ruby_llm/CVE-2026-67991.yml)
describes polynomial-time name normalization on Ruby 3.1.x when a very
long crafted class, agent, or tool name reaches the affected operation.
The released 1.15.0 gem contains the regex inline in
`RubyLLM::Tool#name`; the advisory's later `Utils.underscore` reference
does not mean released gems were unaffected.

The exception is based on the application's runtime and actual input
paths:

- **Runtime:** the Gemfile, `.ruby-version`, CircleCI and Docker images
specify Ruby 3.4.4. A bounded local probe of the installed `Tool#name`
method reproduced quadratic behavior on Ruby 3.1.0, but linear behavior
on Ruby 3.4.4. The latter also reports this regex as linear-time,
consistent with [Ruby's regex memoization introduced in
3.2](https://www.ruby-lang.org/en/news/2022/12/25/ruby-3-2-0-released/).
- **Built-in tools:** names come from fixed application classes, rather
than user-generated class names.
- **Custom tools:** [slugs are limited to 64
characters](https://github.com/chatwoot/chatwoot/blob/e16d8f2fa4bd00c65747de0b8363f0e05092e36c/enterprise/app/models/captain/custom_tool.rb#L38-L65).
[Toolable overrides the name
method](https://github.com/chatwoot/chatwoot/blob/e16d8f2fa4bd00c65747de0b8363f0e05092e36c/enterprise/app/models/concerns/toolable.rb#L8-L25)
to return that slug directly, bypassing RubyLLM's normalizer.
- **Agent handoffs:** Chatwoot uses `Agents::Agent` from ai-agents; its
handoff tools override naming with a separate lowercase/filter
conversion. Scenario handoff names also have a length budget. Ordinary
prompt/message text does not automatically enter the affected
normalization operation.

Local timings for synthetic all-capital class names:

| Characters | Ruby 3.1.0 | Ruby 3.4.4 |
| --- | ---: | ---: |
| 5,000 | 104 ms | 0.85 ms |
| 10,000 | 430 ms | 1.72 ms |
| 20,000 | 1,676 ms | 3.31 ms |

These measurements characterize the reported regex behavior, not
production capacity. Ruby 3.1.0 was used only for the isolated method
probe; Chatwoot itself was not run on that unsupported version. Live
deployment runtimes were not remotely inspected.

### Why this PR does not upgrade RubyLLM

As of 16 September 2026, stable 1.16.0 still contains the pattern and
still matches the advisory. The [upstream
fix](crmne/ruby_llm@9d75b03)
is released starting with 2.0.0.rc1; the latest release is 2.0.0.rc4,
also a prerelease.

`ai-agents 0.12.0` requires `ruby_llm ~> 1.14`. An isolated Bundler
resolution rejects 2.0.0.rc4. Bypassing the constraint for a
compatibility probe makes `require 'agents'` fail on the removed `param`
method. Chatwoot [requires agents unconditionally during
initialization](https://github.com/chatwoot/chatwoot/blob/e16d8f2fa4bd00c65747de0b8363f0e05092e36c/config/initializers/ai_agents.rb#L1-L3),
so simply widening that constraint can prevent application startup,
including when Captain is unused.

The [2.0
migration](https://github.com/crmne/ruby_llm/blob/v2.0.0.rc4/docs/_reference/upgrading.md)
also affects existing Chatwoot behavior:

| Surface | Required migration |
| --- | --- |
| Captain V2 / scenario handoffs | Replace removed `Tool::Halt`, update
instruction/tool replacement APIs, and preserve runner state and handoff
control. |
| Built-in/custom tools and Copilot | Update `param`/`params`,
parameter-schema readers, tool registration, provider options and
callbacks. |
| Rewrites, replies, summaries, labels and other Captain tasks | Replace
direct token readers; current response conversion can otherwise fail
after a successful provider response. |
| Images and conversation replay | Replace `RubyLLM::Content` with the
new attachment/message API. |
| Structured generation | Read parsed output through `response.parsed`;
current Hash consumers can otherwise produce empty overview points,
blank article fields or raw JSON taglines. |
| OpenAI-compatible endpoints | Make protocol selection explicit: 2.0
defaults to `/responses` instead of `/chat/completions`. Existing
`response_format` options and custom endpoints need adaptation or an
explicit Chat Completions protocol. |
| Request behavior / observability | Validate temperature settings,
token/cost tracing and the renamed model-registry refresh method. |

The isolated request probe confirmed the protocol change and that a
GPT-5.2 request configured with temperature 0.5 sends 1.0 under 1.15 and
0.5 under rc4. No live provider requests were made.

There is no `acts_as_chat`/`acts_as_message` persistence integration in
Chatwoot, so the upstream Rails data migration does not automatically
apply. Embedding/moderation readers used here still exist, and legacy
speech/PDF operations use the separate OpenAI client. RubyLLM remains
MIT licensed. A supported upgrade should be coordinated with ai-agents
instead of being folded into this audit exception.

### Removal condition

Remove the exception when Chatwoot adopts a supported patched
RubyLLM/ai-agents combination, including a recognized compatible 1.x
backport if one becomes available. Reassess it if the supported Ruby
runtime or tool-name construction changes. This records advisory
applicability; it does not claim that RubyLLM 1.15.0 includes the
upstream fix.

The original audit failure was reproduced against ruby-advisory-db
`44784c295391577f25d198a9205eae4ba73ec4da`. The actual changed
configuration now passes the refreshed audit, YAML formatting and
whitespace checks. The prior investigation passed 528 existing focused
specs on this application revision; after adding the exception, all 131
selected Captain base-task, custom-tool and scenario specs passed again.
Existing Rails enum deprecation warnings remain unchanged. No new specs
were added for the configuration-only change.

## Closes

No linked ticket.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How to reproduce

1. On the parent commit, run the CircleCI dependency-audit check with an
updated ruby-advisory-db. It reports RubyLLM 1.15.0 / CVE-2026-67991 and
exits unsuccessfully.
2. Run the same check on this branch. It succeeds with the documented
exception; the gem versions and application behavior remain unchanged.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation (inline
exception rationale and removal condition)
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works (not applicable: audit configuration only)
- [x] New and existing unit tests pass locally with my changes (131
focused existing examples)
- [ ] Any dependent changes have been merged and published in downstream
modules (not applicable: no dependent changes)
Add reconnect attempts configuration for Redis.

## Description

This PR fixes an issue where Chatwoot stops delivering websocket updates
after Redis is restarted.

For the past few months, `needrestart` has been silently restarting
services that depend on upgraded libraries during system updates,
including Redis. This is the case for me, and maybe it will be for other
people as well.

Issue is When Redis restarts, Action Cable loses its Redis subscription
connection.

The root cause is that the Redis client used by Action Cable only
performs a limited reconnect attempt. If Redis is unavailable during
that window, the subscription remains disconnected permanently. As a
result, websocket connections stay open and continue sending pings, but
no real-time updates are delivered to clients.

This change configures Redis subscription reconnection attempts with
exponential backoff, allowing Action Cable to recover automatically from
temporary Redis outages and service restarts.

Closes #14705

## Type of change

* [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

### Reproduction Steps

1. Deploy Chatwoot v4.8.0.
2. Open the Chatwoot admin panel.
3. Verify that real-time updates are working.
4. Restart Redis:
```sh
sudo systemctl restart redis-server

```


5. Send a new message or trigger a real-time event.

### Before this change

* Websocket connections remain open.
* Only ping frames are exchanged.
* No real-time updates are delivered.
* Chatwoot requires a restart to restore functionality.

### After this change

* Action Cable automatically reconnects to Redis.
* Real-time updates continue working after Redis becomes available.
* No Chatwoot restart is required.

## Checklist:

* [x] My code follows the style guidelines of this project
* [x] I have performed a self-review of my code
* [x] I have commented on my code, particularly in hard-to-understand
areas
* [x] I have made corresponding changes to the documentation
* [x] My changes generate no new warnings
* [x] I have added tests that prove my fix is effective or that my
feature works
* [x] New and existing unit tests pass locally with my changes
* [x] Any dependent changes have been merged and published in downstream
modules

## Environment

| Component | Version |
|---|---|
| **OS** | Ubuntu (Noble/24.04) |
| **Ruby** | 3.4.4 |
| **Rails** | 7.1.5.2 |
| **ActionCable** | 7.1.5.2 |
| **redis** (gem) | 5.0.6 |
| **redis-client** (gem) | 0.22.2 |
| **Redis Server** | 8.0.5 |

---------

Co-authored-by: tejush <mac@MacBook-Air.local>
…a_id (#15702)

WhatsApp Cloud attachments are now uploaded to Meta's media endpoint and
sent by `media_id` instead of a public `link`. Meta no longer downloads
the file from the Chatwoot server, so outbound media stops failing
intermittently with `131053: Media upload error` on hosts that share an
ASN with other Chatwoot/Evolution API instances — Meta's `fwdproxy` rate
limits per destination ASN, not per WABA account. It also removes the
secondary failure where `fwdproxy` does not follow ActiveStorage's `302`
redirect and reports `Unsupported mime type text/html`.

Operators who need the previous behaviour can set
`WHATSAPP_MEDIA_UPLOAD_STRATEGY=link`. No migrations, no config required
on upgrade.

Supersedes #15069 by @AdarshJ173, which established this approach and
the `media_id` direction. The review points raised by @nestordavalos on
that PR are addressed here.

## Closes

- Closes #13540
- Supersedes #15069

## How to reproduce

On a WhatsApp Cloud inbox, send an image, document or voice note from an
instance whose hosting provider shares an ASN with other Chatwoot
deployments (e.g. Hostinger, ASN 47583). Meta's webhook returns:

```
Downloading media from weblink failed with http code 429
ratelimit reason: Request ratelimit by fwdproxy
[GlobalCountingRatelimiter] Request hit ratelimit policy — destination matcher asn_list: 47583
```

After this change the file is uploaded first and the message references
the returned `media_id`, so Meta never fetches it from us.

## What changed

- **New `Whatsapp::MediaUploadService`** — streams the blob to `POST
/{version}/{phone_number_id}/media` as multipart form data and returns
`{ 'id' => media_id }`, or `nil` when the upload is disabled or fails.
- **`WhatsappCloudService#build_attachment_content`** sends the returned
media object, falling back to `{ 'link' => download_url }` so a
media-endpoint failure never blocks a message.
- Uses `Faraday::Multipart::FilePart`, matching
`Telegram::SendAttachmentsService`, the codebase's existing multipart
upload. The file part carries an explicit filename and content type.
- Reads the blob through `blob.open` and streams it via Faraday's
`CompositeReadIO`, so large attachments are never buffered in memory and
any ActiveStorage backend works.
- Follows `WHATSAPP_API_VERSION` via `GlobalConfigService`, matching
`calls_phone_id_path` in the enterprise provider, rather than pinning a
version in source.
- Falls back to the link on `Faraday::Error` or a non-success response
only; other errors surface instead of silently degrading.
- Logs one warning naming the fallback and Meta's own error, including
for non-JSON error pages: `[WHATSAPP] Media upload failed, falling back
to link for account … attachment …: HTTP 429 Request ratelimit by
fwdproxy`.

The `voice`/`caption`/`filename` fields and the existing `audio/opus` →
`audio/ogg` normalisation are unchanged; the normalisation now also
governs the uploaded part's content type.

---------

Co-authored-by: A.Adarsh Jagannath <adarshjagannath777@gmail.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
## Problem
Widget file uploads via ActiveStorage DirectUpload fail on production
with:

```text
ActionController::InvalidAuthenticityToken (Can't verify CSRF token authenticity.)
```

This occurs because `Api::V1::Widget::DirectUploadsController` inherits
from `ActiveStorage::DirectUploadsController` →
`ActiveStorage::BaseController`, which has protect_from_forgery with:
:exception enabled.

While CSRF works on same-origin, it fails in production because the
widget is embedded in an iframe on third-party customer sites
(cross-origin)


## Solution
Skip CSRF verification for the widget direct uploads endpoint - this is
safe because:

- Widget endpoints authenticate via website_token + X-Auth-Token
(token-based auth)
- CSRF protection is designed for session-based auth, not token-based
auth

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
…(#14050)

Summary
This PR fixes XSS vulnerabilities by sanitizing unsafe HTML rendering
paths:

Replaced unsafe
[v-html](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)/[innerHTML](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)
rendering with
[v-dompurify-html](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)
in captain assistant UI components
Sanitized signup terms HTML before rendering in the signup form
Added server-side markdown sanitization in
[chatwoot_markdown_renderer.rb](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)
before marking HTML safe
Added regression coverage for markdown XSS sanitization.
Changed files

[MessageList.vue](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)

[ScenariosCard.vue](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)

[Index.vue](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)

[Form.vue](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)

[chatwoot_markdown_renderer.rb](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)

[chatwoot_markdown_renderer_spec.rb](vscode-file://vscode-app/c:/Users/gurji/AppData/Local/Programs/Microsoft%20VS%20Code/e7fb5e96c0/resources/app/out/vs/code/electron-browser/workbench/workbench.html)
Testing
bundle exec rspec spec/lib/chatwoot_markdown_renderer_spec.rb
Optional frontend validation by building the dashboard and confirming
the updated components render safely without unsafe HTML injection
paths.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
Ruby 3.4 with YJIT enabled inlines constant values at compile time, so
`stub_const` in tests can't replace a class-level constant that YJIT has
already cached — the spec calls still see the original value and the
session-limit tests return 200 instead of 409.

## What changed

- Removed the `MAX_SESSIONS` class constant from
`DeviseOverrides::SessionsController`
- `sessions_limit_reached?` now reads `ENV.fetch('MAX_USER_SESSIONS',
25).to_i` directly on each call (with a guard for `<= 0`)
- Updated the spec to use `with_modified_env` instead of `stub_const` so
the limit is configurable at test time without hitting the YJIT cache

## How to test

Run the session limit enforcement specs:

```
bundle exec rspec spec/controllers/devise_overrides/sessions_controller_spec.rb
```

All tests should pass, including on Ruby 3.4 with YJIT enabled.

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
Fixes #15797 - credit to @Uankur for the precise report and diagnosis
(exact citations, repro curls, and the proposed direction); this PR
implements the fix they sketched, verified independently against current
`develop`. @Uankur - would love your eyes on this.

## Problem

`GET /api/v1/accounts/:account_id/portals/:slug` returns HTTP 500 when
the slug does not exist in the account. `fetch_portal` uses
`find_by(slug:)`, which returns nil instead of raising, and `show` then
calls `@portal.articles` on nil. The blast radius is wider than `show`:
`fetch_portal` runs as a `before_action` for every portal action except
`index`/`create`, so `update`, `destroy`, `archive`, `logo` and
`send_instructions` hit the same nil crash (EE `ssl_status` too).

## Fix

One-token change: `find_by` -> `find_by!`.
`ActiveRecord::RecordNotFound` then flows through the existing
`handle_with_exception` rescue, which renders the standard 404
`"Resource could not be found"` - the same pattern sibling controllers
already use (`labels_controller` uses `find`, and the public portals
controllers use `find_by!(slug:)`). The lookup stays scoped to
`Current.account.portals`, so a slug belonging to another account also
returns 404, matching the reporter's expectation. Pundit authorization
is class-level here and unchanged.

## Tests

Two request specs added to `portals_controller_spec.rb`: unknown slug ->
404 with the standard body, and cross-account slug -> 404.

**Test status (honest):** the specs are written but NOT executed - no
Ruby toolchain was available in our environment. Verification was
static: the nil path, the `handle_with_exception` RecordNotFound -> 404
rescue, and the sibling/public-controller `find_by!` precedent were all
confirmed against current `develop` (`2f1ed80f89`). The one-token change
mirrors the repo's own usage elsewhere.

> Built by breken, your AI support engineer - breken.ai - this one's on
us.
## Description

This fixes Facebook Messenger postback events not being received by
Chatwoot inboxes.

Previously, Chatwoot subscribed Facebook pages without the
`messaging_postbacks` webhook field, so actions like `Get Started` and
button postbacks were not delivered. This change adds the missing
subscription field, registers a Messenger `:postback` handler, and maps
postback payloads into the existing Facebook inbound message flow.

Fixes #8821

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

Manual reproduction steps:
1. Create or connect a Facebook Page inbox in Chatwoot.
2. Configure a Messenger `Get Started` button or a button that sends a
postback.
3. Trigger the postback from Messenger.
4. Confirm the event is received and created through the existing
Facebook inbound message pipeline.

Automated coverage added:
- Added a model spec to verify `messaging_postbacks` is included in
Facebook page webhook subscriptions.
- Added a builder spec to verify a Messenger postback creates an
incoming message using the postback title.

Note:
- I could not run the full local Ruby test suite in the current
environment because Ruby/Bundler was not available in the active shell.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Include ET translation files

Co-authored-by: Sojan Jose <sojan@pepalo.com>
* fix: authorize conversations before macro execution

* fix: log skipped macro executions and cover authorization
* fix: run pre-authentication checks on header-supplied sign-in credentials

* fix: normalize email in SAML password-auth guard
## Description

Prevent non-Cloud login and application pages, Super Admin, and
installation setup pages from being indexed. Cloud application pages
remain eligible for indexing. Widget pages always include `noindex`, and
their existing crawl exclusion is preserved to avoid creating contacts
from crawler requests. Public Help Center homepages and articles retain
their existing indexing behavior, including when a widget is embedded.

A public Rails route replaces the static robots.txt file and retains
`Disallow: /widget`. The application layout uses the existing Cloud
detection helper to apply `noindex` conditionally; widget, Super Admin,
and setup pages include it on every installation. Public Help Center
layouts remain unchanged.

Because widget crawling remains blocked, crawlers cannot reliably read
its `noindex` tag to remove already-indexed widget URLs. Allowing widget
crawling safely requires a separate change to make widget GET requests
create no records.

## Closes

Closes
https://linear.app/chatwoot/issue/CW-8224/prevent-login-page-indexing-on-non-cloud-installations

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

1. On a self-hosted installation, open the page source for `/app/login`,
`/app/login/sso`, `/app`, `/`, and
`/widget?website_token=<valid-token>`. The HTML head includes `<meta
name="robots" content="noindex">`, including when the manifest is
disabled.
2. Set the installation's `DEPLOYMENT_ENV` to `cloud` with Enterprise
enabled and reload. The application pages no longer include the
directive, while the widget still includes it. Community installations
retain the directive on application and widget pages.
3. Open `/super_admin/sign_in`, sign in, and inspect `/super_admin` and
`/super_admin/accounts`. All include `noindex` on Cloud, self-hosted
Enterprise, and Community installations.
4. On a fresh installation, open `/` and follow the redirect to
`/installation/onboarding`. The setup page includes `noindex`.
5. Open a public Help Center homepage, article, and custom-domain
homepage using either portal layout, including with a widget attached.
None receives the directive. Plain article previews remain unaffected.
6. Open `/robots.txt` without signing in. It returns plain text with
`User-agent: *` and `Disallow: /widget`, including in API-only
deployments. Other paths remain crawlable.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
scmmishra and others added 27 commits September 29, 2026 15:18
…5996)

Adds the backend for installing Captain toolsets from the
[chatwoot/tools](https://github.com/chatwoot/tools) repository. A
toolset is addressed as `chatwoot/tools/<folder>` and pinned to a
commit, either one passed in or the latest on the default branch.
Installing the same commit again is a no-op, and a new commit updates
the existing tools in place while keeping their slugs and the admin's
enabled or disabled choice. Nothing calls these services yet; the API
and UI come in the next PRs.

This is part 2 of a series of PRs that bring toolset manifest import to
Captain, following #15995 (merged).

## Changes

- Custom tools record their source (repository, folder, tool id, commit,
version).
- Install service that fetches a toolset from chatwoot/tools and
installs it onto an assistant.
- Toolsets are validated before they are merged into chatwoot/tools, so
Chatwoot only parses the manifest and fills in defaults. Every installed
tool still goes through the custom tool model's validations (headers,
endpoint safety, auth config), and the admin's install values are
checked for type and required fields.
- Optional internal GitHub token for commit lookups, with fallback when
it's rejected.
…5997)

Adds the API and install dialog for installing Captain toolsets from the
[chatwoot/tools](https://github.com/chatwoot/tools) repository. The
dialog takes `chatwoot/tools/<folder>`, previews the toolset and its
tools, asks for the inputs and secrets it declares, and installs the
latest commit from the default branch. If an older version is installed,
it offers to update it. Installed tools show where they came from and
are read-only, apart from enabling or disabling them. The dialog has no
entry point on its own; the Tools Catalog in the next PR opens it.

Installs require the `custom_tools` plan and a new Super Admin switch,
"Captain Tools Manifest", which is off by default.

This is part 3 of a series of PRs that bring toolset manifest import to
Captain, following #15996 (merged).

## Changes

- Preview and install API for toolsets in chatwoot/tools, for
administrators.
- Short, translated error messages for bad sources and configuration
issues.
- Install dialog, opened by the Tools Catalog in the next PR.
- Installed tools show their source, open read-only, and can only be
enabled or disabled.
- Tool titles open the side panel: editable for admins' own tools,
read-only otherwise.
…(#15998)

Agents can request a phone number again from eligible WhatsApp Cloud
conversations identified by a BSUID. The action now checks availability
when an agent opens a conversation whose contact has no phone number,
and refreshes as the contact or request state changes.

Eligibility is fetched separately from conversation rendering, keeping
pending-request history queries out of conversation lists. Existing
checks still prevent duplicate pending requests and require an approved
Request Contact Info template outside the messaging window.

Related:
https://linear.app/chatwoot/project/whatsapp-bsuid-4df5614db3f9/issues

### Background: previous approach

[PR #15826](chatwoot/chatwoot#15826) temporarily
hid the dedicated Request Contact Info action to remove pending-request
message-history lookups from routine conversation rendering, including
inbox lists, filtered lists, contact conversation lists, and individual
conversation responses. These eligibility checks added database work to
ordinary browsing. Checking only the messages loaded in the UI would
also miss pending requests in older history, potentially showing an
action that the backend would then reject.

This PR restores the action through a separate availability request for
the selected WhatsApp Cloud conversation when the contact has no phone
number. The endpoint retains the full pending-request check across
conversations sharing the contact-inbox identity, while keeping history
lookups out of conversation rendering as in #15826. History lookups
still occur for the selected conversation's eligibility check; they are
no longer part of routine list rendering.

### Things to know

- No database migrations or feature flags are required. The unused
static `contact_info_request` field is removed from conversation
responses; the dashboard reads availability from the dedicated endpoint.
External clients consuming that field must use the endpoint instead.
- Availability requests are cancelled when switching conversations, and
a pending request remains disabled even when its message is outside the
loaded message history.
- The interactive request and customer phone-sharing response were
manually verified end to end. The template flow outside the messaging
window has not been tested live.
- Existing targeted validation passed: 29 Ruby service examples, 155
frontend tests, Ruby and JavaScript linting, and focused endpoint,
authorization, and concurrent-request checks.

### How to reproduce

Open a WhatsApp Cloud conversation with a BSUID contact, no phone
number, and an active messaging window. Before this fix, the Request
Contact Info action is hidden because the conversation payload reports
the capability as unavailable.

### How to test

1. Open an eligible WhatsApp Cloud conversation whose contact has no
phone number. Confirm Request Contact Info appears in the composer.
2. Send the request. Confirm the action is disabled while a request is
pending, including after reopening the conversation.
3. Share the phone number from WhatsApp. Confirm the contact is updated
and the action disappears.
4. Switch between conversations while availability is loading. Confirm
the action reflects the selected conversation.
5. Open a conversation outside the messaging window. With an approved
Request Contact Info template, confirm the action opens the template
picker; without one, confirm the action is hidden.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Agents couldn't find email conversations by their subject, and the
subject of outgoing emails wasn't visible anywhere in the conversation.
Conversation search now matches the email subject, and email
conversations show the subject in the header next to the conversation ID
and inbox, with the full text in a tooltip when it's truncated.

Closes
https://linear.app/chatwoot/issue/CW-8228/display-outgoing-email-subjects-in-conversation-view-and-search

### Screenshots

#### RTL

<img width="3140" height="296" alt="CleanShot 2026-09-28 at 14 54 57@2x"
src="https://github.com/user-attachments/assets/37c5482e-ab15-4d6c-a5ec-9b1b5c7d17d7"
/>

#### Simple Subject Line
<img width="2646" height="968" alt="CleanShot 2026-09-28 at 14 51 49@2x"
src="https://github.com/user-attachments/assets/58b7f97a-370c-47a4-a374-b4bfa8818117"
/>

#### Absurdly Long Subject Line
<img width="2646" height="968" alt="CleanShot 2026-09-28 at 14 51 45@2x"
src="https://github.com/user-attachments/assets/81213729-2e74-421d-9e0a-b7ec5b3ee622"
/>

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
…audit logs (#15976)

## Description

Impersonation links now record which super admin generated them, and the
session they create carries that super admin on its token, so a live
impersonation session can be attributed to a staff member. A durable
staff-side record of impersonation will follow in a separate PR.

This also fixes impersonation showing up in the customer's audit log.
Impersonation sign in and ending it (sign out) wrote regular sign_in and
sign_out entries to the account's audit log, so account admins saw
logins they did not make, from an unfamiliar location. Both are now
skipped for impersonation sessions, the same way session tracking
already skipped them.

Fixes https://linear.app/chatwoot/issue/CW-8272

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

Specs cover the super admin binding on the token and session, and the
skipped sign in and sign out audit events.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
## Description

Impersonation tokens were minted every time a super admin opened a user
page, since the link was rendered into the page. They are now minted
only when a super admin clicks.

- Impersonate user opens the customer dashboard in a new tab, as before.
- Copy link copies a single-use link (valid for 5 minutes) to the
clipboard, for opening in another browser or an incognito window.

Both are authenticated, CSRF-protected POSTs, and the token is bound to
the super admin who clicked.

<img width="1138" height="857" alt="image"
src="https://github.com/user-attachments/assets/86f54dd5-9efa-4c05-a005-257c8ef32a5a"
/>


Depends on #15976.

Fixes https://linear.app/chatwoot/issue/CW-8276

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

Request specs cover: no token minted on page render, a token bound to
the signed-in super admin on both actions, authentication required, and
CSRF rejection.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
…re pickup (#15961)

Fixes a bug with outbound Twilio calls. If an agent hung up before the
callee answered, the callee's phone kept ringing. If they then picked
up, they heard hold music with nobody on the line. Now hanging up in the
dashboard also hangs up the callee's Twilio call leg. A leg that still
gets answered after the call has ended is sent `<Hangup/>` instead of an
empty conference.

In production, 38 of 289 agent-ended outbound Twilio calls (13.1%) kept
the callee's leg alive after teardown. 22 of them were answered into
hold music, 12 rang on to `no-answer`, and 4 ended `busy`. That comes to
about 49 minutes a month of billed Twilio audio with nobody on our side.

## Closes

-

## How to reproduce

1. Place an outbound call from a Twilio voice inbox to a phone you have
with you.
2. While it is still ringing, hang up in the dashboard.
3. Before this fix, the phone keeps ringing, and answering it plays hold
music. After this fix, it stops ringing right away.

## What changed

- `ConferenceService#terminate_call` hangs up `provider_call_id`, and
also `parent_call_sid` when it is set and different, with
`Status=completed`. It uses `call.inbox.channel.client`, so api-key mode
works. `ConferenceController#destroy` calls it before `end_conference`,
and both run before the call is finalized, so a failure still leaves the
call repairable. `end_conference` stays as it was, because it handles
the agent's leg and any other participants already in a live conference.
- Why `completed` in every state: Twilio documents that "Specifying
`canceled` will attempt to hang up calls that are queued or ringing;
however, it will not affect calls already in progress. Specifying
`completed` will attempt to hang up a call even if it's already in
progress." Sending `canceled` would silently do nothing if the callee
answers while the request is in flight, and that is exactly this bug.
- For a leg that has already ended (the normal case, 251 of 289 calls),
Twilio returns HTTP 200 and leaves the status unchanged. We tested this
against a real account for both status values, on both `completed` and
`no-answer` legs. So no rescue is needed.
- In `Twilio::VoiceController#call_twiml`, if the resolved call is
already terminal, it now renders `<Hangup/>` instead of the conference
TwiML. This covers the race where the callee answers while the hang-up
request is in flight. The `reject_inbound?` path is unchanged.
- This also fixes the August bug where an inbound caller was stuck in
hold music. When the conference was still in `init`, `end_conference`'s
`status: 'in-progress'` query found nothing. `terminate_call` acts on
the Call resource instead, so the conference state no longer matters.

## Out of scope (follow-ups)

- `useCallSession.js#endCall` has no in-flight lock (unlike `joinCall`'s
`globalIsJoining`), so 18% of call_sids get duplicate `destroy` calls.
Those are harmless now, because a hang-up on an ended leg returns 200.
- Outbound calls are marked `in_progress` when the agent joins the
conference, not when the callee answers. So calls nobody answered still
get a "completed" bubble.
- `Voice::Provider::Twilio::Adapter#twilio_client` pairs `account_sid`
with `auth_token`, which is broken in api-key mode.
## Description

Conversation monitors are now included with Business and Enterprise
Cloud plans. Captain Classifier remains available on every paid plan,
including Startups. Shopify-billed accounts receive Captain Classifier
on entitled plans; monitor access follows each plan's configured
`conversation_monitors` feature. Existing monitor definitions remain in
place after a downgrade, while the monitor API and workers respect the
updated account flag.

Closes
[CW-8331](https://linear.app/chatwoot/issue/CW-8331/limit-conversation-monitors-to-business-plans-and-above).
Follow-up to
[CW-8305](https://linear.app/chatwoot/issue/CW-8305/enable-conversation-monitors-and-captain-classifier-for-all-paid-plans).

Rollout dependency: update `CHATWOOT_SHOPIFY_PLANS` so only
Business-equivalent Shopify tiers and above include
`conversation_monitors`. After deployment, run the reviewed
Rails-console backfill manually to refresh existing billed accounts. The
script previews changes and preserves explicit
`manually_managed_features` grants. Audit any older direct Super Admin
grants before running it; those grants are not distinguishable from
former plan grants in the feature bit alone.

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [x] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

- Verified the Stripe plan matrix, Shopify catalog-based grants,
downgrades, and manual overrides with focused RSpec coverage (84
examples passed).
- Verified that a Startups account loses monitor API access after
reconciliation.
- RuboCop passed for all changed Ruby files; `git diff --check` passed.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
## Description

Adds logs for super admin impersonation.

Fixes https://linear.app/chatwoot/issue/CW-8337

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

Existing impersonation and session specs pass with the change.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
…16020)

Fixes #16019

`MailPresenter#from` previously chose a present `reply_to` array before
checking its entries, then called `downcase` on every entry. `[nil]` is
present in Rails, so a malformed Reply-To address list raises
`NoMethodError`. The IMAP fetch job counts the exception and skips the
message for six hours after three failures.

This removes nil/blank entries before downcasing. A Reply-To with no
usable addresses falls back to From. If neither list has an address, the
presenter returns `[]`, leaving the existing IMAP sender-validity check
to ignore the mail. No changes to the fetch cache or unrelated sender
parsing.

Regression specs cover mixed and empty address lists in the presenter,
plus a synthetic raw-email fixture whose `Reply-To: <"">` parses as
`[nil]`. The IMAP mailbox regression uses that fixture without stubbing
`reply_to` and creates a conversation and message from the valid From
address.

### Related issues

- Linear:
[CW-8301](https://linear.app/chatwoot/issue/CW-8301/imap-mailpresenterfrom-calls-downcase-on-nil-and-the-message-is)
- Sentry:
[CHATWOOT-FAZ](https://chatwoot-p3.sentry.io/issues/7696163347/)
- Sentry:
[CHATWOOT-FEY](https://chatwoot-p3.sentry.io/issues/7708063382/)

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
…pdated (#16077)

Contacts now get the right type as they are created and updated. A
contact becomes a lead when it has an email, a phone number or an
identifier, when it has a conversation (clicking a campaign message
counts, since that opens one), or when it was created on purpose through
the contacts API, the dashboard or an import. Everyone else stays a
visitor, and a lead never goes back to being a visitor.

This is the first phase of making `contact_type` the source of truth for
contacts. Nothing reads the type differently yet, so the contact list,
search, filters and exports behave as before on accounts without
`crm_v2`. Existing contacts are not touched; the backfill is the next
PR.

## Closes

Part of
[CW-8316](https://linear.app/chatwoot/issue/CW-8316/contacts-contact-type-stale-contact-cleanup-and-opensearch-migration).
This is phase 1 of 5, so the issue stays open.

## How to test

There is no UI for the type yet. Read it from the console with
`Contact.find(id).contact_type`.

- Open a site with the widget and do nothing: the contact is a visitor.
- Send a first message from the widget: the contact becomes a lead.
- Click a campaign message in the widget without typing anything: the
contact becomes a lead.
- Call `setUser` with only an identifier: lead.
- Create a contact with only a name, from the dashboard or with `POST
/api/v1/accounts/:id/contacts`: lead.
- Import a CSV whose rows have only a name: every row is a lead.
- Create a contact on an API inbox with only a name: visitor, until its
first conversation.
- Start a conversation from the dashboard with a contact that has no
email, phone or identifier: lead.
- Merge a lead into a visitor: the surviving contact is a lead.
- Remove the email, phone and identifier from a lead: it stays a lead.

## What changed

- `Contacts::SyncAttributes` also promotes a visitor when it has an
`identifier`.
- `Conversation` gets an `after_create` that marks a visitor contact as
a lead. It runs inside the insert's transaction instead of after commit.
The app runs `load_defaults 7.0`, so commit callbacks run in reverse
declaration order, and one that raises skips the remaining commit
callbacks of every record in the transaction, which would drop
`conversation_created` and the first message's callbacks.
- `Api::V1::Accounts::ContactsController#create` builds the contact as a
lead.
- `DataImport::ContactManager` builds new CSV rows as leads.
`Contact.import` does not run save callbacks, so until now every new CSV
contact was stored as a visitor, even with an email.
- `DataImports::Importer` (Freshdesk, Intercom) always imports contacts
as leads and promotes a matched visitor.
- `ContactMergeAction` keeps the higher of the two types.
- No write reads the `crm_v2` flag, and the `contacts` table is
unchanged.

## Things to know

- A contact's first conversation sends one extra `contact_updated` event
to webhooks, the dashboard and the LeadSquared hook. Its
`changed_attributes` names `contact_type`, which the payload itself does
not carry.
- The contact row is updated inside the conversation's transaction, so
it stays locked until that transaction commits. On Facebook that
includes the attachment download.
- Conversations created by the Freshdesk and Intercom importers are
inserted in bulk and do not run the new callback. The importer marks its
contacts as leads directly.
- Accounts with `crm_v2` on already read `contact_type = lead` for the
contact list, so they will see the newly promoted contacts.
- Captain prompts print the contact type. Anyone in a conversation now
reads as a lead there.
…te payloads (#16089)

Webhook payloads and dashboard live updates for a contact now carry its
type: `visitor`, `lead` or `customer`. Since #16077 a visitor's first
conversation sends a `contact_updated` event whose `changed_attributes`
names `contact_type`, but the payload itself did not carry the field.
Now it does, so a consumer can read the new type from the event instead
of fetching the contact again.

## Closes

Part of
[CW-8316](https://linear.app/chatwoot/issue/CW-8316/contacts-contact-type-stale-contact-cleanup-and-opensearch-migration).
Follows up on the review of #16077.

## How to test

- Add an account webhook subscribed to `contact_created` and
`contact_updated`.
- Open the widget on a site as a new visitor and send a message. The
`contact_updated` delivery carries `"contact_type": "lead"`, and its
`changed_attributes` shows the change from `visitor`.
- Create a contact from the dashboard. The `contact_created` delivery
carries `"contact_type": "lead"`.
- Keep the contacts page open in the dashboard while a contact changes.
The live update for that contact carries `contact_type` too.

## What changed

- `Contact#webhook_data` and `Contact#push_event_data` gain
`contact_type`. Both are additive: no existing field changes.
- The REST contact payload is unchanged.
## Description

Administrators can use an active conversation monitor as an automation
event. They can create a rule from the automation screen or from the
monitor list and report, then see linked rules on the monitor report. A
rule fires when a live public message first makes the conversation
match, whether that message is incoming or outgoing. Replies generated
by another automation remain visible in monitor reporting but cannot
trigger a linked rule; historical scans do not run actions. Pausing or
deleting a monitor disables its linked rules, and resuming the monitor
leaves them disabled until an administrator re-enables them.

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## Closes

-
[CW-8304](https://linear.app/chatwoot/issue/CW-8304/trigger-automations-from-conversation-monitor-matches)

## How to test

1. Use an account with Reports, Conversation Monitors, and Automations
enabled and a configured monitor provider.
2. Create a monitor, then create an automation with **Monitor matched**
as its event and select that monitor. Confirm only active monitors are
offered.
3. From the monitor list or report, create another automation and
confirm the selected monitor is prefilled. After saving, confirm the
monitor report shows the linked rule.
4. Send a new public incoming or outgoing message that first makes the
monitor match. Confirm the rule's action runs. Send an
automation-generated reply that alone matches the monitor, including
immediately after a customer message, and confirm it records the match
without running the linked rule. Confirm historical scans do not run
actions and the same recorded match is not replayed.
5. Pause the monitor and confirm its linked rules become disabled.
Resume it and confirm the rules stay disabled until explicitly
re-enabled. Deleting a monitor should also disable its linked rules.

## What changed

- Added monitor event validation, account scoping, live activity
attribution, durable delivery records, and action dispatch in
Enterprise.
- Kept automation-generated replies in monitor reporting while excluding
them from the action-eligible message context.
- Preserved live eligibility when a new message is updated before
commit, and isolated delivery inserts so deleting a linked rule cannot
discard the monitor match.
- Added the monitor event picker, rule status and search, monitor
shortcuts, and linked rule list in the dashboard.
- Added coverage for API validation, delivery behavior, lifecycle
changes, and frontend selection and navigation.

## Checklist

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have added tests that prove the feature works
- [x] New and existing focused unit tests pass locally with my changes


## CI follow-up

- Extended the automation composable spec to cover the monitor event.

---------

Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This draft improves Shopify connections started by administrators from
dashboard settings. It adds expiring OAuth state, feature-gate checks,
store-domain validation, and protection against reconnecting an
installation during uninstall cleanup.

Existing signup, login, password reset, and billing behavior remain
unchanged.

Related: chatwoot/chatwoot#13550

### Things to know

- First PR in the Shopify split; merge this before the installation
foundation and signup/authentication changes.
- Shopify remains behind its feature flag.
- Includes dashboard integration and cleanup specs. No database
migrations.
- Browser verification and frontend tests remain outstanding; this draft
is not a merge-readiness confirmation.

### How to test

1. Enable Shopify for a test account and connect a development store
from Settings → Integrations → Shopify.
2. Verify customer orders appear in conversations.
3. Disconnect and reconnect the store.
4. Disable Shopify and verify connection attempts are blocked.
5. Confirm an existing Stripe account retains its billing configuration.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
…(#16098)

Bulk conversation assignments now accept only users and teams belonging
to the current account. Invalid assignment targets return `422` before
the bulk job is queued, so the request does not partially change
conversation status, labels, or assignments.

Existing assignment removal remains supported: `assignee_id: null`,
`team_id: null`, and the dashboard's `team_id: 0` value. Valid
assignments and other bulk actions keep their current behavior.

### How to test

1. As an agent with access to an inbox, select multiple conversations
and assign an agent and team from the current account. Confirm the
assignments update.
2. Remove the agent and team assignments. Confirm both are cleared.
3. Submit a bulk request with an assignment target outside the current
account or a nonexistent target. Expect `422` and no conversation
changes, including when the request also changes status.
4. Bulk-update conversation status without assignment fields. Confirm it
still works.

The pushed commit was checked against a running local Rails server and
Sidekiq worker with synthetic accounts. All 12 HTTP checks passed,
including rejected requests leaving conversations unchanged.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
… (#16104)

## Description

Conversation create now validates `assignee_id` and `team_id` before
creating the conversation, and returns `422` for values that don't
resolve, in line with how bulk actions handle them. Requests without
these fields behave as before.

Fixes https://linear.app/chatwoot/issue/CW-8347

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

Added request specs for invalid assignee and team values (422, no
conversation created). Existing conversations controller specs pass.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
Merchants installing Chatwoot from Shopify can create an account or sign
in to an eligible existing workspace, then continue to Shopify billing
setup. The installation destination follows them through email
confirmation, invitation acceptance, password reset, Google sign-in,
SAML, and MFA.

This is the final Shopify onboarding PR. Dashboard connections and the
installation foundation have already merged in #16084 and #16085; this
PR targets `develop` and completes the App Store signup and
authentication flows.

### Closes

- Fixes
https://linear.app/chatwoot/issue/CW-6500/support-shopify-app-store-install-flow-for-merchant-initiated
- Fixes
https://linear.app/chatwoot/issue/PLA-170/resolve-shopify-app-store-billing-rejection

### Things to know

- Shopify remains behind its feature flag. Shared signup, login,
confirmation, password-reset, and account-routing code changes in this
PR still require regression coverage with Shopify disabled.
- App Store onboarding uses Shopify billing. Existing Stripe customers
continue connecting through the dashboard flow.
- Google signup rejection preserves the target workspace. Invalid or
claimed installation tokens return a recoverable error, and invitation
password-setup links preserve the installation continuation.
- Account membership creates notification settings within its
transaction and reuses existing settings when an agent is re-added.
Unrelated agent-cleanup, IMAP entitlement, and shared-plan lookup
changes are excluded.
- No Shopify lookup-index migration is included; those indexes are
deferred.
- Shop-scoped locking and database uniqueness enforcement are deferred.
Concurrent signups using separate pending tokens for the same shop can
create duplicate connections and block later App Store launches.
Database transactions and model validation do not prevent this race. The
installation/uninstall race is also deferred. Both are accepted risks,
not fixed issues:
chatwoot/chatwoot#13550 (comment)
and
chatwoot/chatwoot#13550 (comment).
- Configure the Shopify app URLs for the target environment. Webhook
testing requires a reachable callback host.

### How to test

1. With Shopify enabled, install from a development store, create an
account, confirm the email, and complete billing setup with a test plan.
Verify the store is connected to the new workspace.
2. Repeat while signed out of an eligible existing workspace. Verify
workspace selection and the installation destination survive
email/password login, Google, SAML, and MFA.
3. Start with an unconfirmed invited administrator, resend confirmation,
accept the invitation, and set a password. Verify onboarding resumes in
the intended workspace.
4. Exercise password reset and confirmation resends from a Shopify
continuation. Verify the installation token or billing destination and
workspace are preserved.
5. Reject a personal Google signup and retry with the correct identity.
Verify the target workspace is retained. Retry with an expired or
claimed installation token and verify a recoverable error appears
instead of a server error.
6. With Shopify disabled, verify ordinary signup, confirmation,
invitation acceptance, login/logout, password reset/change, Google,
SAML, and MFA. Check existing Stripe billing and account access.

---------

Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Conversation monitors now allow **10,000,000 calls per UTC month** and
**500,000 calls per UTC day**, per account. Allowances count attempted
provider requests, including preview calls, and no longer depend on
estimated token lengths. Pending evaluations retain their quota holds
when new messages arrive and resume after the applicable reset without
retrying ahead of a provider cooldown.

## What changed

- Enforce both call limits using persisted daily usage under the account
lock, so concurrent workers cannot exceed the remaining allowance. Each
attempted provider batch consumes one call, even when it evaluates
multiple monitors or returns a provider error.
- Remove the daily token reservation and reconciliation, which mixed
JSON request bytes with provider input tokens. Keep request-size limits
and provider token/cost telemetry.
- Preserve daily and monthly quota holds across new conversation input.
Spread retries over up to four hours after the later of the quota reset
and the longest provider cooldown recorded across failed batches.
- Report when the current monthly allowance was reached, ignoring
timestamps from the previous 100,000-call allowance.
- Document the RubyLLM bundle-audit exceptions for the pinned Ruby 3.4.4
runtime, with reassessment required when the runtime or supported
dependency versions change.

## Quota logging

When a committed call reaches the daily or monthly account cap, emit one
structured `conversation_monitor_limit_reached` log event for that
period. It includes `account_id`, `period` (`daily` or `monthly`),
`used`, `limit`, and the UTC reset timestamp `resets_at`. Rolled-back
calls and subsequent blocked retries do not emit events. Reaching both
caps in the same call emits one event for each period.

The event uses the existing Rails log forwarding path. In New Relic,
group events by account and period with:

```sql
FROM Log
WITH aparse(message, '%"account_id":*,%') AS account_id,
     aparse(message, '%"period":"*"%') AS period
SELECT count(*)
WHERE message LIKE '%conversation_monitor_limit_reached%'
FACET account_id, period
SINCE 7 days ago
LIMIT MAX
```

## Closes

Fixes https://linear.app/chatwoot/issue/INF-117

## How to reproduce

1. Exhaust an account's daily allowance, then receive additional
conversation messages before the next UTC midnight. Previously, each
message could pull the held work into an early retry and accumulate
another job at the reset. The work now retains its saved retry time
while recording the new input.
2. Have an earlier provider batch return a long `Retry-After`, then
exhaust the daily or monthly allowance before the next batch. The saved
retry now honors both the allowance reset and the longer provider
cooldown before adding jitter.

## Rollout

Existing quota holds retain their saved retry times; the increased
allowances apply when work next reserves a call. Already-scheduled jobs
from before deployment will still run once.

---------

Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
… verifier (#16140)

Moves Slack request signature verification out of the events webhook
controller into `Integrations::Slack::SignatureVerifier`, so other Slack
endpoints can reuse it. There is no change in behaviour for the events
webhook.

This is split out of #16129, which adds a second Slack endpoint
(interactive button clicks) that needs the same check.

## What changed

- New `Integrations::Slack::SignatureVerifier` with the signing secret
lookup and the signature check, moved as they were from
`Api::V1::Integrations::WebhooksController`.
- The events webhook controller calls the verifier. It still skips
verification with a warning when no signing secret is configured, and
still returns `401` for a missing, stale or invalid signature.
- `SignatureVerifier.valid?` returns `false` when no signing secret is
configured. The events webhook never reaches that case because it checks
for the secret first; it is there so a caller that requires a secret can
use `valid?` alone.
… senders (#16141)

Messages Chatwoot posts into a Slack thread now show who sent them more
clearly:

- Captain's replies are labelled **(Captain)** and use the Captain logo
instead of the generic bot one.
- Contact, agent, bot and system messages get new, sharper avatars in
one consistent style.
- Agents who are also super admins are now labelled **(Agent)**; they
were shown as **(Bot)**.

This is split out of #16129. The borders for private notes and activity
messages are in #16142; the two can merge in either order.

## How to test

1. Connect the Slack integration and start a conversation so a thread is
created in the channel.
2. Let Captain reply in the conversation: in Slack the message shows as
`<assistant name> (Captain)` with the Captain logo. A reply from another
agent bot shows as `(Bot)` with the bot avatar.
3. From the dashboard, reply as an agent without a profile picture, and
send a message as a contact: each shows its new default avatar in the
thread. An agent with a profile picture still shows their own.
4. Reply from the dashboard as an agent who is also a super admin: the
message is labelled `(Agent)`.
5. Resolve or reassign the conversation: the activity message appears in
the thread under **System** with the new system avatar.

Slack caches avatars per message, so only messages posted after this
change show the new ones.

## What changed

- `SendOnSlackService` maps each sender label to an avatar file
explicitly. The avatars live under new file names in
`public/integrations/slack/` so Slack fetches them fresh; `user.png`
stays (redrawn) because the Dyte integration uses it as its default
avatar.
- Agents without a profile picture now get the agent avatar. They were
pointed at `agent.png`, which does not exist.
Merge chatwoot/chatwoot develop @ ed7b122 (2026-09-11 → 2026-10-05) into the fork.

Resolution notes:
- EE voice stack files the fork removed in #10 stay removed (12 modify/delete conflicts), plus
  the new upstream spec for Voice::CallStatus::Manager. The EE licensing spec dropped in
  deffe91 stays dropped.
- Campaigns keep the fork's broadcast / proactive / templates pages. Upstream's full-page
  WhatsApp campaign form, WhatsApp campaign list and campaign analytics pages are not taken,
  nor the components only they used; the analytics API lives under enterprise/.
- WhatsApp one-off campaigns take upstream's cursor batching and keep the fork's
  blocked-contact filter (#66); the fork's specs now drain the enqueued batch job.
- feature_flags_ext_1 keeps the fork's bits (tickets 6, audit_log_ip_address 7); upstream's
  captain_classifier, conversation_monitors, campaign_analytics, company_enrichment follow at
  8-11 instead of upstream's 7-10.
- .bundler-audit.yml takes upstream's ruby_llm entries; the rack-proxy GHSA-42qh-8mx8-7wqm
  ignore is gone because the lock now resolves rack-proxy 1.0.3, its own removal condition.
- Login keeps the Pathors SSO route path and the resume-after-login return path alongside
  upstream's redirect_url / Shopify / MFA-setup flow.
- Reply box bot-handoff banner takes upstream's assign-then-reopen logic and keeps hiding it
  while a Pathors call is live.
- Switch, TabBar, conversation filter, company sort and the sidebar keep the fork's reka-ui
  primitives, Popover and multi-open groups, with upstream's disabled switch, tab icons,
  status-filter toggle, monitors entry and open-in-new-tab links ported in.
- zh_TW locales merged at the JSON key level, preferring the fork's translations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- integrations.routes.spec (new upstream) mocks the fork's Pathors.vue page, whose import
  chain otherwise loops back into settings.routes before integrations is defined.
- routes/index.spec gives the resume-after-login route a query, as vue-router always does;
  upstream's login redirect now reads to.query.
- Contact quick filters expect the browser timezone on last_activity_at, which upstream's
  timezone-aware date filters (#15843) now attach to timestamp conditions.
- ReplyBoxBanner.spec (new upstream) gets an active Pinia for the fork's live-call store and
  covers the banner hiding while a Pathors call is live.
- The fork's blocked-contact WhatsApp campaign spec drains the batch job that perform now
  enqueues (#15854).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown

❌ SonarQube Quality Gate ERROR — pathorsAI_inbox

failed condition value threshold
new_security_hotspots_reviewed 0.0 ≥ 100
new_violations 83 ≤ 0

83 open issues on this PR:

  • CRITICAL ruby:S1192 — Define a constant instead of duplicating this literal "Invalid state parameter" 4 times. (app/controllers/shopify/callbacks_controller.rb:25)
  • CRITICAL javascript:S3776 — Refactor this function to reduce its Cognitive Complexity from 49 to the 15 allowed. (app/javascript/dashboard/routes/index.js:39)
  • MINOR javascript:S7764 — Prefer globalThis over window. (app/javascript/dashboard/routes/index.js:76)
  • MINOR javascript:S7776 — SHOPIFY_ENTITLED_STATES should be a Set, and use SHOPIFY_ENTITLED_STATES.has() to check existence or non-existence. (app/javascript/v3/helpers/AuthHelper.js:42)
  • CRITICAL javascript:S3776 — Refactor this function to reduce its Cognitive Complexity from 19 to the 15 allowed. (app/javascript/v3/helpers/AuthHelper.js:134)
  • MINOR javascript:S7764 — Prefer globalThis over window. (app/javascript/v3/helpers/AuthHelper.js:173)
  • CRITICAL javascript:S3776 — Refactor this function to reduce its Cognitive Complexity from 19 to the 15 allowed. (app/javascript/v3/helpers/RouteHelper.js:15)
  • MAJOR javascript:S3358 — Extract this nested ternary operation into an independent statement. (app/javascript/v3/helpers/RouteHelper.js:63)
  • MINOR javascript:S7764 — Prefer globalThis over window. (app/javascript/v3/views/auth/confirmation/Index.vue:32)
  • MINOR javascript:S7764 — Prefer globalThis over window. (app/javascript/v3/views/auth/password/Edit.vue:79)
  • …and 73 more on the dashboard.

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.