Compare commits

..
Author SHA1 Message Date
Sony Mathew 692f5f6375 fix(deps): upgrade net-imap 2026-06-13 22:15:21 +05:30
Sony MathewandGitHub b1c2db5435 fix: Stabilize help center builder error spec (#14725)
## Description

Stabilizes the enterprise help center article builder source URL
validation spec by asserting the custom exception via its class name and
message text. This keeps the spec focused on the intended behavior while
avoiding brittle custom exception constant identity checks in
CI/reloading environments.

A bunch of builds on different PRs have been failing because of this
error, sample traces are below:
*
https://app.circleci.com/pipelines/github/chatwoot/chatwoot/114064/workflows/bdb6eca9-3b65-4c38-b8cf-f2f8564476f8/jobs/158777
*
https://app.circleci.com/pipelines/github/chatwoot/chatwoot/114064/workflows/bdb6eca9-3b65-4c38-b8cf-f2f8564476f8/jobs/158777

Fixes # N/A

## Type of change

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

## How Has This Been Tested?

- `/Users/sonymathew/.rbenv/shims/bundle exec rspec
spec/enterprise/services/onboarding/help_center_article_builder_spec.rb`
- `/Users/sonymathew/.rbenv/shims/bundle exec rubocop
spec/enterprise/services/onboarding/help_center_article_builder_spec.rb`
- `git diff --check`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
- [x] New and existing unit tests pass locally with my changes
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-13 19:57:42 +05:30
Vishnu NarayananandGitHub 92a1fb8ab7 feat: manage active user sessions from profile (CW-7169) (#14556)
## Description

First PR of user_sessions feature - enforcement, impersonation and mfa will be handled separately.

Adds an Active Sessions section under Profile where users can see every
device currently logged in and revoke any session they don't recognize.
Helps users lock down stale or unrecognized logins on their own without
needing support.

**Behavior at the limit, by client:**
- **Browser:** returns 409 with a picker overlay; user picks a session
to revoke or chooses "End all sessions" to clear them.

- **Mobile / API client:** silently evicts the oldest session and
proceeds with login (no picker UI to render).
- **Pre-tracking users** (token rows without `user_sessions`, i.e.
anyone already logged in before this ships): silent-evict any untracked
token first, so freshly tracked sessions are never killed in favor of
legacy ones.

Sessions are stored in a new `user_sessions` table keyed on `(user_id,
client_id)` with browser, platform, IP, last activity and (when
configured) geo. Kept in sync with `user.tokens` via an after_save
callback so revoking a token from any path cleans up the row.

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

## Type of change

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

## How Has This Been Tested?

- Added specs.
- Manual local testing: browser picker fires at limit; pre-tracking user
silent-evicts; mixed tracked/untracked correctly drops the untracked one
first; profile page revoke succeeds; current session cannot be revoked
from profile.

## 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
2026-06-12 16:11:07 +05:30
e055cead35 fix: only subscribe calls webhook field when voice calling enabled (#14718)
## Description

Solves issue https://github.com/chatwoot/chatwoot/issues/14690

## Type of change

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

## How has this been tested?

- UI flows

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] 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
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-06-12 12:25:25 +05:30
c041fde3a2 fix: Preserve original filenames for WhatsApp cloud attachments (#14168)
## Summary

Preserves the original filename provided in the WhatsApp Cloud
attachment payload when downloading media files.

## Problem

Files containing accented or special characters could be saved with
malformed names or incorrect extensions due to relying on remote
download metadata.

## Changes Made

- Uses attachment_payload[:filename] when present
- Creates a tempfile using the original filename and extension
- Preserves content type metadata
- Falls back to existing behavior when no filename is provided

## Notes

This should improve attachment downloads for filenames containing
accented characters.
Related to #10973

---------

Co-authored-by: Rorrick Smith <rorrick@bulksms.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-12 10:21:42 +04:00
c6a38e2fc6 fix: Keep Instagram scopes out of new Messenger OAuth flows (#14695)
Removes Instagram permissions from the Facebook Messenger inbox setup
flow to avoid blocking Meta App Review for Messenger-only apps.

Meta rejects or stalls Messenger-only app reviews when the OAuth flow
requests unrelated Instagram permissions such as `instagram_basic` and
`instagram_manage_messages`. This blocks approval for Messenger
permissions like `human_agent`, even when the user only wants to
configure a Facebook Messenger inbox.

New Facebook Messenger inbox setup now requests only the Page and
Messenger permissions required for Messenger. Existing Facebook
Messenger inboxes that already have Instagram details continue to
request Instagram permissions during reauthorization, preserving support
for the legacy combined Facebook/Instagram inbox flow.

Going forward, new Instagram setups should use the dedicated Instagram
channel inbox instead of being bundled into the Messenger setup flow.

Closes #13860

## How to test

1. Go to Settings → Inboxes → Add Inbox → Facebook Messenger.
2. Click Login with Facebook.
3. Verify the OAuth scope does not include `instagram_basic` or
`instagram_manage_messages`.
4. Reauthorize an existing Facebook Messenger inbox with `instagram_id`
present.
5. Verify the reauth scope still includes `instagram_basic` and
`instagram_manage_messages`.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-06-12 09:39:26 +04:00
Sugar Ray LedesmaandGitHub 2663a8495d refactor(security): rename path_without_extensions in Rack::Attack (#14216)
Fix misspelling 'extentions' and clarify comments. Behavior unchanged:
same path-stripping logic for throttle matching (e.g. /auth and
/auth.json). Also correct 'You may' in remote_ip comment.
2026-06-11 18:36:34 +05:30
Sivin VargheseandGitHub d0d275d962 fix: cannot resize pasted images (#14709) 2026-06-11 16:47:11 +05:30
95d6aecb51 chore: Add security flags to session cookie configuration (#14248)
## Summary
- Adds `httponly` and `secure` flags to session cookie configuration
- Adds RSpec tests for session configuration

## Changes
- `config/initializers/session_store.rb`: add `httponly: true`, `secure:
FORCE_SSL`
- `spec/config/session_store_spec.rb`: tests for all session store
options

Ref #2683 (hardens the session cookie but does not remove it, sessions
are still needed for super_admin dashboard)

---------

Co-authored-by: Adam-Relay <adam@userelay.ai>
Co-authored-by: Adam Berger <adam@adam-berger.com>
Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-06-11 16:13:57 +05:30
a901c87ab4 perf: merge 3 conversation COUNT queries in /meta into single query (#13536)
# Pull Request Template

## Description


- Merges 3 separate COUNT queries in
`ConversationFinder#set_count_for_all_conversations` into a single query
using PostgreSQL `COUNT(*) FILTER (WHERE ...)`
- Every call to `/meta` and conversation index fires 3 COUNT queries
against the conversations table — one for "mine", one for "unassigned",
one for "all". These share the same base query and scan the same rows,
but execute as 3 independent round-trips.

from pg_stats

| Query | Calls | Total DB Time | Avg Latency |
|-------|-------|--------------|-------------|
| COUNT unassigned | 4.1M | 141,081 sec | 34ms |
| COUNT all | 4.1M | 132,302 sec | 32ms |
| COUNT mine | 3.7M | 37,637 sec | 10ms |
| **Total** | **~12M** | **311,020 sec** | |

`/meta` alone runs at 2.3K RPM (NewRelic), triggered on every WebSocket
event (conversation created/updated/status changed/assignee changed).



Fixes
https://linear.app/chatwoot/issue/INF-43/make-single-query-to-count-the-assigned-unassigned-and-all-instead-of

## Type of change

- [x] Performance fix

## How Has This Been Tested?

- [x] Verify conversation list shows correct
mine/unassigned/assigned/all counts
- [x] Verify /meta endpoint returns correct counts
- [x] Verify counts update correctly when assigning/unassigning
conversations
- [x] Verify counts with team filter applied
- [x] Monitor DB query count reduction in NewRelic after deploy

## 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

---------

Co-authored-by: Sony Mathew <ynos1234@gmail.com>
2026-06-11 16:10:48 +05:30
Sony MathewandGitHub 6d26e7930a fix(contacts): truncate inbound contact names (IMAP sender display names) (#14631)
## Description

Truncates long contact names in `ContactInboxWithContactBuilder` before
persisting them, so inbound IMAP sender display names over the
`contacts.name` 255-character limit no longer raise
`ActiveRecord::RecordInvalid` from `Inboxes::FetchImapEmailsJob`. This
keeps email ingestion moving while preserving the existing contact name
length constraint.

Linear ticket:
[https://linear.app/chatwoot/issue/CW-7263/sentry-active-record-invalid-contact-create-from-imap-fetch-job](https://linear.app/chatwoot/issue/CW-7263/sentry-active-record-invalid-contact-create-from-imap-fetch-job)

## Type of change

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

## How Has This Been Tested?

- [x] `bundle exec rspec
spec/builders/contact_inbox_with_contact_builder_spec.rb`
- [x] `bundle exec rspec spec/mailboxes/imap/imap_mailbox_spec.rb`
- [x] `bundle exec rubocop
app/builders/contact_inbox_with_contact_builder.rb
spec/builders/contact_inbox_with_contact_builder_spec.rb`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [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
2026-06-11 15:15:14 +05:30
Tanmay Deep SharmaandGitHub 8d5d02ea97 feat: add per-inbox toggle to disable incoming calls (#14645)
## Description

Adds a per-inbox "Allow incoming calls" toggle for voice-enabled
WhatsApp and Twilio inboxes. When turned off, the setting is persisted
on the channel; actually rejecting inbound calls is handled in a
follow-up PR.

## Type of change

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

## Screenshot
<img width="804" height="384" alt="Screenshot 2026-06-04 at 11 44 07 AM"
src="https://github.com/user-attachments/assets/df8bb026-0387-4031-bcba-6d9a56872eb7"
/>


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] 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
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-11 14:22:44 +05:30
ce93ddec78 fix: re-fetch widget conversation status on reconnect (#14683)
When an inbox has **"Allow messages after conversation is resolved"**
disabled, the live-chat widget should hide the reply box once a
conversation is resolved — but users could still send a reply, silently
reopening it. This syncs the widget's conversation status on reconnect
so the reply box hides correctly.

## Closes
- CW-7272

## How to reproduce
1. Disable **Allow messages after conversation is resolved** on an
inbox.
2. Open the widget, then let its websocket drop (background tab / lose
network).
3. While disconnected, get the conversation resolved (e.g. auto-resolve
on inactivity).
4. Reconnect → reply box is still visible and a sent message reopens the
resolved conversation.

## Why
Reply-box hiding is gated on the widget's local status, updated via the
`conversation.status_changed` socket event. If the resolve happens while
disconnected, the event is missed — and on reconnect the widget only
re-synced messages, never the status, so it stayed stale at `open`.

## What changed
- `onReconnect` now also dispatches
`conversationAttributes/getAttributes` to refresh status after a missed
event.
- Added a spec covering the reconnect behavior.

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-06-11 14:09:13 +05:30
Sivin VargheseandGitHub 940428c10e fix: preserve markdown links with unsafe href characters in help center content (#14686) 2026-06-11 13:47:08 +05:30
4d3196b02f feat: Show unread count for all conversations (#14627)
## Description

Adds the unread count for All Conversations to the left sidebar. The
unread counts API now returns an `all_count` aggregate based on the same
permission-scoped inbox counts already used for sidebar badges, and the
sidebar renders that backend-provided value on the All Conversations
item.

Fixes
[CW-7240](https://linear.app/chatwoot/issue/CW-7240/unread-count-for-all-conversations)

## 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)
- [ ] This change requires a documentation update

## How Has This Been Tested?

- `bundle exec rspec
spec/services/conversations/unread_counts/counter_spec.rb
spec/enterprise/services/conversations/unread_counts/counter_spec.rb
spec/controllers/api/v1/accounts/conversations_controller_spec.rb`
- `pnpm test
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js`
- `pnpm exec eslint
app/javascript/dashboard/components-next/sidebar/Sidebar.vue
app/javascript/dashboard/store/modules/conversationUnreadCounts.js
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js`
- `bundle exec rubocop
app/services/conversations/unread_counts/counter.rb
spec/services/conversations/unread_counts/counter_spec.rb
spec/enterprise/services/conversations/unread_counts/counter_spec.rb
spec/controllers/api/v1/accounts/conversations_controller_spec.rb`

## 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

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-06-11 13:05:52 +05:30
Tanmay Deep SharmaandGitHub c6e5657ee5 fix(call): emit assignee activity message when agent answers a call (#14700)
## Linear Ticket
- https://linear.app/chatwoot/issue/CW-7249/debug-assignment-log

## Description

When an agent answers an inbound WhatsApp call on an unassigned
conversation, the conversation is claimed for that agent — but no
"assigned" activity message or assignee-changed event was emitted, so
the assignment was invisible in the timeline (and notifications/live
assignee panel didn't update).

The claim ran before `update_conversation_call_status`, so that later
`update!` on the same conversation clobbered
`saved_change_to_assignee_id?` before `after_commit` fired. Moving the
claim to be the conversation's final write in the transaction restores
the activity message, the `ASSIGNEE_CHANGED` event, and the live
assignee update.

## Type of change

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

## How Has This Been Tested?

1. Open an unassigned WhatsApp-call conversation.
2. As an agent, answer an inbound call.
3. Before: the conversation is assigned to you, but no "self-assigned"
activity message appears. After: the activity message is created and the
assignee updates live.

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] 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
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-11 11:16:41 +05:30
Sony Mathew e9a92a6366 Merge branch 'release/4.14.2' into develop 2026-06-10 22:44:31 +05:30
Sony Mathew bec795f764 Bump version to 4.14.2 2026-06-10 22:43:22 +05:30
Shivam MishraandGitHub e6dfb91fcc refactor: use SafeFetch for website branding page fetch (#14693) 2026-06-10 16:26:24 +05:30
Shivam MishraandGitHub d9c07fe2e9 feat: expose onboarding help center generation status (#14671)
This PR adds a reload-safe onboarding Help Center generation status
path. Previously, generation progress was pushed through ActionCable,
which meant the onboarding UI could lose context after a page reload or
missed websocket event. The new endpoint exposes the current generation
id, raw Redis generation state, and Help Center article/category counts
so clients can recover state by fetching from the backend.

**What changed**

- Added an onboarding Help Center generation status endpoint.
- Persisted `help_center_generation_id` when generation is enqueued.
- Removed Help Center generation ActionCable broadcasts completely.
- Kept generation progress in Redis as the backend source of truth.
- Kept Help Center generation out of the onboarding hot path; the
existing onboarding controller does not start generation yet.

**How to test**

1. Start Help Center generation for an account.
2. Fetch the onboarding generation status endpoint and verify it returns
the generation id, Redis state, and article/category counts.
3. Reload the onboarding UI or client state and fetch again to confirm
progress can be recovered without relying on websocket events.
4. Verify skipped/completed generation states are reflected from Redis.

## Related PRs

- https://github.com/chatwoot/chatwoot/pull/14569
- https://github.com/chatwoot/chatwoot/pull/14568
- https://github.com/chatwoot/chatwoot/pull/14567
- https://github.com/chatwoot/chatwoot/pull/14619
- https://github.com/chatwoot/chatwoot/pull/14565 (Primary onboarding
PR)
2026-06-10 16:24:37 +05:30
f93f2067b6 fix(whatsapp): Drop obsolete WABA scope check broken by Meta embedded signup (#14697)
## Description

Fixes WhatsApp embedded signup failing with "No WABA scope found in
token." Meta recently changed which scopes embedded-signup tokens carry
(whatsapp_business_manage_events instead of
whatsapp_business_management), which tripped a brittle scope-string
check. That check was redundant anyway — PhoneInfoService already
verifies the token's access to the specific WABA by calling its
/phone_numbers endpoint right before it. This removes the obsolete
TokenValidationService and relies on the functional check.

## Type of change

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

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] 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
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-06-10 13:22:30 +04:00
Shivam MishraandGitHub 3dfb5061e1 refactor: route captain custom tool requests through SafeFetch (#14620)
Captain custom tools previously used their own hand-rolled `Net::HTTP`
request code. This moves them onto `SafeFetch`, the same shared
HTTP-fetching helper already used by webhooks, uploads, avatar imports,
and the Captain page crawler. Behavior for normal tools is unchanged —
they just now share one consistent path for host resolution, timeouts,
response size limits, and redirect handling.

**What changed**
- `HttpTool#execute_http_request` now delegates to `SafeFetch.fetch`
instead of building `Net::HTTP` requests by hand. Auth headers, basic
auth, metadata headers, JSON content-type, and the 1 MB response cap all
map onto `SafeFetch` options.
- Removed ~80 lines of bespoke request/validation plumbing from
`HttpTool`.
- The custom tools `test` endpoint now reads the response body string
directly (the executor returns the body rather than a response object).

**How to test**
1. Enable `custom_tools` (or `captain_integration_v2`) for an account.
2. Create a Captain custom tool pointing at a public HTTPS endpoint
(e.g. a test API).
3. Use the **Test** button in the tool form — you should get a success
result.
4. Run the tool from a Captain conversation and confirm the response is
returned/templated as before.

**Gotchas**
- **Local dev:** `SafeFetch` blocks requests to private/loopback
addresses by default. If you're testing a custom tool against a service
on `localhost` or a private IP during development, set
`SAFE_FETCH_ALLOW_PRIVATE_NETWORK=true` or the request will be rejected.
(This matches how the rest of `SafeFetch` already behaves locally.)
- **Test endpoint status field:** on success the `test` response now
reports `status: 200` rather than the exact 2xx code (201/204/etc.),
because `SafeFetch` signals success-vs-failure rather than exposing the
raw response. The UI only checks the 2xx range, so this is invisible
there — but worth knowing if anything consumes the API directly.
- **Non-2xx responses** still surface as an error (same as before), now
via `SafeFetch::HttpError`.
2026-06-10 14:01:30 +05:30
72a59e4795 fix: update oauth dependencies (#14691)
Updates the locked OAuth dependencies to patched versions so bundle
audit no longer reports the current OAuth advisories.

What changed
- Updated `oauth` from `1.1.0` to `1.1.6` for `GHSA-prq8-7wvh-44qh`.
- Updated `oauth2` from `2.0.9` to `2.0.22` for `GHSA-pp92-crg2-gfv9`.
- Accepted Bundler's required transitive lockfile updates for the
patched OAuth gems.

How to test
1. Run `bundle exec bundle audit update && bundle exec bundle audit
check -v` and confirm no vulnerabilities are reported.
2. Smoke test OAuth-based authentication and email integration flows.

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-06-10 12:33:46 +05:30
Sivin VargheseandGitHub cabe9bc733 fix: populate general settings form on hard reload (#14685) 2026-06-10 10:56:17 +05:30
33f7550525 fix(whatsapp): restrict OGG voice recording to WhatsApp Cloud inboxes (#14692)
Recording and sending an audio message from a **Twilio WhatsApp** inbox
failed silently — Twilio rejected the media with delivery error `63019`
("Media failed to download") and the voice note never reached the
customer. This restores audio sending for Twilio WhatsApp (and
360dialog) inboxes.

Closes Regression from #14606

## How to reproduce

1. Open a conversation in a **Twilio WhatsApp** inbox.
2. Record a voice message in the reply box and send it.
3. Before this fix: the message fails to deliver and a
`Webhooks::TwilioDeliveryStatusJob` is enqueued with `ErrorCode: 63019`,
`ErrorMessage: "Media failed to download"`.
4. After this fix: the audio is recorded as MP3 and delivers normally.

## What changed

PR #14606 added WhatsApp **Cloud** voice notes, which require OGG/Opus.
It changed `audioRecordFormat` in `ReplyBox.vue` to return OGG for
`isAWhatsAppChannel` — but that getter is also `true` for Twilio
WhatsApp inboxes. The OGG handling (content-type normalization + the
`voice: true` flag) lives only in `WhatsappCloudService`, so Twilio
could not download/process the remuxed OGG file.

This change scopes OGG to `isAWhatsAppCloudChannel`. Twilio WhatsApp,
360dialog, and Telegram fall back to MP3 exactly as they did before the
PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:52:34 +04:00
149 changed files with 1851 additions and 1085 deletions
+32 -21
View File
@@ -136,6 +136,8 @@ GEM
audited (5.4.1)
activerecord (>= 5.0, < 7.7)
activesupport (>= 5.0, < 7.7)
auth-sanitizer (0.2.1)
version_gem (~> 1.1, >= 1.1.10)
aws-actionmailbox-ses (0.1.0)
actionmailbox (>= 7.1.0)
aws-sdk-s3 (~> 1, >= 1.123.0)
@@ -168,7 +170,7 @@ GEM
base64 (0.3.0)
bcrypt (3.1.22)
benchmark (0.4.1)
bigdecimal (3.2.2)
bigdecimal (3.3.1)
bindex (0.8.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
@@ -184,6 +186,7 @@ GEM
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
byebug (11.1.3)
cgi (0.5.1)
childprocess (5.1.0)
logger (~> 1.5)
cld3 (3.7.0)
@@ -312,7 +315,7 @@ GEM
hashie
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.2)
faraday-net_http (3.4.4)
net-http (~> 0.5)
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
@@ -435,7 +438,8 @@ GEM
hana (1.3.7)
hash_diff (1.1.1)
hashdiff (1.1.0)
hashie (5.0.0)
hashie (5.1.0)
logger
html2text (0.4.0)
nokogiri (>= 1.0, < 2.0)
http (5.1.1)
@@ -470,7 +474,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.19.5)
json (2.19.8)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -568,7 +572,7 @@ GEM
ruby2_keywords
msgpack (1.8.0)
multi_json (1.15.0)
multi_xml (0.8.0)
multi_xml (0.9.1)
bigdecimal (>= 3.1, < 5)
multipart-post (2.4.1)
mutex_m (0.3.0)
@@ -578,7 +582,7 @@ GEM
uri (>= 0.11.1)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.24)
net-imap (0.6.4.1)
date
net-protocol
net-pop (0.1.2)
@@ -603,19 +607,26 @@ GEM
racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
oauth-tty (1.0.5)
version_gem (~> 1.1, >= 1.1.1)
oauth2 (2.0.9)
faraday (>= 0.17.3, < 3.0)
jwt (>= 1.0, < 3.0)
oauth (1.1.6)
auth-sanitizer (~> 0.2, >= 0.2.1)
base64 (~> 0.1)
cgi
oauth-tty (~> 1.0, >= 1.0.8)
snaky_hash (~> 2.0, >= 2.0.5)
version_gem (~> 1.1, >= 1.1.11)
oauth-tty (1.0.8)
auth-sanitizer (~> 0.1, >= 0.1.3)
cgi
version_gem (~> 1.1, >= 1.1.9)
oauth2 (2.0.22)
auth-sanitizer (~> 0.2, >= 0.2.1)
faraday (>= 0.17.3, < 4.0)
jwt (>= 1.0, < 4.0)
logger (~> 1.2)
multi_xml (~> 0.5)
rack (>= 1.2, < 4)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
snaky_hash (~> 2.0, >= 2.0.5)
version_gem (~> 1.1, >= 1.1.11)
oj (3.16.10)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
@@ -935,9 +946,9 @@ GEM
gli
hashie
logger
snaky_hash (2.0.1)
hashie
version_gem (~> 1.1, >= 1.1.1)
snaky_hash (2.0.5)
hashie (>= 0.1.0, < 6)
version_gem (>= 1.1.8, < 3)
sorbet-runtime (0.5.11934)
spring (4.1.1)
spring-watcher-listen (2.1.0)
@@ -995,7 +1006,7 @@ GEM
valid_email2 (5.2.6)
activemodel (>= 3.2)
mail (~> 2.5)
version_gem (1.1.4)
version_gem (1.1.11)
vite_rails (3.10.0)
railties (>= 5.1, < 9)
vite_ruby (~> 3.0, >= 3.2.2)
+1 -1
View File
@@ -1 +1 @@
4.14.1
4.14.2
@@ -50,7 +50,7 @@ class ContactInboxWithContactBuilder
def create_contact
account.contacts.create!(
name: contact_attributes[:name] || ::Haikunator.haikunate(1000),
name: contact_name,
phone_number: contact_attributes[:phone_number],
email: contact_attributes[:email],
identifier: contact_attributes[:identifier],
@@ -59,6 +59,11 @@ class ContactInboxWithContactBuilder
)
end
def contact_name
name = contact_attributes[:name] || ::Haikunator.haikunate(1000)
name.truncate(ApplicationRecord::MAX_STRING_COLUMN_LENGTH, omission: '')
end
def find_contact
contact = find_contact_by_identifier(contact_attributes[:identifier])
contact ||= find_contact_by_email(contact_attributes[:email])
-14
View File
@@ -7,7 +7,6 @@ class RoomChannel < ApplicationCable::Channel
ensure_stream
update_subscription
broadcast_presence
transmit_cache_keys
end
def update_presence
@@ -25,19 +24,6 @@ class RoomChannel < ApplicationCable::Channel
ActionCable.server.broadcast(pubsub_token, { event: 'presence.update', data: data })
end
# Push the authoritative cache-key map to this subscriber on every
# (re)subscribe. Boot and reconnect cache freshness ride the same
# account.cache_invalidated event the dashboard already handles for live
# invalidations — the client never pulls /cache_keys itself.
def transmit_cache_keys
return if @current_account.blank? || !@current_user.is_a?(User)
transmit({
event: Events::Types::ACCOUNT_CACHE_INVALIDATED,
data: { account_id: @current_account.id, cache_keys: @current_account.cache_keys }
})
end
def ensure_stream
stream_from pubsub_token
stream_from "account_#{@current_account.id}" if @current_account.present? && @current_user.is_a?(User)
@@ -16,6 +16,10 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
render 'api/v1/accounts/update', format: :json
end
def help_center_generation
render json: help_center_generation_status
end
private
def finalizing_account_details?
@@ -33,4 +37,15 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
def custom_attributes_params
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
end
def help_center_generation_status
{
generation_id: nil,
state: nil,
articles_count: 0,
categories_count: 0
}
end
end
Api::V1::Accounts::OnboardingsController.prepend_mod_with('Api::V1::Accounts::OnboardingsController')
@@ -50,6 +50,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def cache_keys
expires_in 10.seconds, public: false, stale_while_revalidate: 5.minutes
render json: { cache_keys: cache_keys_for_account }, status: :ok
end
@@ -92,7 +93,11 @@ class Api::V1::AccountsController < Api::BaseController
end
def cache_keys_for_account
@account.cache_keys
{
label: fetch_value_for_key(params[:id], Label.name.underscore),
inbox: fetch_value_for_key(params[:id], Inbox.name.underscore),
team: fetch_value_for_key(params[:id], Team.name.underscore)
}
end
def fetch_account
@@ -0,0 +1,36 @@
class Api::V1::Profile::SessionsController < Api::BaseController
before_action :set_session, only: [:destroy]
def index
@sessions = current_user.user_sessions.where(client_id: active_token_client_ids).order(last_activity_at: :desc)
@current_client_id = request.headers['client']
end
def destroy
if @session.current?(request.headers['client'])
render json: { error: I18n.t('profile_settings.sessions.cannot_revoke_current') }, status: :unprocessable_entity
return
end
revoke_token!(@session.client_id)
@session.destroy!
head :ok
end
private
def set_session
@session = current_user.user_sessions.find(params[:id])
end
def revoke_token!(client_id)
tokens = current_user.tokens
tokens.delete(client_id)
current_user.update!(tokens: tokens)
end
def active_token_client_ids
now = Time.current.to_i
(current_user.tokens || {}).select { |_, v| v['expiry'].to_i > now }.keys
end
end
@@ -13,17 +13,11 @@ class Api::V1::ProfilesController < Api::BaseController
@user.assign_attributes(profile_params)
@user.custom_attributes.merge!(custom_attributes_params)
@user.save!
# Profile updates can change cached agent fields, including avatar-backed thumbnails.
@user.invalidate_avatar_cache
end
def avatar
@user.avatar.attachment.destroy! if @user.avatar.attached?
@user.reload
# Agent thumbnails are cached separately, and avatar attachment deletes do not dirty user columns.
@user.invalidate_avatar_cache
end
def auto_offline
@@ -3,6 +3,7 @@ class ApplicationController < ActionController::Base
include RequestExceptionHandler
include Pundit::Authorization
include SwitchLocale
include TrackSessionActivity
skip_before_action :verify_authenticity_token
@@ -0,0 +1,22 @@
module TrackSessionActivity
extend ActiveSupport::Concern
included do
after_action :update_session_activity
end
private
def update_session_activity
return unless current_user
return if request.headers['client'].blank?
UserSessionTrackingService.new(
user: current_user,
request: request,
client_id: request.headers['client']
).update_activity!
rescue StandardError => e
Rails.logger.warn "Session activity update failed: #{e.message}"
end
end
@@ -20,6 +20,7 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def render_create_success
track_user_session
render partial: 'devise/auth', formats: [:json], locals: { resource: @resource }
end
@@ -114,6 +115,19 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
def render_mfa_error(message_key, status = :bad_request)
render json: { error: I18n.t(message_key) }, status: status
end
def track_user_session
client_id = @token&.try(:client) || response.headers['client']
return unless client_id.present? && @resource.present?
UserSessionTrackingService.new(
user: @resource,
request: request,
client_id: client_id
).create_or_update!
rescue StandardError => e
Rails.logger.warn "Session tracking failed: #{e.message}"
end
end
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
+12 -1
View File
@@ -40,7 +40,7 @@ class ConversationFinder
def perform
set_up
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
mine_count, unassigned_count, all_count = set_count_for_all_conversations
assigned_count = all_count - unassigned_count
filter_by_assignee_type
@@ -184,6 +184,17 @@ class ConversationFinder
end
def set_count_for_all_conversations
return legacy_count_for_all_conversations if @conversations.limit_value || @conversations.offset_value || @conversations.eager_loading?
counts = @conversations.unscope(:order).pick(
Arel.sql("COUNT(*) FILTER (WHERE assignee_id = #{current_user.id})"),
Arel.sql('COUNT(*) FILTER (WHERE assignee_id IS NULL)'),
Arel.sql('COUNT(*)')
)
counts || [0, 0, 0]
end
def legacy_count_for_all_conversations
[
@conversations.assigned_to(current_user).count,
@conversations.unassigned.count,
+2 -10
View File
@@ -19,7 +19,6 @@ import {
verifyServiceWorkerExistence,
} from './helper/pushHelper';
import ReconnectService from 'dashboard/helper/ReconnectService';
import paintStoresFromCache from 'dashboard/helper/CacheHelper/paintStoresFromCache';
import { useUISettings } from 'dashboard/composables/useUISettings';
export default {
@@ -109,21 +108,14 @@ export default {
this.$store.dispatch('setActiveAccount', {
accountId: this.currentAccountId,
});
const { pubsub_token: pubsubToken } = this.currentUser || {};
vueActionCable.init(this.store, pubsubToken);
// Paint cached config from IndexedDB instantly while the cable
// connects. Freshness needs no orchestration here: RoomChannel pushes
// the cache-key map on every (re)subscribe and on every server-side
// change, all through the same account.cache_invalidated event.
await paintStoresFromCache(this.$store, this.currentAccountId);
const account = this.getAccount(this.currentAccountId);
const { locale, latest_chatwoot_version: latestChatwootVersion } =
account;
const { pubsub_token: pubsubToken } = this.currentUser || {};
// If user locale is set, use it; otherwise use account locale
this.setLocale(this.uiSettings?.locale || locale);
this.latestChatwootVersion = latestChatwootVersion;
vueActionCable.init(this.store, pubsubToken);
this.reconnectService = new ReconnectService(this.store, this.router);
window.reconnectService = this.reconnectService;
@@ -5,15 +5,14 @@ import ApiClient from './ApiClient';
class CacheEnabledApiClient extends ApiClient {
constructor(resource, options = {}) {
super(resource, options);
// `cacheModel` is the Rails Model.name.underscore value — simultaneously
// the server cache-key name and the IDB object-store name.
this.cacheModelName = options.cacheModel;
// inbox/label endpoints wrap collections in { payload }; the rest return
// the bare array.
this.payloadEnvelope = options.payloadEnvelope || false;
this.dataManager = new DataManager(this.accountIdFromRoute);
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
throw new Error('cacheModelName is not defined');
}
get(cache = false) {
if (cache) {
return this.getFromCache();
@@ -26,14 +25,14 @@ class CacheEnabledApiClient extends ApiClient {
return axios.get(this.url);
}
// eslint-disable-next-line class-methods-use-this
extractDataFromResponse(response) {
return this.payloadEnvelope ? response.data.payload : response.data;
return response.data.payload;
}
// eslint-disable-next-line class-methods-use-this
marshallData(dataToParse) {
return this.payloadEnvelope
? { data: { payload: dataToParse } }
: { data: dataToParse };
return { data: { payload: dataToParse } };
}
async getFromCache() {
@@ -44,23 +43,24 @@ class CacheEnabledApiClient extends ApiClient {
return this.getFromNetwork();
}
// Trust the IDB cache. Freshness is maintained by the
// account.cache_invalidated event alone: RoomChannel pushes the cache-key
// map on every (re)subscribe — boot and reconnect included — and the
// server broadcasts it on every change. Skipping a per-call /cache_keys
// preflight eliminates N GET requests per cold settings-page load.
const localData = await this.dataManager.get({
modelName: this.cacheModelName,
});
const { data } = await axios.get(
`/api/v1/accounts/${this.accountIdFromRoute}/cache_keys`
);
const cacheKeyFromApi = data.cache_keys[this.cacheModelName];
const isCacheValid = await this.validateCacheKey(cacheKeyFromApi);
if (localData.length > 0) {
return this.marshallData(localData);
let localData = [];
if (isCacheValid) {
localData = await this.dataManager.get({
modelName: this.cacheModelName,
});
}
// Empty IDB (first load or wiped): fetch data without a cache key. The
// next pushed key map won't match the missing key and will refetch once,
// stamping the authoritative key — the client never pulls keys itself.
return this.refetchAndCommit(null);
if (localData.length === 0) {
return this.refetchAndCommit(cacheKeyFromApi);
}
return this.marshallData(localData);
}
async refetchAndCommit(newKey = null) {
@@ -69,9 +69,7 @@ class CacheEnabledApiClient extends ApiClient {
try {
await this.dataManager.initDb();
// Await replace so data is persisted before the cache key is — otherwise
// a concurrent reader could see a fresh key paired with stale data.
await this.dataManager.replace({
this.dataManager.replace({
modelName: this.cacheModelName,
data: this.extractDataFromResponse(response),
});
@@ -91,15 +89,8 @@ class CacheEnabledApiClient extends ApiClient {
await this.dataManager.initDb();
}
const cacheKey = await this.dataManager.getCacheKey(this.cacheModelName);
if (cacheKey === undefined) {
const localData = await this.dataManager.get({
modelName: this.cacheModelName,
});
return localData.length === 0;
}
return cacheKeyFromApi === cacheKey;
const cachekey = await this.dataManager.getCacheKey(this.cacheModelName);
return cacheKeyFromApi === cachekey;
}
}
+7
View File
@@ -9,6 +9,13 @@ class AccountAPI extends ApiClient {
createAccount(data) {
return axios.post(`${this.apiVersion}/accounts`, data);
}
async getCacheKeys() {
const response = await axios.get(
`/api/v1/accounts/${this.accountIdFromRoute}/cache_keys`
);
return response.data.cache_keys;
}
}
export default new AccountAPI();
+3 -3
View File
@@ -1,10 +1,10 @@
/* global axios */
import CacheEnabledApiClient from './CacheEnabledApiClient';
import ApiClient from './ApiClient';
class Agents extends CacheEnabledApiClient {
class Agents extends ApiClient {
constructor() {
super('agents', { accountScoped: true, cacheModel: 'account_user' });
super('agents', { accountScoped: true });
}
bulkInvite({ emails }) {
+5 -7
View File
@@ -1,15 +1,13 @@
import CacheEnabledApiClient from './CacheEnabledApiClient';
/* global axios */
import ApiClient from './ApiClient';
class AttributeAPI extends CacheEnabledApiClient {
class AttributeAPI extends ApiClient {
constructor() {
super('custom_attribute_definitions', {
accountScoped: true,
cacheModel: 'custom_attribute_definition',
});
super('custom_attribute_definitions', { accountScoped: true });
}
getAttributesByModel() {
return super.get(true);
return axios.get(this.url);
}
}
+6
View File
@@ -106,4 +106,10 @@ export default {
const urlData = endPoints('resetAccessToken');
return axios.post(urlData.url);
},
getSessions() {
return axios.get('/api/v1/profile/sessions');
},
revokeSession(id) {
return axios.delete(`/api/v1/profile/sessions/${id}`);
},
};
+6 -11
View File
@@ -1,20 +1,15 @@
/* global axios */
import CacheEnabledApiClient from './CacheEnabledApiClient';
import ApiClient from './ApiClient';
class CannedResponse extends CacheEnabledApiClient {
class CannedResponse extends ApiClient {
constructor() {
super('canned_responses', {
accountScoped: true,
cacheModel: 'canned_response',
});
super('canned_responses', { accountScoped: true });
}
get({ searchKey } = {}) {
if (searchKey) {
return axios.get(`${this.url}?search=${searchKey}`);
}
return super.get(true);
get({ searchKey }) {
const url = searchKey ? `${this.url}?search=${searchKey}` : this.url;
return axios.get(url);
}
}
+12 -5
View File
@@ -3,11 +3,12 @@ import CacheEnabledApiClient from './CacheEnabledApiClient';
class Inboxes extends CacheEnabledApiClient {
constructor() {
super('inboxes', {
accountScoped: true,
cacheModel: 'inbox',
payloadEnvelope: true,
});
super('inboxes', { accountScoped: true });
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
return 'inbox';
}
getCampaigns(inboxId) {
@@ -59,6 +60,12 @@ class Inboxes extends CacheEnabledApiClient {
disableWhatsappCalling(inboxId) {
return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`);
}
setInboundCalls(inboxId, enabled) {
return axios.post(`${this.url}/${inboxId}/set_inbound_calls`, {
inbound_calls_enabled: enabled,
});
}
}
export default new Inboxes();
+6 -5
View File
@@ -2,11 +2,12 @@ import CacheEnabledApiClient from './CacheEnabledApiClient';
class LabelsAPI extends CacheEnabledApiClient {
constructor() {
super('labels', {
accountScoped: true,
cacheModel: 'label',
payloadEnvelope: true,
});
super('labels', { accountScoped: true });
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
return 'label';
}
}
@@ -9,6 +9,10 @@ class OnboardingAPI extends ApiClient {
update(data) {
return axios.patch(this.url, data);
}
getHelpCenterGeneration() {
return axios.get(`${this.url}/help_center_generation`);
}
}
export default new OnboardingAPI();
+17 -1
View File
@@ -1,9 +1,25 @@
/* global axios */
// import ApiClient from './ApiClient';
import CacheEnabledApiClient from './CacheEnabledApiClient';
export class TeamsAPI extends CacheEnabledApiClient {
constructor() {
super('teams', { accountScoped: true, cacheModel: 'team' });
super('teams', { accountScoped: true });
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
return 'team';
}
// eslint-disable-next-line class-methods-use-this
extractDataFromResponse(response) {
return response.data;
}
// eslint-disable-next-line class-methods-use-this
marshallData(dataToParse) {
return { data: dataToParse };
}
getAgents({ teamId }) {
@@ -175,6 +175,9 @@ useEventListener(document, 'touchend', onResizeEnd);
const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabelsOnSidebar');
const allUnreadCount = useMapGetter(
'conversationUnreadCounts/getAllUnreadCount'
);
const getInboxUnreadCount = useMapGetter(
'conversationUnreadCounts/getInboxUnreadCount'
);
@@ -297,6 +300,7 @@ const menuItems = computed(() => {
{
name: 'All',
label: t('SIDEBAR.ALL_CONVERSATIONS'),
badgeCount: allUnreadCount.value,
activeOn: ['inbox_conversation'],
to: accountScopedRoute('home'),
},
@@ -375,10 +375,10 @@ export default {
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
},
audioRecordFormat() {
if (this.isAWhatsAppChannel) {
if (this.isAWhatsAppCloudChannel) {
return AUDIO_FORMATS.OGG;
}
if (this.isATelegramChannel) {
if (this.isAWhatsAppChannel || this.isATelegramChannel) {
return AUDIO_FORMATS.MP3;
}
if (this.isAPIInbox) {
@@ -70,7 +70,8 @@ describe('useFacebookPageConnect', () => {
ACCOUNT_ID
);
expect(window.FB.login).toHaveBeenCalledWith(expect.any(Function), {
scope: expect.stringContaining('pages_show_list'),
scope:
'pages_manage_metadata,business_management,pages_messaging,pages_show_list,pages_read_engagement',
});
});
@@ -1,13 +1,9 @@
import { ref } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import ChannelApi from 'dashboard/api/channels';
import { buildFacebookLoginScopes } from 'dashboard/helper/facebookScopes';
import { setupFacebookSdk } from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
// Page-management + messaging scopes required to list pages and create a
// Channel::FacebookPage inbox (mirrors the standalone settings flow).
const FB_PAGE_SCOPES =
'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages';
// Headless half of the Facebook Page connect flow: load the Meta SDK, run
// FB.login for page scopes, and fetch the user's pages. The caller owns the
// page-picker UI and the channel creation, because choosing a page is an
@@ -51,7 +47,7 @@ export function useFacebookPageConnect() {
: null
);
},
{ scope: FB_PAGE_SCOPES }
{ scope: buildFacebookLoginScopes() }
);
});
@@ -154,6 +154,10 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
SHARE_CLICKED: 'Year in Review: Share clicked',
});
export const SESSION_EVENTS = Object.freeze({
REVOKED_FROM_PROFILE: 'Revoked an active session',
});
export const ONBOARDING_EVENTS = Object.freeze({
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
@@ -1,10 +1,9 @@
import { openDB } from 'idb';
import { DATA_VERSION } from './version';
import { cacheableModels, cacheableModelNames } from './cacheableModels';
export class DataManager {
constructor(accountId) {
this.modelsToSync = cacheableModelNames;
this.modelsToSync = ['inbox', 'label', 'team'];
this.accountId = accountId;
this.db = null;
}
@@ -12,26 +11,12 @@ export class DataManager {
async initDb() {
if (this.db) return this.db;
const dbName = `cw-store-${this.accountId}`;
this.db = await openDB(dbName, DATA_VERSION, {
upgrade(db, oldVersion, _newVersion, tx) {
// Flush data carried over from a previous schema version so a
// DATA_VERSION bump acts as a global cache reset. oldVersion === 0 on
// first install, so fresh devices skip this. Clearing before creating
// means we only ever clear stores that pre-existed this upgrade.
if (oldVersion > 0) {
for (let index = 0; index < db.objectStoreNames.length; index += 1) {
tx.objectStore(db.objectStoreNames.item(index)).clear();
}
}
if (!db.objectStoreNames.contains('cache-keys')) {
db.createObjectStore('cache-keys');
}
cacheableModels.forEach(model => {
if (!db.objectStoreNames.contains(model.name)) {
db.createObjectStore(model.name, { keyPath: 'id' });
}
});
this.db = await openDB(`cw-store-${this.accountId}`, DATA_VERSION, {
upgrade(db) {
db.createObjectStore('cache-keys');
db.createObjectStore('inbox', { keyPath: 'id' });
db.createObjectStore('label', { keyPath: 'id' });
db.createObjectStore('team', { keyPath: 'id' });
},
});
@@ -56,7 +41,7 @@ export class DataManager {
async replace({ modelName, data }) {
this.validateModel(modelName);
await this.db.clear(modelName);
this.db.clear(modelName);
return this.push({ modelName, data });
}
@@ -80,11 +65,9 @@ export class DataManager {
}
async setCacheKeys(cacheKeys) {
await Promise.all(
Object.entries(cacheKeys).map(([modelName, value]) =>
this.db.put('cache-keys', value, modelName)
)
);
Object.keys(cacheKeys).forEach(async modelName => {
this.db.put('cache-keys', cacheKeys[modelName], modelName);
});
}
async getCacheKey(modelName) {
@@ -1,23 +0,0 @@
// Single source of truth for IDB-cached workspace config.
//
// Each entry must keep `name` equal to the Rails `Model.name.underscore` value
// so the server's `cache_keys` payload (and the IDB object store name) lines up
// with what the client looks up.
//
// `setMutation` is the full commit path used to seed Vuex from IDB (boot
// paint) and to swap in refetched rows (event-driven revalidation). Every
// SET_* mutation must REPLACE its records (not merge) so rows deleted
// server-side never survive as phantoms.
export const cacheableModels = [
{ name: 'inbox', setMutation: 'inboxes/SET_INBOXES' },
{ name: 'label', setMutation: 'labels/SET_LABELS' },
{ name: 'team', setMutation: 'teams/SET_TEAMS' },
{ name: 'canned_response', setMutation: 'SET_CANNED' },
{ name: 'account_user', setMutation: 'agents/SET_AGENTS' },
{
name: 'custom_attribute_definition',
setMutation: 'attributes/SET_CUSTOM_ATTRIBUTE',
},
];
export const cacheableModelNames = cacheableModels.map(model => model.name);
@@ -1,42 +0,0 @@
import AgentAPI from 'dashboard/api/agents';
import AttributeAPI from 'dashboard/api/attributes';
import CannedResponseAPI from 'dashboard/api/cannedResponse';
import InboxesAPI from 'dashboard/api/inboxes';
import LabelsAPI from 'dashboard/api/labels';
import TeamsAPI from 'dashboard/api/teams';
import { cacheableModels } from './cacheableModels';
// model name → cache-enabled API client. Lives here rather than in
// cacheableModels to keep that module import-cycle-free: the API clients
// import DataManager, which imports cacheableModels.
const apiByModel = {
inbox: InboxesAPI,
label: LabelsAPI,
team: TeamsAPI,
canned_response: CannedResponseAPI,
account_user: AgentAPI,
custom_attribute_definition: AttributeAPI,
};
const revalidateModel = async (store, model, newKey) => {
try {
const api = apiByModel[model.name];
if (await api.validateCacheKey(newKey)) return;
const response = await api.refetchAndCommit(newKey);
store.commit(model.setMutation, api.extractDataFromResponse(response));
} catch {
// Ignore error — a failed refetch leaves the painted data in place; the
// next pushed key map retries.
}
};
// The single freshness engine: given a pushed { model_name => key } map
// (RoomChannel transmits one on every (re)subscribe, the server broadcasts
// one on every change), diff each key against IDB and refetch mismatches.
export const dispatchCacheRevalidations = (store, keys = {}) =>
Promise.all(
cacheableModels
.filter(model => keys[model.name] !== undefined)
.map(model => revalidateModel(store, model, keys[model.name]))
);
@@ -1,31 +0,0 @@
import { DataManager } from './DataManager';
import { cacheableModels } from './cacheableModels';
// Seed Vuex from IndexedDB before the dashboard renders so warm boots paint
// cached config instantly. This is purely local — zero network calls.
//
// Freshness is handled entirely by the account.cache_invalidated event:
// RoomChannel transmits the current cache-key map on every (re)subscribe, and
// the server broadcasts it on every change. dispatchCacheRevalidations diffs
// those keys against IDB and refetches mismatches — the client never pulls
// cache keys itself.
export default async function paintStoresFromCache(store, accountId) {
let dm;
try {
dm = new DataManager(accountId);
await dm.initDb();
} catch {
// IDB unsupported (e.g. Firefox private mode) — silent no-op. Components
// will fetch from the network normally via the cache-enabled API client.
return;
}
// Stale-while-revalidate paint: commit cached data into Vuex immediately.
await Promise.all(
cacheableModels.map(async model => {
const localData = await dm.get({ modelName: model.name });
if (localData.length === 0) return;
store.commit(model.setMutation, localData);
})
);
}
@@ -1,9 +1,3 @@
// Bump DATA_VERSION to (a) add new object stores to the IDB schema or (b)
// flush bad/stale cache globally. The `upgrade()` callback in DataManager runs
// only when the stored DB version is less than the requested version; on any
// such bump it clears every existing store (a full cache reset) and then
// idempotently creates any missing stores. So bump this whenever a cached
// model's serializer shape changes, or to force all clients to refetch.
//
// Thursday, 28 May 2026 — bumped to add canned_response + account_user stores + custom_attribute_definition store
export const DATA_VERSION = '1748390400';
// Monday, 13 March 2023
// Change this version if you want to invalidate old data
export const DATA_VERSION = '1678706392';
@@ -98,6 +98,17 @@ class ReconnectService {
await this.store.dispatch('notifications/index', { ...filter, page: 1 });
};
revalidateCaches = async () => {
const { label, inbox, team } = await this.store.dispatch(
'accounts/getCacheKeys'
);
await Promise.all([
this.store.dispatch('labels/revalidate', { newKey: label }),
this.store.dispatch('inboxes/revalidate', { newKey: inbox }),
this.store.dispatch('teams/revalidate', { newKey: team }),
]);
};
handleRouteSpecificFetch = async () => {
const currentRoute = this.router.currentRoute.value.name;
if (isAConversationRoute(currentRoute, true)) {
@@ -127,11 +138,9 @@ class ReconnectService {
this.setConversationLastMessageId();
};
// Cached workspace config needs no explicit revalidation here: ActionCable
// auto-resubscribes after a drop, and RoomChannel pushes the cache-key map
// on every subscribe via the account.cache_invalidated event.
onReconnect = async () => {
await this.handleRouteSpecificFetch();
await this.revalidateCaches();
emitter.emit(BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED);
};
}
@@ -14,7 +14,6 @@ import {
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { dispatchCacheRevalidations } from './CacheHelper/dispatchCacheRevalidations';
const { isImpersonating } = useImpersonation();
const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
@@ -270,7 +269,10 @@ class ActionCableConnector extends BaseActionCableConnector {
};
onCacheInvalidate = data => {
dispatchCacheRevalidations(this.app.$store, data.cache_keys);
const keys = data.cache_keys;
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
};
onVoiceCallIncoming = data => {
@@ -0,0 +1,22 @@
export const FACEBOOK_PAGE_SCOPES = [
'pages_manage_metadata',
'business_management',
'pages_messaging',
'pages_show_list',
'pages_read_engagement',
];
export const INSTAGRAM_SCOPES = [
'instagram_basic',
'instagram_manage_messages',
];
export const buildFacebookLoginScopes = ({
includeInstagramScopes = false,
} = {}) => {
const scopes = [...FACEBOOK_PAGE_SCOPES];
if (includeInstagramScopes) {
scopes.push(...INSTAGRAM_SCOPES);
}
return scopes.join(',');
};
@@ -1,108 +0,0 @@
import { dispatchCacheRevalidations } from '../../CacheHelper/dispatchCacheRevalidations';
import InboxesAPI from 'dashboard/api/inboxes';
import LabelsAPI from 'dashboard/api/labels';
import CannedResponseAPI from 'dashboard/api/cannedResponse';
import TeamsAPI from 'dashboard/api/teams';
vi.mock('dashboard/api/inboxes', () => ({
default: {
validateCacheKey: vi.fn(),
refetchAndCommit: vi.fn(),
extractDataFromResponse: vi.fn(),
},
}));
vi.mock('dashboard/api/labels', () => ({
default: {
validateCacheKey: vi.fn(),
refetchAndCommit: vi.fn(),
extractDataFromResponse: vi.fn(),
},
}));
vi.mock('dashboard/api/teams', () => ({
default: {
validateCacheKey: vi.fn(),
refetchAndCommit: vi.fn(),
extractDataFromResponse: vi.fn(),
},
}));
vi.mock('dashboard/api/cannedResponse', () => ({
default: {
validateCacheKey: vi.fn(),
refetchAndCommit: vi.fn(),
extractDataFromResponse: vi.fn(),
},
}));
vi.mock('dashboard/api/agents', () => ({
default: {
validateCacheKey: vi.fn(),
refetchAndCommit: vi.fn(),
extractDataFromResponse: vi.fn(),
},
}));
vi.mock('dashboard/api/attributes', () => ({
default: {
validateCacheKey: vi.fn(),
refetchAndCommit: vi.fn(),
extractDataFromResponse: vi.fn(),
},
}));
describe('dispatchCacheRevalidations', () => {
let store;
beforeEach(() => {
vi.clearAllMocks();
store = { commit: vi.fn() };
});
it('refetches stale models and commits via their setMutation', async () => {
InboxesAPI.validateCacheKey.mockResolvedValue(false);
InboxesAPI.refetchAndCommit.mockResolvedValue({ data: { payload: [] } });
InboxesAPI.extractDataFromResponse.mockReturnValue([{ id: 1 }]);
LabelsAPI.validateCacheKey.mockResolvedValue(true);
await dispatchCacheRevalidations(store, {
inbox: 'inbox-key',
label: 'label-key',
});
expect(InboxesAPI.refetchAndCommit).toHaveBeenCalledWith('inbox-key');
expect(store.commit).toHaveBeenCalledWith('inboxes/SET_INBOXES', [
{ id: 1 },
]);
expect(LabelsAPI.refetchAndCommit).not.toHaveBeenCalled();
expect(store.commit).toHaveBeenCalledTimes(1);
});
it('skips models absent from the key payload', async () => {
InboxesAPI.validateCacheKey.mockResolvedValue(true);
await dispatchCacheRevalidations(store, { inbox: 'inbox-key' });
expect(TeamsAPI.validateCacheKey).not.toHaveBeenCalled();
expect(store.commit).not.toHaveBeenCalled();
});
it('treats missing keys as an empty payload', async () => {
await dispatchCacheRevalidations(store);
expect(InboxesAPI.validateCacheKey).not.toHaveBeenCalled();
expect(store.commit).not.toHaveBeenCalled();
});
it('swallows per-model errors so one failure does not block the rest', async () => {
InboxesAPI.validateCacheKey.mockResolvedValue(false);
InboxesAPI.refetchAndCommit.mockRejectedValue(new Error('network down'));
CannedResponseAPI.validateCacheKey.mockResolvedValue(false);
CannedResponseAPI.refetchAndCommit.mockResolvedValue({ data: [] });
CannedResponseAPI.extractDataFromResponse.mockReturnValue([{ id: 7 }]);
await dispatchCacheRevalidations(store, {
inbox: 'inbox-key',
canned_response: 'canned-key',
});
expect(store.commit).toHaveBeenCalledWith('SET_CANNED', [{ id: 7 }]);
expect(store.commit).toHaveBeenCalledTimes(1);
});
});
@@ -1,79 +0,0 @@
import paintStoresFromCache from '../../CacheHelper/paintStoresFromCache';
import { DataManager } from '../../CacheHelper/DataManager';
describe('paintStoresFromCache', () => {
const accountId = 'paint-test-account';
const originalAxios = window.axios;
let axiosMock;
let dm;
let storeMock;
beforeEach(async () => {
axiosMock = {
get: vi.fn(),
};
window.axios = axiosMock;
storeMock = {
commit: vi.fn(),
dispatch: vi.fn(),
};
dm = new DataManager(accountId);
await dm.initDb();
});
afterEach(async () => {
const tx = dm.db.transaction(
[...dm.modelsToSync, 'cache-keys'],
'readwrite'
);
[...dm.modelsToSync, 'cache-keys'].forEach(name => {
tx.objectStore(name).clear();
});
await tx.done;
window.axios = originalAxios;
});
it('does nothing when IDB is empty (first ever load)', async () => {
await paintStoresFromCache(storeMock, accountId);
expect(storeMock.commit).not.toHaveBeenCalled();
expect(storeMock.dispatch).not.toHaveBeenCalled();
});
it('seeds Vuex from IDB without any network interaction', async () => {
await dm.push({
modelName: 'inbox',
data: [{ id: 1, name: 'Support' }],
});
await dm.push({
modelName: 'label',
data: [{ id: 9, title: 'Bug' }],
});
await paintStoresFromCache(storeMock, accountId);
expect(storeMock.commit).toHaveBeenCalledWith('inboxes/SET_INBOXES', [
{ id: 1, name: 'Support' },
]);
expect(storeMock.commit).toHaveBeenCalledWith('labels/SET_LABELS', [
{ id: 9, title: 'Bug' },
]);
expect(axiosMock.get).not.toHaveBeenCalled();
expect(storeMock.dispatch).not.toHaveBeenCalled();
});
it('seeds teams via SET_TEAMS', async () => {
await dm.push({
modelName: 'team',
data: [{ id: 1, name: 'Sales' }],
});
await paintStoresFromCache(storeMock, accountId);
expect(storeMock.commit).toHaveBeenCalledWith('teams/SET_TEAMS', [
{ id: 1, name: 'Sales' },
]);
});
});
@@ -253,6 +253,27 @@ describe('ReconnectService', () => {
});
});
describe('revalidateCaches', () => {
it('should dispatch revalidate actions for labels, inboxes, and teams', async () => {
storeMock.dispatch.mockResolvedValueOnce({
label: 'labelKey',
inbox: 'inboxKey',
team: 'teamKey',
});
await reconnectService.revalidateCaches();
expect(storeMock.dispatch).toHaveBeenCalledWith('accounts/getCacheKeys');
expect(storeMock.dispatch).toHaveBeenCalledWith('labels/revalidate', {
newKey: 'labelKey',
});
expect(storeMock.dispatch).toHaveBeenCalledWith('inboxes/revalidate', {
newKey: 'inboxKey',
});
expect(storeMock.dispatch).toHaveBeenCalledWith('teams/revalidate', {
newKey: 'teamKey',
});
});
});
describe('handleRouteSpecificFetch', () => {
it('should fetch conversations and messages if current route is a conversation route', async () => {
isAConversationRoute.mockReturnValue(true);
@@ -314,10 +335,12 @@ describe('ReconnectService', () => {
});
describe('onReconnect', () => {
it('should handle route-specific fetch and emit WEBSOCKET_RECONNECT_COMPLETED event', async () => {
it('should handle route-specific fetch, revalidate caches, and emit WEBSOCKET_RECONNECT_COMPLETED event', async () => {
reconnectService.handleRouteSpecificFetch = vi.fn();
reconnectService.revalidateCaches = vi.fn();
await reconnectService.onReconnect();
expect(reconnectService.handleRouteSpecificFetch).toHaveBeenCalled();
expect(reconnectService.revalidateCaches).toHaveBeenCalled();
expect(emitter.emit).toHaveBeenCalledWith(
BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED
);
@@ -653,6 +653,10 @@
},
"CREDENTIALS": {
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
},
"INBOUND": {
"LABEL": "Allow incoming calls",
"DESCRIPTION": "Let customers call this number. When turned off, incoming calls are declined automatically — agents aren't notified and no conversation is created. Agents can still place outgoing calls."
}
},
"WHATSAPP_CALLING": {
@@ -86,6 +86,17 @@
"NOTE": "Manage additional security features for your account.",
"MFA_BUTTON": "Manage Two-Factor Authentication"
},
"SESSIONS_SECTION": {
"TITLE": "Active Sessions",
"NOTE": "These are the devices currently logged in to your account.",
"CURRENT": "Current session",
"REVOKE": "Revoke",
"REVOKE_SUCCESS": "Session revoked successfully",
"REVOKE_ERROR": "Unable to revoke session. Please try again.",
"FETCH_ERROR": "Unable to fetch sessions. Please try again.",
"LAST_ACTIVE": "Last active",
"UNKNOWN_DEVICE": "Unknown device"
},
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration",
@@ -94,8 +94,18 @@ export default {
return this.getAccount(this.accountId) || {};
},
},
watch: {
'currentAccount.id'(id) {
if (id) {
this.initializeAccount();
}
},
},
mounted() {
this.initializeAccount();
// Account already in the store (navigated in): seed immediately.
if (this.currentAccount.id) {
this.initializeAccount();
}
},
methods: {
async initializeAccount() {
@@ -8,7 +8,7 @@ import SectionLayout from './SectionLayout.vue';
const { t } = useI18n();
const { currentAccount } = useAccount();
const getAccountId = computed(() => currentAccount.value.id.toString());
const getAccountId = computed(() => currentAccount.value?.id?.toString());
</script>
<template>
@@ -4,6 +4,7 @@ import InboxReconnectionRequired from '../components/InboxReconnectionRequired.v
import { useAlert } from 'dashboard/composables';
import { loadScript } from 'dashboard/helper/DOMHelpers';
import { buildFacebookLoginScopes } from 'dashboard/helper/facebookScopes';
import * as Sentry from '@sentry/vue';
export default {
@@ -20,6 +21,11 @@ export default {
inboxId() {
return this.inbox.id;
},
facebookLoginScopes() {
return buildFacebookLoginScopes({
includeInstagramScopes: !!this.inbox.instagram_id,
});
},
},
mounted() {
window.fbAsyncInit = this.runFBInit;
@@ -77,8 +83,7 @@ export default {
}
},
{
scope:
'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages',
scope: this.facebookLoginScopes,
auth_type: 'reauthorize',
}
);
@@ -1,9 +1,11 @@
<script>
import { useAlert } from 'dashboard/composables';
import InboxesAPI from 'dashboard/api/inboxes';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import NextInput from 'dashboard/components-next/input/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
export default {
components: {
@@ -11,6 +13,7 @@ export default {
SettingsToggleSection,
NextInput,
NextButton,
Spinner,
},
props: {
inbox: {
@@ -21,9 +24,11 @@ export default {
data() {
return {
voiceEnabled: this.inbox.voice_enabled || false,
inboundCallsEnabled: this.inbox.inbound_calls_enabled !== false,
apiKeySid: this.inbox.api_key_sid || '',
apiKeySecret: '',
isUpdating: false,
isTogglingInbound: false,
};
},
computed: {
@@ -62,8 +67,27 @@ export default {
'inbox.api_key_sid'(val) {
this.apiKeySid = val || '';
},
'inbox.inbound_calls_enabled'(val) {
this.inboundCallsEnabled = val !== false;
},
},
methods: {
async handleInboundToggle(newValue) {
if (this.isTogglingInbound) return;
const previousValue = this.inboundCallsEnabled;
this.inboundCallsEnabled = newValue;
this.isTogglingInbound = true;
try {
await InboxesAPI.setInboundCalls(this.inbox.id, newValue);
await this.$store.dispatch('inboxes/get', this.inbox.id);
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (_) {
this.inboundCallsEnabled = previousValue;
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
} finally {
this.isTogglingInbound = false;
}
},
async updateVoiceSettings() {
this.isUpdating = true;
try {
@@ -123,6 +147,24 @@ export default {
/>
</div>
<div
v-if="inbox.voice_enabled"
class="relative"
:class="{ 'pointer-events-none opacity-60': isTogglingInbound }"
>
<SettingsToggleSection
:model-value="inboundCallsEnabled"
:header="$t('INBOX_MGMT.VOICE_CONFIGURATION.INBOUND.LABEL')"
:description="$t('INBOX_MGMT.VOICE_CONFIGURATION.INBOUND.DESCRIPTION')"
:hide-toggle="isTogglingInbound"
@update:model-value="handleInboundToggle"
>
<template v-if="isTogglingInbound" #hiddenToggle>
<Spinner class="size-4 text-n-slate-11" />
</template>
</SettingsToggleSection>
</div>
<div v-if="inbox.voice_enabled && inbox.voice_call_webhook_url">
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
@@ -24,10 +24,13 @@ export default {
data() {
return {
callingEnabled: this.inbox.provider_config?.calling_enabled || false,
inboundCallsEnabled:
this.inbox.provider_config?.inbound_calls_enabled !== false,
permissionRequestBody:
this.inbox.provider_config?.call_permission_request_body || '',
isUpdating: false,
isTogglingCalling: false,
isTogglingInbound: false,
};
},
computed: {
@@ -44,8 +47,27 @@ export default {
'inbox.provider_config.call_permission_request_body'(val) {
this.permissionRequestBody = val || '';
},
'inbox.provider_config.inbound_calls_enabled'(val) {
this.inboundCallsEnabled = val !== false;
},
},
methods: {
async handleInboundToggle(newValue) {
if (this.isTogglingInbound) return;
const previousValue = this.inboundCallsEnabled;
this.inboundCallsEnabled = newValue;
this.isTogglingInbound = true;
try {
await InboxesAPI.setInboundCalls(this.inbox.id, newValue);
await this.$store.dispatch('inboxes/get', this.inbox.id);
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (_) {
this.inboundCallsEnabled = previousValue;
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
} finally {
this.isTogglingInbound = false;
}
},
async handleCallingToggle(newValue) {
if (this.isTogglingCalling) return;
const previousValue = this.callingEnabled;
@@ -117,6 +139,25 @@ export default {
</div>
<template v-if="callingEnabled">
<div
class="relative"
:class="{ 'pointer-events-none opacity-60': isTogglingInbound }"
>
<SettingsToggleSection
:model-value="inboundCallsEnabled"
:header="$t('INBOX_MGMT.VOICE_CONFIGURATION.INBOUND.LABEL')"
:description="
$t('INBOX_MGMT.VOICE_CONFIGURATION.INBOUND.DESCRIPTION')
"
:hide-toggle="isTogglingInbound"
@update:model-value="handleInboundToggle"
>
<template v-if="isTogglingInbound" #hiddenToggle>
<Spinner class="size-4 text-n-slate-11" />
</template>
</SettingsToggleSection>
</div>
<SettingsFieldSection
v-if="phoneNumber"
:label="$t('INBOX_MGMT.WHATSAPP_CALLING.PHONE_NUMBER.LABEL')"
@@ -0,0 +1,137 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { formatDistanceToNow, parseISO } from 'date-fns';
import { useAlert } from 'dashboard/composables';
import authAPI from 'dashboard/api/auth';
import AnalyticsHelper from 'dashboard/helper/AnalyticsHelper';
import { SESSION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const { t } = useI18n();
const sessions = ref([]);
const loading = ref(false);
const relativeTime = dateStr => {
if (!dateStr) return '';
return formatDistanceToNow(parseISO(dateStr), { addSuffix: true });
};
const isUnknown = val => !val || val === 'Unknown' || val === 'Unknown Browser';
const deviceIcon = session => {
const name = (session.device_name || '').toLowerCase();
if (
name.includes('iphone') ||
name.includes('android') ||
name.includes('mobile')
) {
return 'i-lucide-smartphone';
}
if (name.includes('ipad') || name.includes('tablet')) {
return 'i-lucide-tablet';
}
return 'i-lucide-monitor';
};
const sessionLabel = session => {
const parts = [];
if (!isUnknown(session.browser_name)) {
parts.push(
session.browser_version
? `${session.browser_name} ${session.browser_version}`
: session.browser_name
);
}
if (!isUnknown(session.platform_name)) parts.push(session.platform_name);
return (
parts.join(' on ') ||
t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.UNKNOWN_DEVICE')
);
};
const locationLabel = session => {
const parts = [];
if (session.city) parts.push(session.city);
if (session.country) parts.push(session.country);
return parts.join(', ');
};
const fetchSessions = async () => {
loading.value = true;
try {
const { data } = await authAPI.getSessions();
sessions.value = data;
} catch {
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.FETCH_ERROR'));
} finally {
loading.value = false;
}
};
const revokeSession = async session => {
try {
await authAPI.revokeSession(session.id);
sessions.value = sessions.value.filter(s => s.id !== session.id);
AnalyticsHelper.track(SESSION_EVENTS.REVOKED_FROM_PROFILE);
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE_SUCCESS'));
} catch {
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE_ERROR'));
}
};
onMounted(fetchSessions);
</script>
<template>
<div class="flex flex-col gap-3">
<div
v-for="session in sessions"
:key="session.id"
class="flex items-center justify-between gap-4 rounded-xl border border-n-slate-4 bg-n-background p-4"
>
<div class="flex items-start gap-3">
<Icon
:icon="deviceIcon(session)"
class="size-5 mt-0.5 text-n-slate-10 flex-shrink-0"
/>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<span class="text-heading-3 text-n-slate-12">
{{ sessionLabel(session) }}
</span>
<span
v-if="session.current"
class="rounded-full bg-n-teal-3 px-2 py-0.5 text-caption text-n-teal-11"
>
{{ $t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.CURRENT') }}
</span>
</div>
<span
v-if="locationLabel(session)"
class="text-body-b3 text-n-slate-11"
>
{{ locationLabel(session) }}
</span>
<span
v-if="session.last_activity_at"
class="text-body-b3 text-n-slate-10"
>
{{ $t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.LAST_ACTIVE') }}
{{ relativeTime(session.last_activity_at) }}
</span>
</div>
</div>
<Button
v-if="!session.current"
type="button"
faded
xs
:label="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE')"
color="ruby"
@click="revokeSession(session)"
/>
</div>
</div>
</template>
@@ -20,6 +20,7 @@ import SectionLayout from '../account/components/SectionLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import AccessToken from './AccessToken.vue';
import MfaSettingsCard from './MfaSettingsCard.vue';
import ActiveSessions from './ActiveSessions.vue';
import Policy from 'dashboard/components/policy.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import {
@@ -42,6 +43,7 @@ export default {
AudioNotifications,
AccessToken,
MfaSettingsCard,
ActiveSessions,
BaseSettingsHeader,
},
setup() {
@@ -307,6 +309,13 @@ export default {
>
<MfaSettingsCard />
</SectionLayout>
<SectionLayout
with-border
:title="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.TITLE')"
:description="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.NOTE')"
>
<ActiveSessions />
</SectionLayout>
<Policy :permissions="audioNotificationPermissions">
<SectionLayout
with-border
@@ -163,6 +163,10 @@ export const actions = {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: false });
}
},
getCacheKeys: async () => {
return AccountAPI.getCacheKeys();
},
};
export const mutations = {
@@ -44,7 +44,7 @@ export const actions = {
get: async ({ commit }) => {
commit(types.default.SET_AGENT_FETCHING_STATUS, true);
try {
const response = await AgentAPI.get(true);
const response = await AgentAPI.get();
commit(types.default.SET_AGENT_FETCHING_STATUS, false);
commit(types.default.SET_AGENTS, response.data);
} catch (error) {
@@ -2,15 +2,21 @@ import ConversationAPI from '../../api/conversations';
import types from '../mutation-types';
export const state = {
allCount: 0,
inboxes: {},
labels: {},
teams: {},
};
const normalizeCount = count => {
const parsedCount = Number(count);
return Number.isFinite(parsedCount) && parsedCount > 0 ? parsedCount : 0;
};
const normalizeCounts = counts => {
return Object.entries(counts || {}).reduce((result, [id, count]) => {
const parsedCount = Number(count);
if (Number.isFinite(parsedCount) && parsedCount > 0) {
const parsedCount = normalizeCount(count);
if (parsedCount > 0) {
result[String(id)] = parsedCount;
}
@@ -19,6 +25,9 @@ const normalizeCounts = counts => {
};
export const getters = {
getAllUnreadCount($state) {
return $state.allCount;
},
getInboxUnreadCount: $state => inboxId => {
return $state.inboxes[String(inboxId)] || 0;
},
@@ -55,6 +64,7 @@ export const actions = {
export const mutations = {
[types.SET_CONVERSATION_UNREAD_COUNTS]($state, payload = {}) {
$state.allCount = normalizeCount(payload.all_count);
$state.inboxes = normalizeCounts(payload.inboxes);
$state.labels = normalizeCounts(payload.labels);
$state.teams = normalizeCounts(payload.teams);
@@ -189,6 +189,17 @@ const sendAnalyticsEvent = channelType => {
};
export const actions = {
revalidate: async ({ commit }, { newKey }) => {
try {
const isExistingKeyValid = await InboxesAPI.validateCacheKey(newKey);
if (!isExistingKeyValid) {
const response = await InboxesAPI.refetchAndCommit(newKey);
commit(types.default.SET_INBOXES, response.data.payload);
}
} catch (error) {
// Ignore error
}
},
get: async ({ commit }) => {
commit(types.default.SET_INBOXES_UI_FLAG, { isFetching: true });
try {
@@ -32,6 +32,18 @@ export const getters = {
};
export const actions = {
revalidate: async function revalidate({ commit }, { newKey }) {
try {
const isExistingKeyValid = await LabelsAPI.validateCacheKey(newKey);
if (!isExistingKeyValid) {
const response = await LabelsAPI.refetchAndCommit(newKey);
commit(types.SET_LABELS, response.data.payload);
}
} catch (error) {
// Ignore error
}
},
get: async function getLabels({ commit }) {
commit(types.SET_LABEL_UI_FLAG, { isFetching: true });
try {
@@ -1,7 +1,6 @@
import axios from 'axios';
import { actions } from '../../agents';
import * as types from '../../../mutation-types';
import AgentAPI from '../../../../api/agents';
import agentList from './fixtures';
const commit = vi.fn();
@@ -9,13 +8,6 @@ const dispatch = vi.fn();
global.axios = axios;
vi.mock('axios');
// Clear the IDB-backed cache between tests so each case starts from a known
// empty state and isn't affected by data persisted by a previous test.
beforeEach(async () => {
await AgentAPI.dataManager.initDb();
await AgentAPI.dataManager.db.clear(AgentAPI.cacheModelName);
});
describe('#actions', () => {
describe('#get', () => {
it('sends correct actions if API is success', async () => {
@@ -1,20 +1,12 @@
import axios from 'axios';
import { actions } from '../../attributes';
import * as types from '../../../mutation-types';
import AttributeAPI from '../../../../api/attributes';
import attributesList from './fixtures';
const commit = vi.fn();
global.axios = axios;
vi.mock('axios');
// Clear the IDB-backed cache between tests so each case starts from a known
// empty state and isn't affected by data persisted by a previous test.
beforeEach(async () => {
await AttributeAPI.dataManager.initDb();
await AttributeAPI.dataManager.db.clear(AttributeAPI.cacheModelName);
});
describe('#actions', () => {
describe('#get', () => {
it('sends correct actions if API is success', async () => {
@@ -15,6 +15,7 @@ describe('#actions', () => {
describe('#get', () => {
it('commits unread counts when API is successful', async () => {
const payload = {
all_count: 2,
inboxes: { 1: '2' },
labels: { 3: 4 },
teams: { 5: 6 },
@@ -3,6 +3,7 @@ import { getters } from '../../conversationUnreadCounts';
describe('#getters', () => {
it('returns inbox unread count by id', () => {
const state = {
allCount: 0,
inboxes: { 1: 2 },
labels: {},
teams: {},
@@ -15,6 +16,7 @@ describe('#getters', () => {
it('returns label unread count by id', () => {
const state = {
allCount: 0,
inboxes: {},
labels: { 3: 4 },
teams: {},
@@ -27,6 +29,7 @@ describe('#getters', () => {
it('returns team unread count by id', () => {
const state = {
allCount: 0,
inboxes: {},
labels: {},
teams: { 5: 6 },
@@ -37,8 +40,20 @@ describe('#getters', () => {
expect(getters.getTeamUnreadCount(state)(6)).toBe(0);
});
it('returns all unread count', () => {
const state = {
allCount: 7,
inboxes: {},
labels: {},
teams: {},
};
expect(getters.getAllUnreadCount(state)).toBe(7);
});
it('returns unread count maps', () => {
const state = {
allCount: 0,
inboxes: { 1: 2 },
labels: { 3: 4 },
teams: { 5: 6 },
@@ -4,9 +4,10 @@ import { mutations } from '../../conversationUnreadCounts';
describe('#mutations', () => {
describe('#SET_CONVERSATION_UNREAD_COUNTS', () => {
it('normalizes unread count payload', () => {
const state = { inboxes: {}, labels: {}, teams: {} };
const state = { allCount: 0, inboxes: {}, labels: {}, teams: {} };
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {
all_count: '3',
inboxes: {
1: '2',
2: 0,
@@ -23,6 +24,7 @@ describe('#mutations', () => {
});
expect(state).toEqual({
allCount: 3,
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
@@ -31,6 +33,7 @@ describe('#mutations', () => {
it('clears counts when payload is empty', () => {
const state = {
allCount: 2,
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
@@ -39,10 +42,21 @@ describe('#mutations', () => {
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {});
expect(state).toEqual({
allCount: 0,
inboxes: {},
labels: {},
teams: {},
});
});
it('normalizes invalid aggregate counts to zero', () => {
const state = { allCount: 2, inboxes: {}, labels: {}, teams: {} };
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {
all_count: 'invalid',
});
expect(state.allCount).toBe(0);
});
});
});
@@ -1,20 +1,12 @@
import axios from 'axios';
import { actions } from '../../inboxes';
import * as types from '../../../mutation-types';
import InboxesAPI from '../../../../api/inboxes';
import inboxList from './fixtures';
const commit = vi.fn();
global.axios = axios;
vi.mock('axios');
// Clear the IDB-backed cache between tests so each case starts from a known
// empty state and isn't affected by data persisted by a previous test.
beforeEach(async () => {
await InboxesAPI.dataManager.initDb();
await InboxesAPI.dataManager.db.clear(InboxesAPI.cacheModelName);
});
describe('#actions', () => {
describe('#get', () => {
it('sends correct actions if API is success', async () => {
@@ -1,20 +1,12 @@
import axios from 'axios';
import { actions } from '../../labels';
import * as types from '../../../mutation-types';
import LabelsAPI from '../../../../api/labels';
import labelsList from './fixtures';
const commit = vi.fn();
global.axios = axios;
vi.mock('axios');
// Clear the IDB-backed cache between tests so each case starts from a known
// empty state and isn't affected by data persisted by a previous test.
beforeEach(async () => {
await LabelsAPI.dataManager.initDb();
await LabelsAPI.dataManager.db.clear(LabelsAPI.cacheModelName);
});
describe('#actions', () => {
describe('#get', () => {
it('sends correct actions if API is success', async () => {
@@ -2,25 +2,18 @@ import axios from 'axios';
import { actions } from '../../teams/actions';
import {
SET_TEAM_UI_FLAG,
CLEAR_TEAMS,
SET_TEAMS,
SET_TEAM_ITEM,
EDIT_TEAM,
DELETE_TEAM,
} from '../../teams/types';
import TeamsAPI from '../../../../api/teams';
import teamsList from './fixtures';
const commit = vi.fn();
global.axios = axios;
vi.mock('axios');
// Clear the IDB-backed cache between tests so each case starts from a known
// empty state and isn't affected by data persisted by a previous test.
beforeEach(async () => {
await TeamsAPI.dataManager.initDb();
await TeamsAPI.dataManager.db.clear(TeamsAPI.cacheModelName);
});
describe('#actions', () => {
describe('#get', () => {
it('sends correct actions if API is success', async () => {
@@ -40,6 +33,7 @@ describe('#actions', () => {
await actions.get({ commit });
expect(commit.mock.calls).toEqual([
[SET_TEAM_UI_FLAG, { isFetching: true }],
[CLEAR_TEAMS],
[SET_TEAMS, teamsList[1]],
[SET_TEAM_UI_FLAG, { isFetching: false }],
]);
@@ -1,4 +1,5 @@
import {
CLEAR_TEAMS,
SET_TEAMS,
SET_TEAM_ITEM,
EDIT_TEAM,
@@ -10,14 +11,9 @@ describe('#mutations', () => {
describe('#SET_teams', () => {
it('set teams records', () => {
const state = { records: {} };
mutations[SET_TEAMS](state, [teams[1], teams[2]]);
expect(state.records).toEqual(teams);
});
it('drops records absent from the new list', () => {
const state = { records: { ...teams } };
mutations[SET_TEAMS](state, [teams[1]]);
expect(state.records).toEqual({ 1: teams[1] });
mutations[SET_TEAMS](state, [teams[2]]);
expect(state.records).toEqual(teams);
});
});
@@ -47,4 +43,12 @@ describe('#mutations', () => {
expect(state.records).toEqual({});
});
});
describe('#CLEAR_TEAMS', () => {
it('delete teams record', () => {
const state = { records: { 1: teams[1] } };
mutations[CLEAR_TEAMS](state);
expect(state.records).toEqual({});
});
});
});
@@ -1,5 +1,6 @@
import {
SET_TEAM_UI_FLAG,
CLEAR_TEAMS,
SET_TEAMS,
SET_TEAM_ITEM,
EDIT_TEAM,
@@ -21,10 +22,22 @@ export const actions = {
commit(SET_TEAM_UI_FLAG, { isCreating: false });
}
},
revalidate: async ({ commit }, { newKey }) => {
try {
const isExistingKeyValid = await TeamsAPI.validateCacheKey(newKey);
if (!isExistingKeyValid) {
const response = await TeamsAPI.refetchAndCommit(newKey);
commit(SET_TEAMS, response.data);
}
} catch (error) {
// Ignore error
}
},
get: async ({ commit }) => {
commit(SET_TEAM_UI_FLAG, { isFetching: true });
try {
const { data } = await TeamsAPI.get(true);
commit(CLEAR_TEAMS);
commit(SET_TEAMS, data);
} catch (error) {
throw new Error(error);
@@ -1,5 +1,6 @@
import {
SET_TEAM_UI_FLAG,
CLEAR_TEAMS,
SET_TEAMS,
SET_TEAM_ITEM,
EDIT_TEAM,
@@ -14,14 +15,19 @@ export const mutations = {
};
},
// Replaces (not merges) so rows deleted server-side never survive as
// phantoms — SET_TEAMS only ever receives the full list.
[CLEAR_TEAMS]: $state => {
$state.records = {};
},
[SET_TEAMS]: ($state, data) => {
const records = {};
const updatedRecords = { ...$state.records };
data.forEach(team => {
records[team.id] = team;
updatedRecords[team.id] = {
...(updatedRecords[team.id] || {}),
...team,
};
});
$state.records = records;
$state.records = updatedRecords;
},
[SET_TEAM_ITEM]: ($state, data) => {
@@ -1,4 +1,5 @@
export const SET_TEAM_UI_FLAG = 'SET_TEAM_UI_FLAG';
export const CLEAR_TEAMS = 'CLEAR_TEAMS';
export const SET_TEAMS = 'SET_TEAMS';
export const SET_TEAM_ITEM = 'SET_TEAM_ITEM';
export const EDIT_TEAM = 'EDIT_TEAM';
@@ -36,6 +36,9 @@ class ActionCableConnector extends BaseActionCableConnector {
onReconnect = () => {
this.syncLatestMessages();
// Re-fetch conversation attributes so a status change (e.g. auto-resolve)
// that happened while disconnected is reflected, keeping the reply box state correct.
this.app.$store.dispatch('conversationAttributes/getAttributes');
};
setLastMessageId = () => {
@@ -0,0 +1,53 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import ActionCableConnector from '../actionCable';
vi.mock('@rails/actioncable', () => ({
createConsumer: () => ({
subscriptions: { create: () => ({}) },
disconnect: vi.fn(),
}),
}));
describe('Widget ActionCableConnector', () => {
let app;
let mockDispatch;
let connector;
beforeEach(() => {
vi.useFakeTimers();
mockDispatch = vi.fn();
app = {
$store: {
dispatch: mockDispatch,
getters: {
getCurrentAccountId: 1,
getCurrentUserID: 1,
},
},
};
connector = new ActionCableConnector(app, 'test-token');
mockDispatch.mockClear();
});
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});
it('registers the conversation.status_changed event handler', () => {
expect(connector.events['conversation.status_changed']).toBe(
connector.onStatusChange
);
});
it('re-fetches conversation attributes on reconnect so a status change missed while disconnected is reflected', () => {
connector.onReconnect();
expect(mockDispatch).toHaveBeenCalledWith(
'conversation/syncLatestMessages'
);
expect(mockDispatch).toHaveBeenCalledWith(
'conversationAttributes/getAttributes'
);
});
});
-3
View File
@@ -52,9 +52,6 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
filename: avatar_file.original_filename,
content_type: avatar_file.content_type
)
# Agent thumbnails are cached separately, and avatar attachments do not dirty user columns.
avatarable.invalidate_avatar_cache if avatarable.respond_to?(:invalidate_avatar_cache)
end
def log_http_error(avatar_url, error)
+18
View File
@@ -0,0 +1,18 @@
class UserSessionIpLookupJob < ApplicationJob
queue_as :low
def perform(session)
return if session.ip_address.blank?
result = IpLookupService.new.perform(session.ip_address)
return unless result
session.update_columns( # rubocop:disable Rails/SkipsModelValidations
city: result.city,
country: result.country,
country_code: result.country_code
)
rescue StandardError => e
Rails.logger.warn "UserSessionIpLookupJob failed: #{e.message}"
end
end
-6
View File
@@ -36,16 +36,10 @@ class AccountUser < ApplicationRecord
accepts_nested_attributes_for :account
AGENT_CACHE_RELEVANT_COLUMNS = %w[role availability auto_offline custom_role_id].freeze
after_create_commit :notify_creation, :create_notification_setting
after_destroy :notify_deletion, :remove_user_from_account
after_save :update_presence_in_redis, if: :saved_change_to_availability?
after_commit -> { account.update_cache_key('account_user') }, on: [:create, :destroy]
after_update_commit -> { account.update_cache_key('account_user') },
if: -> { saved_changes.keys.intersect?(AGENT_CACHE_RELEVANT_COLUMNS) }
validates :user_id, uniqueness: { scope: :account_id }
def create_notification_setting
+4 -1
View File
@@ -1,4 +1,7 @@
class ApplicationRecord < ActiveRecord::Base
MAX_STRING_COLUMN_LENGTH = 255
MAX_TEXT_COLUMN_LENGTH = 20_000
include Events::Types
self.abstract_class = true
@@ -37,7 +40,7 @@ class ApplicationRecord < ActiveRecord::Base
end
def validate_content_length(column)
max_length = column.type == :text ? 20_000 : 255
max_length = column.type == :text ? MAX_TEXT_COLUMN_LENGTH : MAX_STRING_COLUMN_LENGTH
return if self[column.name].nil? || self[column.name].length <= max_length
errors.add(column.name.to_sym, "is too long (maximum is #{max_length} characters)")
-2
View File
@@ -11,8 +11,6 @@
#
class CannedResponse < ApplicationRecord
include AccountCacheRevalidator
validates :content, presence: true
validates :short_code, presence: true
validates :account, presence: true
+6
View File
@@ -12,6 +12,7 @@
# medium :integer default("sms")
# messaging_service_sid :string
# phone_number :string
# provider_config :jsonb
# twiml_app_sid :string
# voice_enabled :boolean default(FALSE), not null
# created_at :datetime not null
@@ -54,6 +55,11 @@ class Channel::TwilioSms < ApplicationRecord
medium == 'sms' ? 'Twilio SMS' : 'Whatsapp'
end
# Mutes only the incoming side of calling; default on, so only an explicit false disables inbound.
def inbound_calls_enabled?
provider_config['inbound_calls_enabled'] != false
end
def send_message(to:, body:, media_url: nil)
params = send_message_from.merge(to: to, body: body)
params[:media_url] = media_url if media_url.present?
+14 -7
View File
@@ -49,6 +49,11 @@ class Channel::Whatsapp < ApplicationRecord
account.feature_enabled?('channel_voice')
end
# Mutes only the incoming side of calling; default on, so only an explicit false disables inbound.
def inbound_calls_enabled?
provider_config['inbound_calls_enabled'] != false
end
# Whether this inbox can do WhatsApp calling at all. Meta's Calling API is
# reachable by any whatsapp_cloud inbox, so 360dialog inboxes can't be toggled
# on even though calling_enabled would persist.
@@ -64,8 +69,10 @@ class Channel::Whatsapp < ApplicationRecord
end
end
# Enables voice: turns calling on at Meta (idempotent), subscribes the `calls`
# webhook field, and sets calling_enabled. Raises on Meta failure.
# Enables voice: turns calling on at Meta (idempotent), then re-registers webhooks
# with the in-memory calling_enabled flag so the `calls` field is subscribed. The
# flag is persisted only after registration succeeds, so a webhook failure can't
# leave the inbox reporting voice_enabled? while the WABA isn't subscribed to calls.
# Saved with validate: false to skip validate_provider_config's remote credential
# re-check, which could spuriously fail and desync the flag from Meta.
def enable_voice_calling!
@@ -73,21 +80,21 @@ class Channel::Whatsapp < ApplicationRecord
raise 'WhatsApp calling requires the channel_voice feature' unless account.feature_enabled?('channel_voice')
provider_service.update_calling_status('ENABLED')
webhook_setup_service.register_callback
self.provider_config = provider_config.merge('calling_enabled' => true)
webhook_setup_service.register_callback
save!(validate: false)
end
# Disables voice: unsets calling_enabled (gates the call subsystem) and drops
# `calls` from the webhook subscription (best-effort, so a Meta outage can't
# trap admins). Leaves Meta's WABA calling.status untouched.
# Disables voice: unsets calling_enabled (gates the call subsystem) and re-registers
# webhooks, which drops `calls` from the subscription (best-effort, so a Meta outage
# can't trap admins). Leaves Meta's WABA calling.status untouched.
def disable_voice_calling!
raise 'WhatsApp calling requires a whatsapp_cloud inbox' unless voice_calling_supported?
self.provider_config = provider_config.merge('calling_enabled' => false)
save!(validate: false)
begin
webhook_setup_service.register_callback(subscribed_fields: %w[messages smb_message_echoes])
webhook_setup_service.register_callback
rescue StandardError => e
Rails.logger.warn "[WHATSAPP CALL] disable webhook re-subscribe failed: #{e.message}"
end
+2 -6
View File
@@ -4,15 +4,11 @@ module CacheKeys
include CacheKeysHelper
include Events::Types
# Self-healing bound: if a write path ever changes cached data without
# bumping its key, expiry forces the sentinel and every client refetches.
# 7 days caps that staleness while sparing quiet models (labels, teams)
# from a spurious full refetch after every idle weekend.
CACHE_KEYS_EXPIRY = 7.days
CACHE_KEYS_EXPIRY = 72.hours
included do
class_attribute :cacheable_models
self.cacheable_models = [Label, Inbox, Team, CannedResponse, AccountUser, CustomAttributeDefinition]
self.cacheable_models = [Label, Inbox, Team]
end
def cache_keys
@@ -22,8 +22,6 @@
# index_custom_attribute_definitions_on_account_id (account_id)
#
class CustomAttributeDefinition < ApplicationRecord
include AccountCacheRevalidator
STANDARD_ATTRIBUTES = {
:conversation => %w[status priority assignee_id inbox_id team_id display_id campaign_id labels browser_language country_code referer created_at
last_activity_at],
-7
View File
@@ -50,13 +50,6 @@ class Portal < ApplicationRecord
schema: PortalConfigSchema::CONFIG_PARAMS_SCHEMA,
attribute_resolver: ->(record) { record.config }
# Portal name/slug are embedded as help_center into the cached inbox payload
# (api/v1/models/_inbox.json.jbuilder), so portal changes must bump the
# account's inbox cache key. Destroy is covered too: dependent: :nullify
# detaches inboxes via update_all and skips their callbacks.
after_update_commit -> { account.update_cache_key('inbox') }
after_destroy_commit -> { account.update_cache_key('inbox') }
scope :active, -> { where(archived: false) }
# TODO: 'website_token' is an unused reserved key; remove with a migration that scrubs it from existing portals' config
-6
View File
@@ -18,12 +18,6 @@ class TeamMember < ApplicationRecord
belongs_to :user
belongs_to :team
validates :user_id, uniqueness: { scope: :team_id }
# is_member is embedded into the cached team payload (per current user) via
# api/v1/models/_team.json.jbuilder, so membership changes must bump the team
# cache key. team is safe-navigated because destroying a team cascades here
# via destroy_async, by which point the team row is already gone.
after_commit -> { team&.account&.update_cache_key('team') }, on: [:create, :destroy]
end
TeamMember.include_mod_with('Audit::TeamMember')
+7 -12
View File
@@ -101,6 +101,7 @@ class User < ApplicationRecord
has_many :messages, as: :sender, dependent: :nullify
has_many :invitees, through: :account_users, class_name: 'User', foreign_key: 'inviter_id', source: :inviter, dependent: :nullify
has_many :user_sessions, dependent: :destroy
has_many :custom_filters, dependent: :destroy_async
has_many :dashboard_apps, dependent: :nullify
has_many :mentions, dependent: :destroy_async
@@ -116,12 +117,9 @@ class User < ApplicationRecord
has_many :macros, foreign_key: 'created_by_id', inverse_of: :created_by
# rubocop:enable Rails/HasManyOrHasOneDependent
AGENT_CACHE_RELEVANT_COLUMNS = %w[name email display_name confirmed_at custom_attributes].freeze
before_validation :set_password_and_uid, on: :create
after_destroy :remove_macros
after_update_commit :bump_account_user_cache_keys,
if: -> { saved_changes.keys.intersect?(AGENT_CACHE_RELEVANT_COLUMNS) }
after_save :sync_user_sessions, if: :saved_change_to_tokens?
scope :order_by_full_name, -> { order('lower(name) ASC') }
@@ -216,19 +214,16 @@ class User < ApplicationRecord
super
end
def invalidate_avatar_cache
bump_account_user_cache_keys
end
private
def sync_user_sessions
active_client_ids = (tokens || {}).keys
user_sessions.where.not(client_id: active_client_ids).destroy_all
end
def remove_macros
macros.personal.destroy_all
end
def bump_account_user_cache_keys
accounts.each { |account| account.update_cache_key('account_user') }
end
end
User.include_mod_with('Audit::User')
+46
View File
@@ -0,0 +1,46 @@
# == Schema Information
#
# Table name: user_sessions
#
# id :bigint not null, primary key
# browser_name :string
# browser_version :string
# city :string
# country :string
# country_code :string
# device_name :string
# ip_address :string
# last_activity_at :datetime
# platform_name :string
# platform_version :string
# user_agent :string
# created_at :datetime not null
# updated_at :datetime not null
# client_id :string not null
# user_id :bigint not null
#
# Indexes
#
# index_user_sessions_on_user_id (user_id)
# index_user_sessions_on_user_id_and_client_id (user_id,client_id) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (user_id => users.id)
#
class UserSession < ApplicationRecord
ACTIVITY_THROTTLE = 5.minutes
belongs_to :user
validates :client_id, presence: true, uniqueness: { scope: :user_id }
def current?(active_client_id)
client_id == active_client_id
end
def should_update_activity?
last_activity_at.nil? || last_activity_at < ACTIVITY_THROTTLE.ago
end
end
+4
View File
@@ -77,4 +77,8 @@ class InboxPolicy < ApplicationPolicy
def disable_whatsapp_calling?
@account_user.administrator?
end
def set_inbound_calls?
@account_user.administrator?
end
end
@@ -23,6 +23,8 @@ class BaseRefreshOauthTokenService
# Refresh the access tokens using the refresh token
# Refer: https://github.com/microsoftgraph/msgraph-sample-rubyrailsapp/tree/b4a6869fe4a438cde42b161196484a929f1bee46
def refresh_tokens
raise 'A refresh_token is not available' if provider_config[:refresh_token].blank?
oauth_strategy = build_oauth_strategy
token_service = build_token_service(oauth_strategy)
@@ -19,8 +19,11 @@ class Conversations::UnreadCounts::Counter
ensure_base_cache!
ensure_assignment_cache! if assignment_mode?
inbox_counts = unread_inbox_counts
{
inboxes: unread_inbox_counts,
all_count: inbox_counts.values.sum,
inboxes: inbox_counts,
labels: unread_label_counts,
teams: unread_team_counts
}
@@ -191,7 +194,7 @@ class Conversations::UnreadCounts::Counter
end
def empty_counts
{ inboxes: {}, labels: {}, teams: {} }
{ all_count: 0, inboxes: {}, labels: {}, teams: {} }
end
def store
@@ -0,0 +1,39 @@
class UserSessionTrackingService
def initialize(user:, request:, client_id:)
@user = user
@request = request
@client_id = client_id
end
def create_or_update!
session = @user.user_sessions.find_or_initialize_by(client_id: @client_id)
session.assign_attributes(session_attributes)
session.last_activity_at = Time.current
session.save!
UserSessionIpLookupJob.perform_later(session) if session.ip_address.present?
session
end
def update_activity!
session = @user.user_sessions.find_by(client_id: @client_id)
return unless session&.should_update_activity?
session.update_columns(last_activity_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
end
private
def session_attributes
browser = Browser.new(@request.user_agent)
{
ip_address: @request.remote_ip,
user_agent: @request.user_agent,
browser_name: browser.name,
browser_version: browser.full_version,
device_name: browser.device.name,
platform_name: browser.platform.name,
platform_version: browser.platform.version
}
end
end
+11 -5
View File
@@ -37,12 +37,18 @@ class WebsiteBrandingService
private
def fetch_page
response = HTTParty.get(@url, follow_redirects: true, timeout: 15)
@http_status = response.code
return nil unless response.success?
body = nil
SafeFetch.fetch(@url, validate_content_type: false) do |result|
body = result.tempfile.read
end
@http_status = 200
return nil if body.blank?
Nokogiri::HTML(response.body)
rescue StandardError => e
Nokogiri::HTML(body)
rescue SafeFetch::HttpError => e
@http_status = e.message.to_i
nil
rescue SafeFetch::Error => e
Rails.logger.error "[WebsiteBranding] Failed to fetch #{@url}: #{e.message}"
nil
end
@@ -13,7 +13,6 @@ class Whatsapp::EmbeddedSignupService
access_token = exchange_code_for_token
phone_info = fetch_phone_info(access_token)
validate_token_access(access_token)
channel = create_or_reauthorize_channel(access_token, phone_info)
# NOTE: We call setup_webhooks explicitly here instead of relying on after_commit callback because:
@@ -42,10 +41,6 @@ class Whatsapp::EmbeddedSignupService
Whatsapp::PhoneInfoService.new(@waba_id, @phone_number_id, access_token).perform
end
def validate_token_access(access_token)
Whatsapp::TokenValidationService.new(access_token, @waba_id).perform
end
def create_or_reauthorize_channel(access_token, phone_info)
if @inbox_id.present?
Whatsapp::ReauthorizationService.new(
+1 -1
View File
@@ -60,7 +60,7 @@ class Whatsapp::FacebookApiClient
data['code_verification_status'] == 'VERIFIED'
end
WEBHOOK_DEFAULT_FIELDS = %w[messages smb_message_echoes calls].freeze
WEBHOOK_DEFAULT_FIELDS = %w[messages smb_message_echoes].freeze
def subscribe_waba_webhook(waba_id, callback_url, verify_token, subscribed_fields: WEBHOOK_DEFAULT_FIELDS)
# Step 1: Subscribe app to WABA first (required before override)
@@ -13,8 +13,17 @@ class Whatsapp::IncomingMessageWhatsappCloudService < Whatsapp::IncomingMessageB
inbox.channel.media_url(attachment_payload[:id]),
headers: inbox.channel.api_headers
)
# This url response will be failure if the access token has expired.
inbox.channel.authorization_error! if url_response.unauthorized?
Down.download(url_response.parsed_response['url'], headers: inbox.channel.api_headers) if url_response.success?
return unless url_response.success?
downloaded_file = Down.download(url_response.parsed_response['url'], headers: inbox.channel.api_headers)
# WhatsApp Cloud sends the original filename in the payload; preserve it so accented
# names keep their correct extension instead of relying on the mangled remote metadata.
filename = attachment_payload[:filename]
downloaded_file.define_singleton_method(:original_filename) { filename } if filename.present?
downloaded_file
end
end
@@ -1,42 +0,0 @@
class Whatsapp::TokenValidationService
def initialize(access_token, waba_id)
@access_token = access_token
@waba_id = waba_id
@api_client = Whatsapp::FacebookApiClient.new(access_token)
end
def perform
validate_parameters!
validate_token_waba_access
end
private
def validate_parameters!
raise ArgumentError, 'Access token is required' if @access_token.blank?
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
end
def validate_token_waba_access
token_debug_data = @api_client.debug_token(@access_token)
waba_scope = extract_waba_scope(token_debug_data)
verify_waba_authorization(waba_scope)
end
def extract_waba_scope(token_data)
granular_scopes = token_data.dig('data', 'granular_scopes')
waba_scope = granular_scopes&.find { |scope| scope['scope'] == 'whatsapp_business_management' }
raise 'No WABA scope found in token' unless waba_scope
waba_scope
end
def verify_waba_authorization(waba_scope)
authorized_waba_ids = waba_scope['target_ids'] || []
return if authorized_waba_ids.include?(@waba_id)
raise "Token does not have access to WABA #{@waba_id}. Authorized WABAs: #{authorized_waba_ids}"
end
end
+11 -9
View File
@@ -17,9 +17,9 @@ class Whatsapp::WebhookSetupService
setup_webhook
end
def register_callback(subscribed_fields: nil)
def register_callback
validate_parameters!
setup_webhook(subscribed_fields: subscribed_fields)
setup_webhook
end
private
@@ -55,21 +55,23 @@ class Whatsapp::WebhookSetupService
@channel.save!
end
def setup_webhook(subscribed_fields: nil)
def setup_webhook
callback_url = build_callback_url
verify_token = @channel.provider_config['webhook_verify_token']
args = [@waba_id, callback_url, verify_token]
if subscribed_fields
@api_client.subscribe_waba_webhook(*args, subscribed_fields: subscribed_fields)
else
@api_client.subscribe_waba_webhook(*args)
end
@api_client.subscribe_waba_webhook(@waba_id, callback_url, verify_token, subscribed_fields: subscribed_fields)
rescue StandardError => e
Rails.logger.error("[WHATSAPP] Webhook setup failed: #{e.message}")
raise "Webhook setup failed: #{e.message}"
end
# Subscribe to `calls` only when voice calling is enabled on the inbox
def subscribed_fields
fields = %w[messages smb_message_echoes]
fields << 'calls' if @channel.provider_config['calling_enabled']
fields
end
def build_callback_url
frontend_url = ENV.fetch('FRONTEND_URL', nil)
phone_number = @channel.phone_number
@@ -14,6 +14,9 @@ if resource.custom_attributes.present?
json.referral_source resource.custom_attributes['referral_source'] if resource.custom_attributes['referral_source'].present?
json.brand_info resource.custom_attributes['brand_info'] if resource.custom_attributes['brand_info'].present?
json.onboarding_step resource.onboarding_step if resource.onboarding_step.present?
if resource.custom_attributes['help_center_generation_id'].present?
json.help_center_generation_id resource.custom_attributes['help_center_generation_id']
end
json.marked_for_deletion_at resource.custom_attributes['marked_for_deletion_at'] if resource.custom_attributes['marked_for_deletion_at'].present?
if resource.custom_attributes['marked_for_deletion_reason'].present?
json.marked_for_deletion_reason resource.custom_attributes['marked_for_deletion_reason']
+5 -1
View File
@@ -140,6 +140,7 @@ end
## Voice attributes for TwilioSms
if resource.twilio? && resource.channel.respond_to?(:voice_enabled?)
json.voice_enabled resource.channel.voice_enabled?
json.inbound_calls_enabled resource.channel.inbound_calls_enabled?
json.voice_configured resource.channel.try(:twiml_app_sid).present?
json.has_api_key_secret resource.channel.try(:api_key_secret).present?
if resource.channel.try(:twiml_app_sid).present?
@@ -149,4 +150,7 @@ if resource.twilio? && resource.channel.respond_to?(:voice_enabled?)
end
## Voice attribute for WhatsApp Cloud (only embedded-signup channels surface true)
json.voice_enabled resource.channel.voice_enabled? if resource.channel_type == 'Channel::Whatsapp' && resource.channel.respond_to?(:voice_enabled?)
if resource.channel_type == 'Channel::Whatsapp' && resource.channel.respond_to?(:voice_enabled?)
json.voice_enabled resource.channel.voice_enabled?
json.inbound_calls_enabled resource.channel.inbound_calls_enabled?
end
@@ -0,0 +1,15 @@
json.array! @sessions do |session|
json.id session.id
json.browser_name session.browser_name
json.browser_version session.browser_version
json.device_name session.device_name
json.platform_name session.platform_name
json.platform_version session.platform_version
json.ip_address session.ip_address
json.city session.city
json.country session.country
json.country_code session.country_code
json.last_activity_at session.last_activity_at
json.created_at session.created_at
json.current session.current?(@current_client_id)
end
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.14.1'
version: '4.14.2'
development:
<<: *shared
+20 -20
View File
@@ -19,7 +19,7 @@ class Rack::Attack
Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(redis: $velma, pool: false)
class Request < ::Rack::Request
# You many need to specify a method to fetch the correct remote IP address
# You may need to specify a method to fetch the correct remote IP address
# if the web server is behind a load balancer.
def remote_ip
@remote_ip ||= (env['action_dispatch.remote_ip'] || ip).to_s
@@ -31,9 +31,9 @@ class Rack::Attack
(default_allowed_ips + env_allowed_ips).include?(remote_ip)
end
# Rails would allow requests to paths with extentions, so lets compare against the path with extention stripped
# Rails would allow requests to paths with extensions, so lets compare against the path with extension stripped
# example /auth & /auth.json would both work
def path_without_extentions
def path_without_extensions
path[/^[^.]+/]
end
end
@@ -75,11 +75,11 @@ class Rack::Attack
### Prevent Brute-Force Super Admin Login Attacks ###
throttle('super_admin_login/ip', limit: 5, period: 5.minutes) do |req|
req.ip if req.path_without_extentions == '/super_admin/sign_in' && req.post?
req.ip if req.path_without_extensions == '/super_admin/sign_in' && req.post?
end
throttle('super_admin_login/email', limit: 5, period: 15.minutes) do |req|
if req.path_without_extentions == '/super_admin/sign_in' && req.post?
if req.path_without_extensions == '/super_admin/sign_in' && req.post?
# NOTE: This line used to throw ArgumentError /rails/action_mailbox/sendgrid/inbound_emails : invalid byte sequence in UTF-8
# Hence placed in the if block
# ref: https://github.com/rack/rack-attack/issues/399
@@ -91,7 +91,7 @@ class Rack::Attack
# ### Prevent Brute-Force Login Attacks ###
# Exclude MFA verification attempts from regular login throttling
throttle('login/ip', limit: 5, period: 5.minutes) do |req|
if req.path_without_extentions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
if req.path_without_extensions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
# Skip if this is an MFA verification request
req.ip
end
@@ -99,7 +99,7 @@ class Rack::Attack
throttle('login/email', limit: 10, period: 15.minutes) do |req|
# Skip if this is an MFA verification request
if req.path_without_extentions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
if req.path_without_extensions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
# ref: https://github.com/rack/rack-attack/issues/399
# NOTE: This line used to throw ArgumentError /rails/action_mailbox/sendgrid/inbound_emails : invalid byte sequence in UTF-8
# Hence placed in the if block
@@ -110,11 +110,11 @@ class Rack::Attack
## Reset password throttling
throttle('reset_password/ip', limit: 5, period: 30.minutes) do |req|
req.ip if req.path_without_extentions == '/auth/password' && req.post?
req.ip if req.path_without_extensions == '/auth/password' && req.post?
end
throttle('reset_password/email', limit: 5, period: 1.hour) do |req|
if req.path_without_extentions == '/auth/password' && req.post?
if req.path_without_extensions == '/auth/password' && req.post?
email = req.params['email'].presence || ActionDispatch::Request.new(req.env).params['email'].presence
email.to_s.downcase.gsub(/\s+/, '')
end
@@ -122,11 +122,11 @@ class Rack::Attack
## Resend confirmation throttling (unauthenticated)
throttle('resend_confirmation/ip', limit: 5, period: 30.minutes) do |req|
req.ip if req.path_without_extentions == '/resend_confirmation' && req.post?
req.ip if req.path_without_extensions == '/resend_confirmation' && req.post?
end
throttle('resend_confirmation/email', limit: 5, period: 1.hour) do |req|
if req.path_without_extentions == '/resend_confirmation' && req.post?
if req.path_without_extensions == '/resend_confirmation' && req.post?
email = req.params['email'].presence || ActionDispatch::Request.new(req.env).params['email'].presence
email.to_s.downcase.gsub(/\s+/, '')
end
@@ -134,25 +134,25 @@ class Rack::Attack
## Resend confirmation throttling (authenticated)
throttle('resend_confirmation_auth/ip', limit: 5, period: 30.minutes) do |req|
req.ip if req.path_without_extentions == '/api/v1/profile/resend_confirmation' && req.post?
req.ip if req.path_without_extensions == '/api/v1/profile/resend_confirmation' && req.post?
end
## MFA throttling - prevent brute force attacks
throttle('mfa_verification/ip', limit: 5, period: 1.minute) do |req|
if req.path_without_extentions == '/api/v1/profile/mfa'
if req.path_without_extensions == '/api/v1/profile/mfa'
req.ip if req.delete? # Throttle disable attempts
elsif req.path_without_extentions.match?(%r{/api/v1/profile/mfa/(verify|backup_codes)})
elsif req.path_without_extensions.match?(%r{/api/v1/profile/mfa/(verify|backup_codes)})
req.ip if req.post? # Throttle verify and backup_codes attempts
end
end
# Separate rate limiting for MFA verification attempts
throttle('mfa_login/ip', limit: 10, period: 1.minute) do |req|
req.ip if req.path_without_extentions == '/auth/sign_in' && req.post? && req.params['mfa_token'].present?
req.ip if req.path_without_extensions == '/auth/sign_in' && req.post? && req.params['mfa_token'].present?
end
throttle('mfa_login/token', limit: 10, period: 1.minute) do |req|
if req.path_without_extentions == '/auth/sign_in' && req.post?
if req.path_without_extensions == '/auth/sign_in' && req.post?
# Track by MFA token to prevent brute force on a specific token
mfa_token = req.params['mfa_token'].presence
(mfa_token.presence)
@@ -161,7 +161,7 @@ class Rack::Attack
## Prevent Brute-Force Signup Attacks ###
throttle('accounts/ip', limit: 5, period: 30.minutes) do |req|
req.ip if req.path_without_extentions == '/api/v1/accounts' && req.post?
req.ip if req.path_without_extensions == '/api/v1/accounts' && req.post?
end
##-----------------------------------------------##
@@ -176,17 +176,17 @@ class Rack::Attack
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK_WIDGET_API', true))
## Prevent Conversation Bombing on Widget APIs ###
throttle('api/v1/widget/conversations', limit: 6, period: 12.hours) do |req|
req.ip if req.path_without_extentions == '/api/v1/widget/conversations' && req.post?
req.ip if req.path_without_extensions == '/api/v1/widget/conversations' && req.post?
end
## Prevent Contact update Bombing in Widget API ###
throttle('api/v1/widget/contacts', limit: 60, period: 1.hour) do |req|
req.ip if req.path_without_extentions == '/api/v1/widget/contacts' && (req.patch? || req.put?)
req.ip if req.path_without_extensions == '/api/v1/widget/contacts' && (req.patch? || req.put?)
end
## Prevent Conversation Bombing through multiple sessions
throttle('widget?website_token={website_token}&cw_conversation={x-auth-token}', limit: 5, period: 1.hour) do |req|
req.ip if req.path_without_extentions == '/widget' && ActionDispatch::Request.new(req.env).params['cw_conversation'].blank?
req.ip if req.path_without_extensions == '/widget' && ActionDispatch::Request.new(req.env).params['cw_conversation'].blank?
end
end
+8 -1
View File
@@ -1,3 +1,10 @@
# Be sure to restart your server when you modify this file.
# Sessions are used only for the super_admin dashboard (flash/CSRF), not for API auth.
Rails.application.config.session_store :cookie_store, key: '_chatwoot_session', same_site: :lax
secure_cookies = ActiveModel::Type::Boolean.new.cast(ENV.fetch('FORCE_SSL', false))
Rails.application.config.session_store :cookie_store,
key: '_chatwoot_session',
same_site: :lax,
secure: secure_cookies,
httponly: true
+4
View File
@@ -47,6 +47,10 @@ en:
saml_not_available: SAML authentication is not available in this installation.
inbox_deletetion_response: Your inbox deletion request will be processed in some time.
profile_settings:
sessions:
cannot_revoke_current: You cannot revoke the current session.
errors:
account:
reporting_timezone:
+5 -1
View File
@@ -55,7 +55,9 @@ Rails.application.routes.draw do
resource :contact_merge, only: [:create]
end
resource :bulk_actions, only: [:create]
resource :onboarding, only: [:update]
resource :onboarding, only: [:update] do
get :help_center_generation
end
resources :agents, only: [:index, :create, :update, :destroy] do
post :bulk_create, on: :collection
end
@@ -265,6 +267,7 @@ Rails.application.routes.draw do
end
post :enable_whatsapp_calling, on: :member
post :disable_whatsapp_calling, on: :member
post :set_inbound_calls, on: :member
end
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates' do
@@ -434,6 +437,7 @@ Rails.application.routes.draw do
post :verify
post :backup_codes
end
resources :sessions, only: [:index, :destroy]
end
end
@@ -0,0 +1,5 @@
class AddProviderConfigToChannelTwilioSms < ActiveRecord::Migration[7.1]
def change
add_column :channel_twilio_sms, :provider_config, :jsonb, default: {}
end
end
@@ -0,0 +1,23 @@
class CreateUserSessions < ActiveRecord::Migration[7.0]
def change
create_table :user_sessions do |t|
t.references :user, null: false, foreign_key: true
t.string :client_id, null: false
t.string :ip_address
t.string :user_agent
t.string :browser_name
t.string :browser_version
t.string :device_name
t.string :platform_name
t.string :platform_version
t.string :city
t.string :country
t.string :country_code
t.datetime :last_activity_at
t.timestamps
end
add_index :user_sessions, [:user_id, :client_id], unique: true
end
end

Some files were not shown because too many files have changed in this diff Show More