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
115 changed files with 1604 additions and 641 deletions
+1 -1
View File
@@ -582,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)
+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])
@@ -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')
@@ -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
@@ -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,
+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
View File
@@ -60,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();
@@ -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();
@@ -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'),
},
@@ -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',
@@ -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(',');
};
@@ -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",
@@ -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
@@ -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);
@@ -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);
});
});
});
-9
View File
@@ -109,15 +109,6 @@ const runSDK = ({ baseUrl, websiteToken }) => {
});
},
openArticle(slug) {
if (!slug) {
throw new Error('Article slug is required');
}
IFrameHelper.events.toggleBubble('open');
IFrameHelper.sendMessage('open-article', { slug });
},
setUser(identifier, user) {
if (typeof identifier !== 'string' && typeof identifier !== 'number') {
throw new Error('Identifier should be a string or a number');
-6
View File
@@ -19,8 +19,6 @@ import {
setBubbleText,
addUnreadClass,
removeUnreadClass,
addArticleViewClass,
removeArticleViewClass,
} from './bubbleHelpers';
import { isWidgetColorLighter } from 'shared/helpers/colorHelper';
import { dispatchWindowEvent } from 'shared/helpers/CustomEventHelper';
@@ -270,10 +268,6 @@ export const IFrameHelper = {
},
resetUnreadMode: () => removeUnreadClass(),
expandWidget: () => addArticleViewClass(),
collapseWidget: () => removeArticleViewClass(),
handleNotificationDot: event => {
if (window.$chatwoot.hideMessageBubble) {
return;
-10
View File
@@ -110,13 +110,3 @@ export const removeUnreadClass = () => {
const holderEl = document.querySelector('.woot-widget-holder');
removeClasses(holderEl, 'has-unread-view');
};
export const addArticleViewClass = () => {
const holderEl = document.querySelector('.woot-widget-holder');
addClasses(holderEl, 'has-article-view');
};
export const removeArticleViewClass = () => {
const holderEl = document.querySelector('.woot-widget-holder');
removeClasses(holderEl, 'has-article-view');
};
+2 -11
View File
@@ -7,14 +7,11 @@ export const SDK_CSS = `
.woot-widget-holder {
box-shadow: 0 5px 40px rgba(0, 0, 0, .16);
opacity: 1;
will-change: transform, opacity, width, height;
will-change: transform, opacity;
transform: translateY(0);
overflow: hidden !important;
position: fixed !important;
transition: opacity 0.2s linear, transform 0.25s linear,
width 0.18s cubic-bezier(0.4, 0, 0.2, 1),
height 0.18s cubic-bezier(0.4, 0, 0.2, 1),
max-height 0.18s cubic-bezier(0.4, 0, 0.2, 1);
transition: opacity 0.2s linear, transform 0.25s linear;
z-index: 2147483000 !important;
}
@@ -290,12 +287,6 @@ export const SDK_CSS = `
min-height: 250px !important;
width: 400px !important;
}
.woot-widget-holder.has-article-view {
width: min(640px, max(0px, -20px + 100dvw)) !important;
height: calc(100% - 125px) !important;
max-height: calc(100% - 125px) !important;
}
}
.woot-hidden {
@@ -38,24 +38,3 @@ export const getMatchingLocale = (selectedLocale = '', allowedLocales = []) => {
// Return the first match that exists in the allowed list, or null
return priorityMatches.find(l => l && allowedLocales.includes(l)) ?? null;
};
/**
* Build the link consumed by the in-widget article viewer, appending the query
* params it expects (plain layout, theme and locale).
*
* @export
* @param {Object} options
* @param {string} options.link Relative article/portal path (e.g. `hc/slug/articles/foo`).
* @param {(string|null)} [options.locale] Resolved portal locale.
* @param {boolean} [options.prefersDarkMode] Whether the widget is in dark mode.
* @returns {string} The link with the article viewer query params appended.
*/
export const buildArticleViewerLink = ({ link, locale, prefersDarkMode }) => {
const params = new URLSearchParams({
show_plain_layout: 'true',
theme: prefersDarkMode ? 'dark' : 'light',
...(locale && { locale }),
});
return `${link}?${params.toString()}`;
};
+1 -74
View File
@@ -16,22 +16,12 @@ import {
ON_AGENT_MESSAGE_RECEIVED,
ON_CAMPAIGN_MESSAGE_CLICK,
ON_UNREAD_MESSAGE_CLICK,
ON_ARTICLE_VIEW_RESIZING,
} from './constants/widgetBusEvents';
// Keep in sync with the widget holder width/height transition in sdk.js. The
// article view is masked for this long so the iframe can reflow off-screen.
const ARTICLE_VIEW_RESIZE_DURATION = 180;
import { useDarkMode } from 'widget/composables/useDarkMode';
import { useRouter } from 'vue-router';
import { useAvailability } from 'widget/composables/useAvailability';
import { useArticleView } from 'widget/composables/useArticleView';
import { SDK_SET_BUBBLE_VISIBILITY } from '../shared/constants/sharedFrameEvents';
import { emitter } from 'shared/helpers/mitt';
import {
getMatchingLocale,
buildArticleViewerLink,
} from 'shared/helpers/portalHelper';
export default {
name: 'App',
@@ -43,17 +33,8 @@ export default {
const { prefersDarkMode } = useDarkMode();
const router = useRouter();
const { isInWorkingHours } = useAvailability();
const { isArticleView, isWidgetExpanded, setArticleView } =
useArticleView();
return {
prefersDarkMode,
router,
isInWorkingHours,
isArticleView,
isWidgetExpanded,
setArticleView,
};
return { prefersDarkMode, router, isInWorkingHours };
},
data() {
return {
@@ -85,11 +66,6 @@ export default {
? getLanguageDirection(this.$root.$i18n.locale)
: false;
},
shouldExpandArticleView() {
// The widget only widens on article pages, and only when the user has
// opted in via the header toggle (persisted, collapsed by default).
return this.isArticleView && this.isWidgetExpanded;
},
},
watch: {
activeCampaign() {
@@ -101,26 +77,6 @@ export default {
document.documentElement.dir = value ? 'rtl' : 'ltr';
},
},
'$route.name'(routeName, previousRouteName) {
// Leaving the article view tears down the iframe, so reset the flag; the
// watcher below collapses the widget if it was expanded.
if (previousRouteName === 'article-viewer') {
this.isArticleView = false;
}
},
shouldExpandArticleView(shouldExpand) {
if (!this.isIFrame) return;
// Resize the host widget and mask the iframe while it reflows off-screen,
// revealing it once the size transition settles.
IFrameHelper.sendMessage({
event: shouldExpand ? 'expandWidget' : 'collapseWidget',
});
emitter.emit(ON_ARTICLE_VIEW_RESIZING, true);
setTimeout(
() => emitter.emit(ON_ARTICLE_VIEW_RESIZING, false),
ARTICLE_VIEW_RESIZE_DURATION
);
},
},
mounted() {
const { websiteToken, locale, widgetColor } = window.chatwootWebChannel;
@@ -204,26 +160,6 @@ export default {
this.$root.$i18n.locale = localeWithoutVariation;
}
},
openArticle(slug) {
const { portal } = window.chatwootWebChannel;
if (!portal || !slug) return;
const locale = getMatchingLocale(
this.$root.$i18n.locale,
portal.config?.allowed_locales
);
const link = buildArticleViewerLink({
link: `hc/${portal.slug}/articles/${slug}`,
locale,
prefersDarkMode: this.prefersDarkMode,
});
// Add a timestamp so the route always changes, even when the same article
// is requested again or the iframe was browsed to another page.
this.router.push({
name: 'article-viewer',
query: { link, v: Date.now() },
});
},
registerUnreadEvents() {
emitter.on(ON_AGENT_MESSAGE_RECEIVED, () => {
const { name: routeName } = this.$route;
@@ -380,15 +316,6 @@ export default {
this.setBubbleLabel();
} else if (message.event === 'set-color-scheme') {
this.setColorScheme(message.darkMode);
} else if (message.event === 'open-article') {
this.openArticle(message.slug);
} else if (message.event === 'portalPageLoaded') {
// portalPageLoaded is delivered asynchronously and can arrive after
// we've left the article view; ignore it unless we're still there so
// the expanded width can't leak onto the listing or other views.
if (this.$route.name === 'article-viewer') {
this.setArticleView(message.isArticle);
}
} else if (message.event === 'toggle-open') {
this.$store.dispatch('appConfig/toggleWidgetOpen', message.isOpen);
@@ -1,12 +1,10 @@
<script setup>
import { toRef } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import HeaderActions from './HeaderActions.vue';
import AvailabilityContainer from 'widget/components/Availability/AvailabilityContainer.vue';
import { useAvailability } from 'widget/composables/useAvailability';
import { useArticleView } from 'widget/composables/useArticleView';
const props = defineProps({
avatarUrl: { type: String, default: '' },
@@ -19,10 +17,7 @@ const props = defineProps({
const availableAgents = toRef(props, 'availableAgents');
const router = useRouter();
const { t } = useI18n();
const { isOnline } = useAvailability(availableAgents);
const { isArticleView, isWidgetExpanded, toggleWidgetExpanded } =
useArticleView();
const onBackButtonClick = () => {
router.replace({ name: 'home' });
@@ -63,25 +58,6 @@ const onBackButtonClick = () => {
/>
</div>
</div>
<div class="flex items-center gap-3">
<button
v-if="isArticleView"
class="button transparent compact"
:title="
isWidgetExpanded
? t('PORTAL.COLLAPSE_ARTICLE')
: t('PORTAL.EXPAND_ARTICLE')
"
@click="toggleWidgetExpanded"
>
<span
class="size-4 text-n-slate-12"
:class="
isWidgetExpanded ? 'i-lucide-minimize-2' : 'i-lucide-maximize-2'
"
/>
</button>
<HeaderActions :show-popout-button="showPopoutButton" />
</div>
<HeaderActions :show-popout-button="showPopoutButton" />
</header>
</template>
@@ -7,10 +7,7 @@ import { useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useDarkMode } from 'widget/composables/useDarkMode';
import {
getMatchingLocale,
buildArticleViewerLink,
} from 'shared/helpers/portalHelper';
import { getMatchingLocale } from 'shared/helpers/portalHelper';
const store = useStore();
const router = useRouter();
@@ -41,11 +38,14 @@ const fetchArticles = () => {
};
const openArticleInArticleViewer = link => {
const linkToOpen = buildArticleViewerLink({
link,
locale: locale.value,
prefersDarkMode: prefersDarkMode.value,
const params = new URLSearchParams({
show_plain_layout: 'true',
theme: prefersDarkMode.value ? 'dark' : 'light',
...(locale.value && { locale: locale.value }),
});
// Combine link with query parameters
const linkToOpen = `${link}?${params.toString()}`;
router.push({ name: 'article-viewer', query: { link: linkToOpen } });
};
@@ -1,31 +0,0 @@
import { ref } from 'vue';
import { LocalStorage } from 'shared/helpers/localStorage';
const EXPANDED_STORAGE_KEY = 'chatwoot:widget:articleViewExpanded';
// Module-level singletons so the header toggle and the resize logic in App.vue
// share a single source of truth.
//
// `isArticleView` - whether the iframe is currently showing an article page.
// `isWidgetExpanded`- the user's persisted expand/collapse preference. Defaults
// to collapsed and only ever applies on article pages.
const isArticleView = ref(false);
const isWidgetExpanded = ref(LocalStorage.get(EXPANDED_STORAGE_KEY) === true);
export function useArticleView() {
const setArticleView = value => {
isArticleView.value = value;
};
const toggleWidgetExpanded = () => {
isWidgetExpanded.value = !isWidgetExpanded.value;
LocalStorage.set(EXPANDED_STORAGE_KEY, isWidgetExpanded.value);
};
return {
isArticleView,
isWidgetExpanded,
setArticleView,
toggleWidgetExpanded,
};
}
@@ -2,4 +2,3 @@ export const ON_AGENT_MESSAGE_RECEIVED = 'ON_AGENT_MESSAGE_RECEIVED';
export const ON_UNREAD_MESSAGE_CLICK = 'ON_UNREAD_MESSAGE_CLICK';
export const ON_CAMPAIGN_MESSAGE_CLICK = 'ON_CAMPAIGN_MESSAGE_CLICK';
export const ON_CONVERSATION_CREATED = 'ON_CONVERSATION_CREATED';
export const ON_ARTICLE_VIEW_RESIZING = 'ON_ARTICLE_VIEW_RESIZING';
@@ -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'
);
});
});
+1 -3
View File
@@ -125,9 +125,7 @@
"PORTAL": {
"POPULAR_ARTICLES": "Popular Articles",
"VIEW_ALL_ARTICLES": "View all articles",
"IFRAME_LOAD_ERROR": "There was an error loading the article, please refresh the page and try again.",
"EXPAND_ARTICLE": "Expand",
"COLLAPSE_ARTICLE": "Collapse"
"IFRAME_LOAD_ERROR": "There was an error loading the article, please refresh the page and try again."
},
"ATTACHMENTS": {
"image": {
+8 -33
View File
@@ -1,41 +1,16 @@
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { useRoute } from 'vue-router';
import { emitter } from 'shared/helpers/mitt';
import { ON_ARTICLE_VIEW_RESIZING } from 'widget/constants/widgetBusEvents';
<script>
import IframeLoader from 'shared/components/IframeLoader.vue';
const route = useRoute();
// Masks the article while the widget resizes (see App.vue#setArticleView) so the
// iframe's text reflow happens off-screen instead of shifting in front of the user.
const isResizing = ref(false);
const setResizing = value => {
isResizing.value = value;
export default {
name: 'ArticleViewer',
components: {
IframeLoader,
},
};
onMounted(() => emitter.on(ON_ARTICLE_VIEW_RESIZING, setResizing));
onBeforeUnmount(() => emitter.off(ON_ARTICLE_VIEW_RESIZING, setResizing));
</script>
<template>
<div class="bg-white dark:bg-slate-900 h-full relative">
<!--
Key by fullPath (not just the link) so the iframe remounts on every
navigation here, including re-opening the same article via the SDK after
the iframe was browsed to another help-center page. See App.vue#openArticle.
-->
<IframeLoader :key="route.fullPath" :url="route.query.link" />
<!--
Cover the article instantly while the widget resizes, then fade it out once
the size transition settles. The asymmetric class (no transition on the way
in, transition on the way out) keeps the cover from revealing the reflow.
-->
<div
class="absolute inset-0 bg-white dark:bg-slate-900 pointer-events-none"
:class="
isResizing ? 'opacity-100' : 'opacity-0 transition-opacity duration-100'
"
/>
<div class="bg-white h-full">
<IframeLoader :url="$route.query.link" />
</div>
</template>
+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
+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)")
+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
+7
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
@@ -118,6 +119,7 @@ class User < ApplicationRecord
before_validation :set_password_and_uid, on: :create
after_destroy :remove_macros
after_save :sync_user_sessions, if: :saved_change_to_tokens?
scope :order_by_full_name, -> { order('lower(name) ASC') }
@@ -214,6 +216,11 @@ class User < ApplicationRecord
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
+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
@@ -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
@@ -88,19 +88,6 @@ html.light {
tocHeader: '<%= I18n.t('public_portal.toc_header') %>'
};
</script>
<% if @is_plain_layout_enabled %>
<script>
// When rendered inside the chat widget, tell it whether this is an article
// page so the widget widens only for articles and collapses on every other page.
window.parent.postMessage(
'chatwoot-widget:' + JSON.stringify({
event: 'portalPageLoaded',
isArticle: <%= @article.present? %>,
}),
'*'
);
</script>
<% end %>
<% if @portal.channel_web_widget.present? && !@is_plain_layout_enabled %>
<script>
window.chatwootSettings = window.chatwootSettings || {};
@@ -12,7 +12,7 @@
<% end %>
<section id="portal-bg" class="w-full bg-white dark:bg-slate-900 shadow-inner">
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6 min-h-[240px] md:min-h-[260px]">
<div class="mx-auto max-w-5xl px-5 md:px-8 flex flex-col items-start">
<div class="mx-auto max-w-5xl px-4 md:px-8 flex flex-col items-start">
<span class="text-sm leading-[24px] font-semibold text-slate-600 dark:text-slate-300 mb-1 <%= @portal.logo.present? ? 'block lg:hidden' : 'hidden' %>"><%= @portal.localized_value('name', @locale) %></span>
<h1 class="text-2xl md:text-4xl text-slate-900 dark:text-white font-semibold leading-normal">
<%= portal.localized_value('header_text', @locale) %>
@@ -10,7 +10,7 @@
<div class="flex flex-row items-center gap-px mb-6">
<a
class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer <%= @is_plain_layout_enabled && 'hover:underline' %> leading-8 font-semibold"
href="<%= generate_home_link(@portal.slug, @article.category&.locale, @theme_from_params, @is_plain_layout_enabled) %>"
href="<%= generate_home_link(@portal.slug, @article.category&.locale, @theme_from_params, @is_plain_layout_enabled) %>"
>
<%= I18n.t('public_portal.common.home') %>
</a>
@@ -28,7 +28,7 @@
<h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white">
<%= article.title %>
</h1>
<div class="flex flex-col items-start justify-between w-full md:flex-row md:items-center">
<div class="flex flex-col items-start justify-between w-full pt-6 md:flex-row md:items-center">
<div class="flex items-start space-x-1">
<span class="flex items-center text-base font-medium text-slate-600 dark:text-slate-400">
<%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %>
@@ -24,19 +24,19 @@
<% if !@is_plain_layout_enabled %>
<div id="portal-bg" class="bg-white dark:bg-slate-900 shadow-inner">
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6">
<div class="max-w-5xl px-5 md:px-8 mx-auto flex flex-col">
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col">
<%= render "public/api/v1/portals/articles/article_header", article: @article %>
</div>
</div>
</div>
<% else %>
<div class="max-w-5xl mx-auto space-y-4 w-full px-5 md:px-8 <%= @is_plain_layout_enabled ? 'py-4' : 'py-8' %>">
<div class="max-w-5xl mx-auto space-y-4 w-full px-4 md:px-8 <%= @is_plain_layout_enabled ? 'py-4' : 'py-8' %>">
<%= render "public/api/v1/portals/articles/article_header", article: @article %>
</div>
<% end %>
<div class="flex max-w-5xl w-full px-5 md:px-8 mx-auto">
<article id="cw-article-content" class="article-content flex-grow flex-2 mx-auto text-slate-800 dark:text-slate-50 text-base max-w-3xl prose-h1:text-xl prose-h2:text-lg prose-h2:mt-8 prose-h3:text-base prose-h3:mt-6 prose-headings:mb-3 [&>:first-child]:!mt-0 prose-code:[&>p]:p-1 prose-code:[&>p]:rounded-sm prose-code:[&>p]:bg-black-100 dark:prose-code:[&>p]:bg-black-600 prose-code:after:content-none prose-code:before:content-none prose dark:prose-invert break-words w-full [&_table]:!border-slate-200 dark:[&_table]:!border-slate-800 [&_th]:!border-slate-200 dark:[&_th]:!border-slate-800 [&_td]:!border-slate-200 dark:[&_td]:!border-slate-800 [&_th]:!bg-slate-50 dark:[&_th]:!bg-slate-800/50 <%= @is_plain_layout_enabled ? 'py-4' : 'pt-8 pb-12' %>">
<div class="flex max-w-5xl w-full px-4 md:px-8 mx-auto">
<article id="cw-article-content" class="article-content flex-grow flex-2 mx-auto text-slate-800 dark:text-slate-50 text-lg max-w-3xl prose-h1:text-2xl prose-h2:text-xl prose-h2:mt-0 prose-h3:text-lg prose-code:[&>p]:p-1 prose-code:[&>p]:rounded-sm prose-code:[&>p]:bg-black-100 dark:prose-code:[&>p]:bg-black-600 prose-code:after:content-none prose-code:before:content-none prose dark:prose-invert break-words w-full [&_table]:!border-slate-200 dark:[&_table]:!border-slate-800 [&_th]:!border-slate-200 dark:[&_th]:!border-slate-800 [&_td]:!border-slate-200 dark:[&_td]:!border-slate-800 [&_th]:!bg-slate-50 dark:[&_th]:!bg-slate-800/50 <%= @is_plain_layout_enabled ? 'py-4' : 'pt-8 pb-12' %>">
<%= @parsed_content %>
</article>
<div class="flex-1" id="cw-hc-toc"></div>
@@ -1,4 +1,4 @@
<div class="flex flex-col px-5 md:px-8 max-w-5xl w-full mx-auto gap-6 <%= @is_plain_layout_enabled && 'py-4' %>">
<div class="flex flex-col px-4 md:px-8 max-w-5xl w-full mx-auto gap-6 <%= @is_plain_layout_enabled && 'py-4' %>">
<div class="flex items-center flex-row">
<a
class="text-slate-500 dark:text-slate-200 text-sm gap-1 <%= @is_plain_layout_enabled && 'hover:underline' %> hover:cursor-pointer leading-8 font-semibold"
@@ -23,7 +23,7 @@
<% else %>
<%= render 'public/api/v1/portals/categories/category-hero', category: @category, portal: @portal %>
<% end %>
<section class="max-w-5xl w-full mx-auto px-5 md:px-8 py-6 flex flex-col items-center justify-center flex-grow">
<section class="max-w-5xl w-full mx-auto px-4 md:px-8 py-6 flex flex-col items-center justify-center flex-grow">
<div class="w-full flex flex-col gap-6 flex-grow">
<% if @category.articles.published.size == 0 %>
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl py-6">
@@ -7,7 +7,7 @@
<% if !@is_plain_layout_enabled %>
<div id="portal-bg" class="bg-white dark:bg-slate-900 shadow-inner">
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6">
<div class="max-w-5xl px-5 md:px-8 mx-auto flex flex-col">
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col">
<div class="flex flex-row items-center gap-px mb-6">
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer hover:underline leading-8 font-semibold"
href="<%= generate_home_link(@portal.slug, params[:locale], @theme_from_params, @is_plain_layout_enabled) %>">
@@ -30,7 +30,7 @@
</div>
</div>
<% else %>
<div class="max-w-5xl px-5 md:px-8 mx-auto flex flex-col py-4">
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col py-4">
<div class="flex flex-row items-center gap-px mb-6">
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer hover:underline leading-8 font-semibold"
href="<%= generate_home_link(@portal.slug, params[:locale], @theme_from_params, @is_plain_layout_enabled) %>">
@@ -56,7 +56,7 @@
<%= render 'public/api/v1/portals/search/search_handler' %>
<section class="max-w-5xl w-full mx-auto px-5 md:px-8 py-6 flex flex-col items-center justify-center flex-grow">
<section class="max-w-5xl w-full mx-auto px-4 md:px-8 py-6 flex flex-col items-center justify-center flex-grow">
<div class="w-full flex flex-col gap-6 flex-grow">
<% if @articles.empty? %>
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl py-6">
@@ -1,5 +1,5 @@
<%= render "public/api/v1/portals/hero", portal: @portal %>
<div class="max-w-5xl w-full flex flex-col flex-grow mx-auto py-8 px-5 md:px-8 gap-6">
<div class="max-w-5xl w-full flex flex-col flex-grow mx-auto py-8 px-4 md:px-8 gap-6">
<%# Featured Articles %>
<% if !@is_plain_layout_enabled %>
<div><%= render "public/api/v1/portals/featured_articles", articles: @portal.articles, categories: @portal.categories.where(locale: @locale), portal: @portal %></div>
+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
+23 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) do
ActiveRecord::Schema[7.1].define(version: 2026_06_11_184600) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -557,6 +557,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) do
t.boolean "voice_enabled", default: false, null: false
t.string "twiml_app_sid"
t.string "api_key_secret"
t.jsonb "provider_config", default: {}
t.index ["account_sid", "phone_number"], name: "index_channel_twilio_sms_on_account_sid_and_phone_number", unique: true
t.index ["messaging_service_sid"], name: "index_channel_twilio_sms_on_messaging_service_sid", unique: true
t.index ["phone_number"], name: "index_channel_twilio_sms_on_phone_number", unique: true
@@ -1252,6 +1253,26 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) do
t.index ["name", "account_id"], name: "index_teams_on_name_and_account_id", unique: true
end
create_table "user_sessions", force: :cascade do |t|
t.bigint "user_id", null: false
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.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id", "client_id"], name: "index_user_sessions_on_user_id_and_client_id", unique: true
t.index ["user_id"], name: "index_user_sessions_on_user_id"
end
create_table "users", id: :serial, force: :cascade do |t|
t.string "provider", default: "email", null: false
t.string "uid", default: "", null: false
@@ -1324,6 +1345,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "inboxes", "portals"
add_foreign_key "user_sessions", "users"
create_trigger("accounts_after_insert_row_tr", :generated => true, :compatibility => 1).
on("accounts").
after(:insert).
@@ -1,4 +1,6 @@
module Enterprise::Api::V1::Accounts::InboxesController
extend ActiveSupport::Concern
def inbox_attributes
super + ee_inbox_attributes
end
@@ -21,6 +23,24 @@ module Enterprise::Api::V1::Accounts::InboxesController
render_could_not_create_error(e.message)
end
# Toggles only the inbound-calls flag in provider_config. Saved with validate: false
# so WhatsApp's remote credential re-check (validate_provider_config) can't reject a
# simple toggle, mirroring enable_voice_calling!. Voice support (WhatsApp calling or
# Twilio voice) is guarded inline by ensure_inbound_calls_supported.
def set_inbound_calls
return unless ensure_inbound_calls_supported
channel = @inbox.channel
channel.provider_config = (channel.provider_config || {}).merge(
'inbound_calls_enabled' => ActiveModel::Type::Boolean.new.cast(params[:inbound_calls_enabled])
)
channel.save!(validate: false)
@inbox.update_account_cache # bump inbox cache key so the cached inbox list refetches the new flag
head :ok
rescue StandardError => e
render_could_not_create_error(e.message)
end
def ee_inbox_attributes
[auto_assignment_config: [:max_assignment_limit]]
end
@@ -35,6 +55,14 @@ module Enterprise::Api::V1::Accounts::InboxesController
false
end
# Inbound calls can be toggled on any voice-enabled inbox (WhatsApp calling or Twilio voice).
def ensure_inbound_calls_supported
return true if @inbox.channel.try(:voice_enabled?)
render_could_not_create_error('Inbox does not support calling')
false
end
def allowed_channel_types
super + ['voice']
end
@@ -0,0 +1,38 @@
module Enterprise::Api::V1::Accounts::OnboardingsController
def help_center_generation
@account = Current.account
render json: help_center_generation_status
end
private
def help_center_generation_status
generation_id = help_center_generation_id
return super if generation_id.blank?
state = Onboarding::HelpCenterGenerationState.current(generation_id)
{
generation_id: generation_id,
state: state,
articles_count: articles_count,
categories_count: categories_count
}
end
def help_center_generation_id
@account.custom_attributes['help_center_generation_id']
end
def articles_count
onboarding_portal&.articles&.count || 0
end
def categories_count
onboarding_portal&.categories&.count || 0
end
def onboarding_portal
@onboarding_portal ||= @account.portals.first
end
end
@@ -24,6 +24,8 @@ class Twilio::VoiceController < ApplicationController
"TWILIO_VOICE_TWIML account=#{current_account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
)
return render xml: reject_twiml if reject_inbound?
call = resolve_call
render xml: conference_twiml(call)
end
@@ -88,6 +90,16 @@ class Twilio::VoiceController < ApplicationController
from_number.start_with?('client:')
end
# A fresh contact-initiated leg on an inbox with inbound calls turned off.
# Reject it so no conference, conversation, or Call row is created.
def reject_inbound?
twilio_direction == 'inbound' && !agent_leg?(twilio_from) && !inbox.channel.inbound_calls_enabled?
end
def reject_twiml
Twilio::TwiML::VoiceResponse.new(&:reject).to_s
end
def resolve_call
return find_call_for_agent if agent_leg?(twilio_from)
@@ -2,10 +2,10 @@ class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob
queue_as :low
retry_on Firecrawl::FirecrawlError, wait: :polynomially_longer, attempts: 3 do |job, error|
_account_id, _portal_id, user_id, generation_id = job.arguments
_account_id, _portal_id, _user_id, generation_id = job.arguments
reason = "firecrawl exhausted: #{error.message}"
Rails.logger.warn "[HelpCenterGenerationJob] gen=#{generation_id} #{reason}"
job.send(:skip_and_broadcast, user: User.find_by(id: user_id), generation_id: generation_id, reason: reason)
job.send(:skip_generation, generation_id: generation_id, reason: reason)
end
def perform(account_id, portal_id, user_id, generation_id)
@@ -19,7 +19,7 @@ class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob
)
rescue Onboarding::HelpCenterErrors::CurationSkipped => e
Rails.logger.info "[HelpCenterGenerationJob] gen=#{generation_id} skipped: #{e.message}"
skip_and_broadcast(user: User.find_by(id: user_id), generation_id: generation_id, reason: e.message)
skip_generation(generation_id: generation_id, reason: e.message)
end
private
@@ -89,15 +89,12 @@ class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob
def enqueue_writer_jobs(account_id:, portal_id:, user_id:, generation_id:, articles:)
articles.each do |article|
Onboarding::HelpCenterArticleWriterJob.perform_later(
account_id, portal_id, user_id, generation_id, { article: article }
account_id, portal_id, user_id, generation_id, article
)
end
end
def skip_and_broadcast(user:, generation_id:, reason:)
def skip_generation(generation_id:, reason:)
Onboarding::HelpCenterGenerationState.skip(generation_id, reason: reason)
Onboarding::HelpCenterBroadcaster.completed(
user: user, generation_id: generation_id, status: 'skipped', skip_reason: reason
)
end
end
@@ -9,43 +9,27 @@ class Onboarding::HelpCenterArticleWriterJob < ApplicationJob
job.send(:on_writer_failure, error)
end
def perform(account_id, portal_id, user_id, generation_id, article_payload)
user = User.find(user_id)
payload = article_payload.with_indifferent_access
article = Onboarding::HelpCenterArticleBuilder.new(
def perform(account_id, portal_id, user_id, generation_id, article)
Onboarding::HelpCenterArticleBuilder.new(
account: Account.find(account_id),
portal: Portal.find(portal_id),
user: user,
article: payload[:article]
user: User.find(user_id),
article: article
).perform
finalize(user: user, generation_id: generation_id, article: article)
finalize(generation_id: generation_id)
end
private
def on_writer_failure(error)
user, generation_id = failure_context
generation_id = arguments[3]
Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} failed: #{error.class} #{error.message}"
finalize(user: user, generation_id: generation_id, article: nil)
finalize(generation_id: generation_id)
end
def failure_context
_account_id, _portal_id, user_id, generation_id = arguments
[User.find_by(id: user_id), generation_id]
end
def finalize(user:, generation_id:, article:)
result = Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
if article
Onboarding::HelpCenterBroadcaster.article_generated(
user: user, generation_id: generation_id, article: article, articles_finished: result[:finished]
)
end
return unless result[:completed]
Onboarding::HelpCenterBroadcaster.completed(user: user, generation_id: generation_id, status: 'completed')
def finalize(generation_id:)
Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
rescue Onboarding::HelpCenterGenerationState::Missing => e
Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} #{e.message}"
end
@@ -1,29 +0,0 @@
module Onboarding::HelpCenterBroadcaster
ARTICLE_GENERATED = 'help_center.article_generated'.freeze
GENERATION_COMPLETED = 'help_center.generation_completed'.freeze
module_function
def article_generated(user:, generation_id:, article:, articles_finished:)
broadcast(user, ARTICLE_GENERATED, {
generation_id: generation_id,
article_id: article.id,
articles_finished: articles_finished
})
end
def completed(user:, generation_id:, status:, skip_reason: nil)
broadcast(user, GENERATION_COMPLETED, {
generation_id: generation_id,
status: status,
skip_reason: skip_reason
})
end
def broadcast(user, event, payload)
token = user&.pubsub_token
return if token.blank?
ActionCableBroadcastJob.perform_later([token], event, payload)
end
end
@@ -77,6 +77,7 @@ class Onboarding::HelpCenterCreationService
generation_id = SecureRandom.uuid
Onboarding::HelpCenterArticleGenerationJob.perform_later(@account.id, portal.id, @user.id, generation_id)
@account.update!(custom_attributes: @account.custom_attributes.merge('help_center_generation_id' => generation_id))
rescue StandardError => e
Rails.logger.error "[HelpCenterCreation] Failed to enqueue article generation for account #{@account.id}: #{e.class} - #{e.message}"
end
@@ -9,7 +9,7 @@ class Whatsapp::CallService
call.with_lock do
transition_to_in_progress!
update_message_status('in_progress')
update_conversation_call_status(call.display_status)
claim_conversation_and_set_call_status
broadcast(:accepted, accepted_by_agent_id: agent.id)
end
call
@@ -56,7 +56,6 @@ class Whatsapp::CallService
forward_answer_to_meta!
call.update!(status: 'in_progress', accepted_by_agent_id: agent.id, started_at: Time.current,
meta: (call.meta || {}).merge('sdp_answer' => sdp_answer))
claim_conversation_for_agent
end
def forward_answer_to_meta!
@@ -64,9 +63,13 @@ class Whatsapp::CallService
invoke_provider!(:accept_call, sdp_answer)
end
# Take ownership of the conversation if no one holds it; leave assignee alone otherwise (transfer via UI).
def claim_conversation_for_agent
call.conversation.update!(assignee: agent) if call.conversation.assignee_id.blank?
# Claim an unheld conversation and set call_status in one save so previous_changes carries both the
# assignee change (activity message + ASSIGNEE_CHANGED) and the call_status change (conversation.updated webhook).
def claim_conversation_and_set_call_status
conversation = call.conversation
attrs = { additional_attributes: (conversation.additional_attributes || {}).merge('call_status' => call.display_status) }
attrs[:assignee] = agent if conversation.assignee_id.blank?
conversation.update!(attrs)
end
# Raise on Meta failure (bool false or transport error) so callers bail before
@@ -72,6 +72,12 @@ class Whatsapp::IncomingCallService
end
def create_inbound_call(payload)
unless inbox.channel.inbound_calls_enabled?
Rails.logger.info "[WHATSAPP CALL] Inbound calls disabled for inbox #{inbox.id}; rejecting call #{payload[:id]}"
inbox.channel.provider_service.reject_call(payload[:id])
return
end
sdp_offer = payload.dig(:session, :sdp)
extra_meta = { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
name = caller_profile_name(payload)
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "4.14.1",
"version": "4.14.2",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -34,7 +34,7 @@
"@amplitude/analytics-browser": "^2.11.10",
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.3.19",
"@chatwoot/prosemirror-schema": "1.3.22",
"@chatwoot/utils": "^0.0.55",
"@formkit/core": "^1.7.2",
"@formkit/vue": "^1.7.2",
+5 -5
View File
@@ -25,8 +25,8 @@ importers:
specifier: 1.2.3
version: 1.2.3
'@chatwoot/prosemirror-schema':
specifier: 1.3.19
version: 1.3.19
specifier: 1.3.22
version: 1.3.22
'@chatwoot/utils':
specifier: ^0.0.55
version: 0.0.55
@@ -458,8 +458,8 @@ packages:
'@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
'@chatwoot/prosemirror-schema@1.3.19':
resolution: {integrity: sha512-LbATIAeTzclvbIK6WjtrGUO37AtMWkzCJi+s/KpUIp81TORhc0fHnclGT3353AbxeH6dF51/4hFTTsmF+ziqGA==}
'@chatwoot/prosemirror-schema@1.3.22':
resolution: {integrity: sha512-0r+PT8xhQLCKCpoV9k9XVTTRECs/0Nr37wbcLsRS7yvc7WkF9FY05z2hGCRJReWmTOcmmshHtb042LVP+MyB/w==}
'@chatwoot/utils@0.0.55':
resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==}
@@ -5128,7 +5128,7 @@ snapshots:
hotkeys-js: 3.8.7
lit: 2.2.6
'@chatwoot/prosemirror-schema@1.3.19':
'@chatwoot/prosemirror-schema@1.3.22':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.7.1
@@ -39,6 +39,21 @@ describe ContactInboxWithContactBuilder do
expect(contact_inbox.inbox_id).to eq(inbox.id)
end
it 'truncates long contact names before creating the contact' do
long_name = 'a' * 300
contact_inbox = described_class.new(
source_id: '123456',
inbox: inbox,
contact_attributes: {
name: long_name,
email: 'testemail@example.com'
}
).perform
expect(contact_inbox.contact.name).to eq(long_name.first(ApplicationRecord::MAX_STRING_COLUMN_LENGTH))
end
it 'doesnot create contact if it already exist with identifier' do
contact_inbox = described_class.new(
source_id: '123456',
+29
View File
@@ -0,0 +1,29 @@
require 'rails_helper'
# rubocop:disable RSpec/DescribeClass
describe 'Session Store Configuration' do
# rubocop:enable RSpec/DescribeClass
let(:session_options) { Rails.application.config.session_options }
it 'uses cookie_store as the session store' do
expect(Rails.application.config.session_store).to eq(ActionDispatch::Session::CookieStore)
end
it 'sets the session key' do
expect(session_options[:key]).to eq('_chatwoot_session')
end
it 'sets same_site to lax' do
expect(session_options[:same_site]).to eq(:lax)
end
it 'sets httponly to true' do
expect(session_options[:httponly]).to be(true)
end
it 'sets secure flag based on FORCE_SSL' do
expected_secure = ActiveModel::Type::Boolean.new.cast(ENV.fetch('FORCE_SSL', false))
expect(session_options[:secure]).to eq(expected_secure)
end
end
@@ -141,6 +141,7 @@ RSpec.describe 'Conversations API', type: :request do
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']).to eq(
'all_count' => 1,
'inboxes' => { visible_inbox.id.to_s => 1 },
'labels' => { label.id.to_s => 1 },
'teams' => {}
@@ -111,4 +111,40 @@ RSpec.describe 'Onboarding API', type: :request do
end
end
end
describe 'GET /api/v1/accounts/{account.id}/onboarding/help_center_generation' do
context 'when unauthenticated' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation", as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated as an agent (non-admin)' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation",
headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when no help center generation has started' do
it 'returns not_started with zero counts' do
get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation",
headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body).to include(
'generation_id' => nil,
'state' => nil,
'articles_count' => 0,
'categories_count' => 0
)
end
end
end
end
@@ -163,4 +163,21 @@ RSpec.describe DeviseOverrides::SessionsController, type: :controller do
expect(response).to redirect_to('/frontend/app/login?error=access-denied')
end
end
describe 'session tracking' do
let(:user) { create(:user, password: 'Test@123456') }
let(:browser_ua) { 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15' }
context 'with a successful login' do
before { request.env['HTTP_USER_AGENT'] = browser_ua }
it 'creates a UserSession row for the new client_id' do
expect { post :create, params: { email: user.email, password: 'Test@123456' } }.to change(user.user_sessions, :count).by(1)
session = user.user_sessions.last
expect(session.browser_name).to eq('Safari')
expect(session.platform_name).to eq('macOS')
end
end
end
end
@@ -122,8 +122,8 @@ RSpec.describe SamlUserBuilder do
it 'does not add the user to the target account' do
expect do
builder.perform
rescue SamlUserBuilder::AuthenticationFailed
nil
rescue StandardError => e
raise unless e.class.name == 'SamlUserBuilder::AuthenticationFailed' # rubocop:disable Style/ClassEqualityComparison
end.not_to change(AccountUser, :count)
expect(existing_user.reload.accounts).not_to include(account)
end
@@ -131,8 +131,8 @@ RSpec.describe SamlUserBuilder do
it 'does not convert the user provider to saml' do
expect do
builder.perform
rescue SamlUserBuilder::AuthenticationFailed
nil
rescue StandardError => e
raise unless e.class.name == 'SamlUserBuilder::AuthenticationFailed' # rubocop:disable Style/ClassEqualityComparison
end.not_to(change { existing_user.reload.provider })
end
end
@@ -0,0 +1,42 @@
require 'rails_helper'
RSpec.describe 'Enterprise Onboarding API', type: :request do
let(:account) { create(:account, domain: 'example.com') }
let(:admin) { create(:user, account: account, role: :administrator) }
describe 'GET /api/v1/accounts/{account.id}/onboarding/help_center_generation' do
context 'when help center generation is in progress' do
let(:generation_id) { 'generation-123' }
let!(:portal) { create(:portal, account_id: account.id) }
let!(:category) { create(:category, portal: portal, account_id: account.id) }
before do
account.update!(custom_attributes: { 'help_center_generation_id' => generation_id })
create(:article, portal: portal, category: category, account_id: account.id, author_id: admin.id)
Onboarding::HelpCenterGenerationState.start(generation_id, total: 3)
Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
end
after do
Redis::Alfred.delete(Onboarding::HelpCenterGenerationState.key(generation_id))
end
it 'returns Redis state and help center counts' do
get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation",
headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body).to include(
'generation_id' => generation_id,
'articles_count' => 1,
'categories_count' => 1
)
expect(response.parsed_body['state']).to include(
'status' => 'generating',
'finished' => '1',
'total' => '3'
)
end
end
end
end
@@ -49,6 +49,59 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
end
end
describe 'POST /api/v1/accounts/{account.id}/inboxes/:id/set_inbound_calls' do
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
end
context 'when administrator' do
it 'disables inbound calls on a Twilio voice inbox' do
channel = create(:channel_twilio_sms, :with_voice, account: account)
post "/api/v1/accounts/#{account.id}/inboxes/#{channel.inbox.id}/set_inbound_calls",
headers: admin.create_new_auth_token,
params: { inbound_calls_enabled: false },
as: :json
expect(response).to have_http_status(:ok)
expect(channel.reload.inbound_calls_enabled?).to be false
end
it 'enables inbound calls on a WhatsApp inbox without re-validating provider config' do
account.enable_features('channel_voice')
account.save!
channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
validate_provider_config: false, sync_templates: false)
channel.update!(provider_config: channel.provider_config.merge('calling_enabled' => true, 'inbound_calls_enabled' => false))
post "/api/v1/accounts/#{account.id}/inboxes/#{channel.inbox.id}/set_inbound_calls",
headers: admin.create_new_auth_token,
params: { inbound_calls_enabled: true },
as: :json
expect(response).to have_http_status(:ok)
expect(channel.reload.inbound_calls_enabled?).to be true
end
end
context 'when agent' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'is forbidden' do
channel = create(:channel_twilio_sms, :with_voice, account: account)
post "/api/v1/accounts/#{account.id}/inboxes/#{channel.inbox.id}/set_inbound_calls",
headers: agent.create_new_auth_token,
params: { inbound_calls_enabled: false },
as: :json
expect(response).to have_http_status(:unauthorized)
expect(channel.reload.inbound_calls_enabled?).to be true
end
end
end
describe 'PATCH /api/v1/accounts/{account.id}/inboxes/:id' do
let(:inbox) { create(:inbox, account: account, auto_assignment_config: { max_assignment_limit: 5 }) }
@@ -112,6 +112,23 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
}
expect(response).to have_http_status(:not_found)
end
it 'rejects the inbound contact leg without building a call when inbound calls are disabled' do
channel.update!(provider_config: { 'inbound_calls_enabled' => false })
expect(Voice::InboundCallBuilder).not_to receive(:perform!)
expect do
post "/twilio/voice/call/#{digits}", params: {
'CallSid' => call_sid,
'From' => from_number,
'To' => to_number,
'Direction' => 'inbound'
}
end.not_to change(Call, :count)
expect(response).to have_http_status(:ok)
expect(response.body).to include('<Reject')
end
end
describe 'POST /twilio/voice/status/:phone' do
@@ -53,11 +53,9 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
admin.id,
generation_id,
hash_including(
'article' => hash_including(
'title' => 'Hello',
'urls' => ['https://x.test/a'],
'category_id' => portal.categories.first.id
)
'title' => 'Hello',
'urls' => ['https://x.test/a'],
'category_id' => portal.categories.first.id
)
)
)
@@ -83,7 +81,7 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
writer_jobs = enqueued_jobs.select { |job| job['job_class'] == Onboarding::HelpCenterArticleWriterJob.name }
expect(writer_jobs.size).to eq(1)
expect(writer_jobs.first['arguments']).to include(
hash_including('article' => hash_including('title' => 'Valid'))
hash_including('title' => 'Valid')
)
end
end
@@ -106,7 +104,7 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
writer_jobs = enqueued_jobs.select { |job| job['job_class'] == Onboarding::HelpCenterArticleWriterJob.name }
expect(writer_jobs.size).to eq(1)
expect(writer_jobs.first['arguments']).to include(
hash_including('article' => hash_including('title' => 'Approved', 'urls' => ['https://x.test/a']))
hash_including('title' => 'Approved', 'urls' => ['https://x.test/a'])
)
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('total' => '1')
end
@@ -170,19 +168,4 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
expect(state['skip_reason']).to include('firecrawl exhausted')
end
end
describe 'broadcasts' do
it 'broadcasts generation_completed with status: skipped on CurationSkipped' do
curator = instance_double(Onboarding::HelpCenterCurator)
allow(curator).to receive(:perform).and_raise(
Onboarding::HelpCenterErrors::CurationSkipped, 'no website url'
)
allow(Onboarding::HelpCenterCurator).to receive(:new).and_return(curator)
payload = hash_including(generation_id: generation_id, status: 'skipped', skip_reason: 'no website url')
expect { described_class.perform_now(*job_args) }
.to have_enqueued_job(ActionCableBroadcastJob)
.with([admin.pubsub_token], 'help_center.generation_completed', payload)
end
end
end
@@ -6,8 +6,7 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
let!(:admin) { create(:user, account: account, role: :administrator) }
let(:generation_id) { 'generation-123' }
let(:article_spec) { { 'urls' => ['https://x.test/a'], 'title' => 'A', 'category_id' => nil } }
let(:article_payload) { { 'article' => article_spec } }
let(:job_args) { [account.id, portal.id, admin.id, generation_id, article_payload] }
let(:job_args) { [account.id, portal.id, admin.id, generation_id, article_spec] }
let(:state_key) { Onboarding::HelpCenterGenerationState.key(generation_id) }
before do
@@ -68,16 +67,14 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('finished' => '1')
end
it 'broadcasts completion when the final writer fails with ArticleBuildFailed' do
it 'marks generation completed when the final writer fails with ArticleBuildFailed' do
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
)
Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
payload = hash_including(generation_id: generation_id, status: 'completed')
expect { described_class.perform_now(*job_args) }
.to have_enqueued_job(ActionCableBroadcastJob)
.with([admin.pubsub_token], 'help_center.generation_completed', payload)
described_class.perform_now(*job_args)
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
'status' => 'completed', 'finished' => '2'
)
@@ -105,7 +102,7 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
end
end
describe 'broadcasts' do
describe 'missing state' do
let(:built_article) { instance_double(Article, id: 9876) }
before do
@@ -113,47 +110,10 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_return(builder)
end
it 'broadcasts help_center.article_generated on success' do
payload = hash_including(generation_id: generation_id, article_id: 9876, articles_finished: 1)
expect { described_class.perform_now(*job_args) }
.to have_enqueued_job(ActionCableBroadcastJob)
.with([admin.pubsub_token], 'help_center.article_generated', payload)
end
it 'broadcasts help_center.generation_completed when the last writer finishes' do
described_class.perform_now(*job_args)
payload = hash_including(generation_id: generation_id, status: 'completed')
expect { described_class.perform_now(*job_args) }
.to have_enqueued_job(ActionCableBroadcastJob)
.with([admin.pubsub_token], 'help_center.generation_completed', payload)
end
it 'does not broadcast article_generated on builder failure' do
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
)
expect { described_class.perform_now(*job_args) }
.not_to have_enqueued_job(ActionCableBroadcastJob)
.with(anything, 'help_center.article_generated', anything)
end
it 'broadcasts generation_completed on late retries past total' do
described_class.perform_now(*job_args)
described_class.perform_now(*job_args)
clear_enqueued_jobs
expect { described_class.perform_now(*job_args) }
.to have_enqueued_job(ActionCableBroadcastJob)
.with([admin.pubsub_token], 'help_center.generation_completed', hash_including(generation_id: generation_id))
end
it 'skips progress broadcasts when state is missing' do
it 'does not raise when state is missing' do
Redis::Alfred.delete(state_key)
expect { described_class.perform_now(*job_args) }
.not_to have_enqueued_job(ActionCableBroadcastJob)
expect { described_class.perform_now(*job_args) }.not_to raise_error
end
end
end
@@ -38,6 +38,19 @@ RSpec.describe Channel::TwilioSms do
end
end
describe '#inbound_calls_enabled?' do
it 'returns true by default when nothing has been toggled' do
channel = create(:channel_twilio_sms, :with_voice, account: account)
expect(channel.inbound_calls_enabled?).to be true
end
it 'returns false only when explicitly disabled in provider_config' do
channel = create(:channel_twilio_sms, :with_voice, account: account,
provider_config: { 'inbound_calls_enabled' => false })
expect(channel.inbound_calls_enabled?).to be false
end
end
describe '#voice_call_webhook_url' do
it 'returns the webhook URL based on phone number' do
channel = create(:channel_twilio_sms, :with_voice)
@@ -26,6 +26,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: agent).perform
expect(result[:all_count]).to eq(2)
expect(result[:inboxes]).to eq(inbox.id.to_s => 2)
expect(result[:labels]).to eq(label.id.to_s => 2)
expect(result[:teams]).to eq(team.id.to_s => 2)
@@ -40,6 +41,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: agent).perform
expect(result[:all_count]).to eq(2)
expect(result[:inboxes]).to eq(inbox.id.to_s => 2)
expect(result[:labels]).to eq(label.id.to_s => 2)
expect(result[:teams]).to eq(team.id.to_s => 2)
@@ -53,6 +55,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: agent).perform
expect(result[:all_count]).to eq(1)
expect(result[:inboxes]).to eq(inbox.id.to_s => 1)
expect(result[:labels]).to eq(label.id.to_s => 1)
expect(result[:teams]).to eq(team.id.to_s => 1)
@@ -65,7 +68,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: agent).perform
expect(result).to eq(inboxes: {}, labels: {}, teams: {})
expect(result).to eq(all_count: 0, inboxes: {}, labels: {}, teams: {})
expect(store.base_ready?(account.id)).to be(false)
expect(store.assignment_ready?(account.id)).to be(false)
end

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