Compare commits

...
Author SHA1 Message Date
Muhsin 949a728977 fix(inboxes): stop disabled continuity mail sends 2026-07-22 21:21:42 +04:00
Muhsin 2437da5339 fix(inboxes): keep disabled read receipts accurate 2026-07-22 18:20:54 +04:00
Muhsin 2abb7759f2 fix(inboxes): preserve disabled inbox status updates 2026-07-22 17:53:33 +04:00
Muhsin KelothandGitHub ca1069241f Merge branch 'develop' into codex/cw-4998-disable-inbox 2026-07-22 17:39:15 +04:00
Muhsin a744b1e247 fix(sms): allow disabled inbox delivery callbacks 2026-07-22 17:20:37 +04:00
887897ea98 fix: lock agent quota checks (#15029)
# Pull Request Template

## Description

Locks the agent quota check to the account row while creating account
users. This fixes a race where concurrent agent-create requests could
all observe the same remaining seat before any `account_users` row was
inserted.

The API continues to return the existing `402 Account limit exceeded.
Please purchase more licenses` response when the limit is reached. Bulk
create now preflights the requested email count while holding the
account lock, then creates each agent through the same locked builder
path. The Enterprise custom-role hook now no-ops when create did not
produce an agent.

Fixes:
[CW-7039](https://linear.app/chatwoot/issue/CW-7039/race-condition-in-agent-creation-bypasses-plan-agent-seat-limit)

## Type of change

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

## How Has This Been Tested?

- `POSTGRES_DATABASE=chatwoot_test_c20f_agent_quota REDIS_DB=9 bundle
exec rspec spec/builders/agent_builder_spec.rb
spec/enterprise/builders/agent_builder_spec.rb
spec/controllers/api/v1/accounts/agents_controller_spec.rb
spec/enterprise/controllers/api/v1/accounts/agents_controller_spec.rb
spec/enterprise/controllers/enterprise/api/v1/accounts/agents_controller_spec.rb`
- `bundle exec rubocop app/builders/agent_builder.rb
app/controllers/api/v1/accounts/agents_controller.rb
enterprise/app/controllers/enterprise/api/v1/accounts/agents_controller.rb
spec/builders/agent_builder_spec.rb
spec/enterprise/controllers/api/v1/accounts/agents_controller_spec.rb`
- `git diff --check`
- One-off threaded Rails validation with 8 concurrent `AgentBuilder`
calls against an account with one remaining seat: `created: 1`,
`limited: 7`, final `count=2`, `limit=2`.

## 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-07-22 18:43:00 +05:30
Muhsin KelothandGitHub 559a052e42 Merge branch 'develop' into codex/cw-4998-disable-inbox 2026-07-22 16:57:13 +04:00
Muhsin f855429d9a fix(inboxes): block disabled inbox side effects 2026-07-22 16:51:49 +04:00
8aee518149 fix(integrations): restrict Linear/Notion/Shopify hook deletion to admins (#15126)
Non-admin agents could delete an account's Linear, Notion, or Shopify
integration through the dedicated integration endpoints, which — unlike
the generic hooks endpoint — never checked the caller's role. This
restores the intended admin-only boundary for removing an integration.

## Closes
- https://linear.app/chatwoot/issue/CW-7383
- https://linear.app/chatwoot/issue/CW-7384
- https://linear.app/chatwoot/issue/CW-7189

## How to reproduce
As a non-admin **agent**, `DELETE
/api/v1/accounts/:id/integrations/{linear,notion,shopify}` returned
`200` and removed the account-wide integration. After this change it
returns `401` and the integration is preserved; administrators can still
remove it.

## What changed
- Route integration-hook deletion through `HookPolicy` (admin-only) via
a shared `Integrations::BaseController`, matching the generic hooks
controller.

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-07-22 17:53:25 +05:30
Muhsin 97b30127a6 fix(inboxes): block disabled widget config and inbound calls 2026-07-22 16:20:41 +04:00
Vishnu NarayananandGitHub 5733b822e3 fix: guard widget email-transcript button against repeat clicks (#15095)
## Description

The widget email-transcript button (`ChatFooter.vue`) had no client-side
guard. Its visibility depends only on whether the contact has an email,
and the click handler fired a request on every click with no in-flight
lock, no disabled state, and no post-send handling. A user could
therefore trigger a large number of duplicate transcript emails from a
single conversation just by clicking repeatedly.

This adds a re-entry guard in the handler, disables the button while a
send is in flight, and applies a short cooldown (15s) after a successful
send. Normal use is unaffected: the button sends once, shows the success
toast, then briefly disables and automatically re-enables so a genuine
later re-request still works. On failure the button stays enabled so the
user can retry immediately. The cooldown timer is cleared on unmount.

Using a timed cooldown (rather than a permanent post-send lock) also
avoids the button getting stuck disabled if a resolved conversation is
reopened and later re-resolved.

This is the client-side complement to the server-side rate limit added
in #15085.

Fixes https://linear.app/chatwoot/issue/CW-7640
2026-07-22 17:36:36 +05:30
Muhsin 3a7c418139 fix(inboxes): block disabled inbox campaign and widget mutations 2026-07-22 15:39:17 +04:00
Muhsin f2e3e5a83f fix(inboxes): allow telegram edits for disabled inboxes 2026-07-22 15:11:21 +04:00
Muhsin 23fac0e7b1 feat(inboxes): show disabled badge on inbox names 2026-07-22 15:08:15 +04:00
Muhsin 02ff710709 fix(inboxes): address disabled inbox review feedback 2026-07-22 14:57:57 +04:00
Muhsin KelothandGitHub 3f9864c74f Merge branch 'develop' into codex/cw-4998-disable-inbox 2026-07-22 14:14:06 +04:00
Sivin VargheseandGitHub 1e52d23d7a fix: guard agent sort against null names in assignment dropdown (#15125) 2026-07-22 15:27:04 +05:30
Sivin VargheseandGitHub fbb3479263 fix: guard agent sort against null names in assignment dropdown (#15125)
# Pull Request Template

## Description

This PR fixes a crash where opening a conversation threw `TypeError:
Cannot read properties of null (reading 'localeCompare')` and prevented
the agent assignment dropdown from rendering.

Since #14866, agent bots are included in the assignable agents list.
`AgentBot#name` is not presence-validated, so system bots (account-less,
global) can have a `null` name. Those nameless bots flowed into
name-based operations that assumed a string, causing crashes and
warnings across multiple surfaces:

* **Assignment dropdown sort:** `getAgentsByAvailability` called
`a.name.localeCompare(b.name)`, causing a `localeCompare` `TypeError`.
* **Dropdown search:** `MultiselectDropdownItems` called
`option.name.toLowerCase()`, causing a `toLowerCase` `TypeError`.
* **Agent Bots settings:** `Avatar` received `name=null` for a `String`
prop, triggering a Vue prop validation warning.

### What changed

* Keep nameless agent bots in the assignment dropdown and render a `-`
fallback label in `useAgentsList`. These are still valid,
assignable-by-ID records: the assignable agents API includes accessible
bots, and `Conversations::AssignmentService` assigns them by ID.
Preserving them avoids hiding valid assignment targets. Bots are still
included only when `includeAgentBots` is enabled.
* Make the sort in `getAgentsByAvailability` null-safe by coercing
missing names to an empty string (defense in depth).
* Make the search filter in `MultiselectDropdownItems` null-safe
(defense in depth).
* Pass a null-safe `name` prop to `Avatar` in the Agent Bots settings
list to eliminate the Vue prop validation warning.

Fixes
https://linear.app/chatwoot/issue/CW-7670/agent-assignment-dropdown-crashes-with-cannot-read-properties-of-null

## Type of change

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

## How Has This Been Tested?


1. Have a system agent bot (`name: null`) that is assignable to an
inbox.
2. Open any conversation in that inbox.
   * The agent assignment dropdown renders without console errors.
   * The nameless bot is listed with a `-` label and can be assigned.
3. Go to **Settings → Agent Bots**.
   * The page renders without the `Avatar` prop validation warning.


## 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
2026-07-22 15:23:19 +05:30
166a41c31c fix(whatsapp): prevent invalid automation sends outside reply window (#15113)
WhatsApp automations now fail locally when they attempt to send a
free-form message after the 24-hour customer service window has closed.
This avoids sending an invalid template request to Meta and gives users
a clear, actionable error instead of “Template not found or invalid
template name.”

Template messages continue to be sent whenever template parameters are
present. Free-form messages continue to be sent normally while the
conversation is replyable.

Fixes
https://linear.app/chatwoot/issue/PLA-183/prevent-whatsapp-automations-outside-the-24-hour-window-from-producing

### How to reproduce

1. Create a WhatsApp automation that sends a message without template
parameters.
2. Trigger it on a conversation whose 24-hour customer service window is
closed.
3. Observe that the message previously reached the template send path
and failed with a misleading provider error.

### How to test

1. Trigger an automation with template parameters and confirm it sends
as a template message.
2. Trigger an automation without template parameters inside the 24-hour
window and confirm it sends as a free-form message.
3. Trigger an automation without template parameters outside the 24-hour
window and confirm it fails locally with a clear error and makes no
request to Meta.

### Things to know

This changes only the invalid closed-window, no-template path. Existing
template and in-window message behavior remains unchanged.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-22 12:40:28 +04:00
e65e18e9c5 fix: normalize phone numbers during whatsapp channel lookup (#13709)
Fixes
https://linear.app/chatwoot/issue/PLA-99/whatsapp-messages-dropped-for-brazilargentina-numbers-due-to-phone
Fixes https://github.com/chatwoot/chatwoot/issues/14492

Meta's WhatsApp Cloud API includes `display_phone_number` in webhook
payloads, but its format can differ from the number stored in Chatwoot's
channel record.
In Brazil, Meta omits the mobile 9 prefix. For example, it sends
55419XXXXXXX (12 digits) instead of 554199XXXXXXX (13 digits). In
Argentina, Meta adds an extra 9 after the country code. For example, it
sends 549XXXXXXXXXX instead of 54XXXXXXXXXX.
The whatsapp event job uses `display_phone_number` for an exact-match
channel lookup. When the formats do not match, the lookup returns nil
and the incoming message is silently dropped, logging:

`Inactive WhatsApp channel: unknown - <phone_number>.`

The fix extends `get_channel_from_wb_payload` to fall back to normalized
phone number matching using the existing PhoneNumberNormalizationService
normalizers (Brazil, Argentina), which were previously only used for
contact-level lookups.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-22 10:33:40 +04:00
Shivam MishraandGitHub 89b83c65c8 fix: close message generation popover when its trigger scrolls away (#15114) 2026-07-21 19:34:57 +05:30
Muhsin KelothandGitHub 0b9b137723 Merge branch 'develop' into codex/cw-4998-disable-inbox 2026-07-15 14:20:46 +04:00
copilot-swe-agent[bot]andGitHub 42e392eca4 Merge branch 'develop' into codex/cw-4998-disable-inbox 2026-07-08 11:52:28 +00:00
Muhsin KelothandMuhsin e668f9da20 Merge branch 'develop' into codex/cw-4998-disable-inbox 2026-07-03 13:50:14 +04:00
Muhsin KelothandGitHub 65a78c7c20 Merge branch 'develop' into codex/cw-4998-disable-inbox 2026-06-25 10:10:44 +04:00
Muhsin 86d0955794 Update inbox_disabled.rb 2026-06-22 14:39:32 +04:00
Muhsin KelothandGitHub d720e013d4 Merge branch 'develop' into codex/cw-4998-disable-inbox 2026-06-22 13:41:51 +04:00
Muhsin 2f52af6e2d Update inbox.rb 2026-06-22 13:40:22 +04:00
Muhsin KelothandGitHub d59415ff5d Merge branch 'develop' into codex/cw-4998-disable-inbox 2026-06-22 13:09:18 +04:00
Muhsin KelothandGitHub 14e4ec81c6 Merge branch 'develop' into codex/cw-4998-disable-inbox 2026-06-09 21:07:29 +04:00
Muhsin cbd24dbdb2 feat(inboxes): add inbox disable toggle 2026-06-09 21:07:05 +04:00
111 changed files with 1291 additions and 123 deletions
+19 -3
View File
@@ -2,6 +2,14 @@
# It initializes with necessary attributes and provides a perform method
# to create a user and account user in a transaction.
class AgentBuilder
LIMIT_EXCEEDED_MESSAGE = 'Account limit exceeded. Please purchase more licenses'.freeze
class LimitExceededError < StandardError
def initialize
super(AgentBuilder::LIMIT_EXCEEDED_MESSAGE)
end
end
# Initializes an AgentBuilder with necessary attributes.
# @param email [String] the email of the user.
# @param name [String] the name of the user.
@@ -14,15 +22,23 @@ class AgentBuilder
# Creates a user and account user in a transaction.
# @return [User] the created user.
def perform
ActiveRecord::Base.transaction do
@user = find_or_create_user
create_account_user
account.with_lock do
raise LimitExceededError unless can_add_agent?
ActiveRecord::Base.transaction do
@user = find_or_create_user
create_account_user
end
end
@user
end
private
def can_add_agent?
account.usage_limits[:agents] > account.account_users.count
end
# Finds a user by email or creates a new one with a temporary password.
# @return [User] the found or created user.
def find_or_create_user
@@ -3,6 +3,8 @@ class Campaigns::CampaignConversationBuilder
def perform
@contact_inbox = ContactInbox.find(@contact_inbox_id)
return unless @contact_inbox.inbox.active?
@campaign = @contact_inbox.inbox.campaigns.find_by!(display_id: campaign_display_id)
ActiveRecord::Base.transaction do
+2
View File
@@ -2,6 +2,8 @@ class ConversationBuilder
pattr_initialize [:params!, :contact_inbox!]
def perform
raise CustomExceptions::InboxDisabled unless @contact_inbox.inbox.active?
look_up_exising_conversation || create_new_conversation
end
@@ -20,6 +20,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
def perform
# This channel might require reauthorization, may be owner might have changed the fb password
return if @inbox.channel.reauthorization_required?
return unless @inbox.active?
ActiveRecord::Base.transaction do
build_contact_inbox
@@ -10,6 +10,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
def perform
return if @inbox.channel.reauthorization_required?
return unless @inbox.active?
ActiveRecord::Base.transaction do
build_message
+3 -2
View File
@@ -22,14 +22,15 @@ class Messages::MessageBuilder
end
def perform
raise CustomExceptions::InboxDisabled unless @conversation.inbox.active?
@message = @conversation.messages.build(message_params)
process_attachments
process_emails
# When the message has no quoted content, it will just be rendered as a regular message
# The frontend is equipped to handle this case
process_email_content
@message.save!
@message
@message.tap(&:save!)
end
private
@@ -1,8 +1,6 @@
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
before_action :fetch_agent, except: [:create, :index, :bulk_create]
before_action :check_authorization
before_action :validate_limit, only: [:create]
before_action :validate_limit_for_bulk_create, only: [:bulk_create]
def index
@agents = agents
@@ -20,6 +18,8 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
)
@agent = builder.perform
rescue AgentBuilder::LimitExceededError => e
render_payment_required(e.message)
end
def update
@@ -36,25 +36,13 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
def bulk_create
emails = params[:emails]
emails.each do |email|
builder = AgentBuilder.new(
email: email,
name: email.split('@').first,
inviter: current_user,
account: Current.account
)
begin
builder.perform
rescue ActiveRecord::RecordInvalid => e
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
end
end
bulk_create_agents(emails)
# This endpoint is used to bulk create agents during onboarding
# onboarding_step key in present in Current account custom attributes, since this is a one time operation
Current.account.custom_attributes.delete('onboarding_step')
Current.account.save!
clear_onboarding_step
head :ok
rescue AgentBuilder::LimitExceededError => e
render_payment_required(e.message)
end
private
@@ -87,22 +75,33 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
end
def validate_limit_for_bulk_create
limit_available = params[:emails].count <= available_agent_count
def bulk_create_agents(emails)
Current.account.with_lock do
raise AgentBuilder::LimitExceededError if emails.count > available_agent_count
render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available
emails.each { |email| create_agent_from_email(email) }
end
end
def validate_limit
render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent?
def create_agent_from_email(email)
builder = AgentBuilder.new(
email: email,
name: email.split('@').first,
inviter: current_user,
account: Current.account
)
builder.perform
rescue ActiveRecord::RecordInvalid => e
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
end
def clear_onboarding_step
Current.account.custom_attributes.delete('onboarding_step')
Current.account.save!
end
def available_agent_count
Current.account.usage_limits[:agents] - agents.count
end
def can_add_agent?
available_agent_count.positive?
Current.account.usage_limits[:agents] - Current.account.account_users.count
end
def delete_user_record(agent)
@@ -1,5 +1,6 @@
class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::Conversations::BaseController
before_action :ensure_api_inbox, only: :update
before_action :ensure_inbox_active, only: [:create, :retry]
def index
@messages = message_finder.perform
@@ -9,6 +10,8 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
user = Current.user || @resource
mb = Messages::MessageBuilder.new(user, @conversation, params)
@message = mb.perform
rescue CustomExceptions::InboxDisabled
render_inbox_disabled_error
rescue StandardError => e
render_could_not_create_error(e.message)
end
@@ -32,6 +35,8 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
service.perform
message.update!(content_attributes: {})
::SendReplyJob.perform_later(message.id)
rescue CustomExceptions::InboxDisabled
render_inbox_disabled_error
rescue StandardError => e
render_could_not_create_error(e.message)
end
@@ -80,4 +85,8 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
# Only API inboxes can update messages
render json: { error: 'Message status update is only allowed for API inboxes' }, status: :forbidden unless @conversation.inbox.api?
end
def ensure_inbox_active
render_inbox_disabled_error unless @conversation.inbox.active?
end
end
@@ -198,6 +198,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@inbox = Current.account.inboxes.find(params[:inbox_id])
authorize @inbox, :show?
render_inbox_disabled_error unless @inbox.active?
end
def contact
@@ -214,6 +215,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# and deprecate the support of passing only source_id as the param
@contact_inbox ||= ::ContactInbox.find_by!(source_id: params[:source_id])
authorize @contact_inbox.inbox, :show?
render_inbox_disabled_error unless @contact_inbox.inbox.active?
rescue ActiveRecord::RecordNotUnique
render json: { error: 'source_id should be unique' }, status: :unprocessable_entity
end
@@ -221,12 +223,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def build_contact_inbox
return if @inbox.blank? || @contact.blank?
ContactInboxBuilder.new(
contact: @contact,
inbox: @inbox,
source_id: params[:source_id],
hmac_verified: hmac_verified?
).perform
ContactInboxBuilder.new(contact: @contact, inbox: @inbox, source_id: params[:source_id], hmac_verified: hmac_verified?).perform
end
def conversation_finder
@@ -193,7 +193,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def normalized_branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
def inbox_attributes
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
[:name, :avatar, :active, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
{ csat_config: [:display_type, :message, :button_text, :language,
@@ -0,0 +1,9 @@
class Api::V1::Accounts::Integrations::BaseController < Api::V1::Accounts::BaseController
private
# Managing an integration hook (create/update/destroy) is admin-only, enforced via HookPolicy.
# Subclasses opt in per action with `before_action :check_authorization, only: [...]`.
def check_authorization
authorize(:hook)
end
end
@@ -1,4 +1,4 @@
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Integrations::BaseController
before_action :fetch_hook, except: [:create]
before_action :check_authorization
@@ -35,10 +35,6 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
@hook = Current.account.hooks.find(params[:id])
end
def check_authorization
authorize(:hook)
end
def permitted_params
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
end
@@ -1,6 +1,7 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Integrations::BaseController
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
before_action :fetch_hook, only: [:destroy]
before_action :check_authorization, only: [:destroy]
def destroy
revoke_linear_token
@@ -1,5 +1,6 @@
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Integrations::BaseController
before_action :fetch_hook, only: [:destroy]
before_action :check_authorization, only: [:destroy]
def destroy
@hook.destroy!
@@ -1,7 +1,8 @@
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Integrations::BaseController
include Shopify::IntegrationHelper
before_action :setup_shopify_context, only: [:orders]
before_action :fetch_hook, except: [:auth]
before_action :check_authorization, only: [:destroy]
before_action :validate_contact, only: [:orders]
def auth
@@ -90,4 +90,8 @@ class Api::V1::Widget::BaseController < ApplicationController
message_type: :incoming
}
end
def ensure_inbox_active
render_inbox_disabled_error unless @web_widget.inbox.active?
end
end
@@ -1,4 +1,5 @@
class Api::V1::Widget::ConfigsController < Api::V1::Widget::BaseController
before_action :ensure_inbox_active
before_action :set_global_config
def create
@@ -1,6 +1,7 @@
class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
include WidgetHelper
before_action :ensure_inbox_active, only: [:update, :set_user, :destroy_custom_attributes]
before_action :validate_hmac, only: [:set_user]
before_action :validate_hmac_for_identified_update, only: [:update]
@@ -1,6 +1,12 @@
class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
include Events::Types
DISABLED_INBOX_ACTIONS = [
:create, :toggle_typing, :toggle_status, :set_custom_attributes, :destroy_custom_attributes, :transcript
].freeze
before_action :render_not_found_if_empty, only: [:toggle_typing, :toggle_status, :set_custom_attributes, :destroy_custom_attributes]
before_action :ensure_inbox_active, only: DISABLED_INBOX_ACTIONS
def index
@conversation = conversation
@@ -1,6 +1,7 @@
class Api::V1::Widget::DirectUploadsController < ActiveStorage::DirectUploadsController
include WebsiteTokenHelper
before_action :set_web_widget
before_action :ensure_inbox_active
before_action :set_contact
def create
@@ -8,4 +9,15 @@ class Api::V1::Widget::DirectUploadsController < ActiveStorage::DirectUploadsCon
super
end
private
def ensure_inbox_active
return if @web_widget.inbox.active?
render json: {
error: 'inbox_disabled',
message: 'This inbox is currently disabled'
}, status: :forbidden
end
end
@@ -1,6 +1,8 @@
class Api::V1::Widget::EventsController < Api::V1::Widget::BaseController
include Events::Types
before_action :ensure_inbox_active
def create
Rails.configuration.dispatcher.dispatch(permitted_params[:name], Time.zone.now, contact_inbox: @contact_inbox,
event_info: permitted_params[:event_info].to_h.merge(event_info))
@@ -1,4 +1,5 @@
class Api::V1::Widget::Integrations::DyteController < Api::V1::Widget::BaseController
before_action :ensure_inbox_active
before_action :set_message
def add_participant_to_meeting
@@ -1,4 +1,6 @@
class Api::V1::Widget::LabelsController < Api::V1::Widget::BaseController
before_action :ensure_inbox_active
def create
if conversation.present? && label_defined_in_account?
conversation.label_list.add(permitted_params[:label])
@@ -1,4 +1,5 @@
class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
before_action :ensure_inbox_active, only: [:create, :update]
before_action :set_conversation, only: [:create]
before_action :set_message, only: [:update]
@@ -9,6 +9,7 @@ module RequestExceptionHandler
included do
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
rescue_from CustomExceptions::InboxDisabled, with: :render_inbox_disabled_error
rescue_from CustomExceptions::Inbox::LimitExceeded, with: :render_error_response
end
@@ -45,6 +46,13 @@ module RequestExceptionHandler
render json: { error: sanitized_error_message(error) }, status: :unprocessable_entity
end
def render_inbox_disabled_error(_exception = nil)
render json: {
error: 'inbox_disabled',
message: 'This inbox is currently disabled'
}, status: :forbidden
end
def render_payment_required(message)
render json: { error: message }, status: :payment_required
end
@@ -0,0 +1,34 @@
class Platform::Api::V1::InboxesController < PlatformController
before_action :set_account
before_action :validate_account_permissible
before_action :set_inbox
def disable
update_active_state(false)
end
def enable
update_active_state(true)
end
private
def set_account
@account = Account.find(params[:account_id])
end
def validate_account_permissible
return if @platform_app.platform_app_permissibles.find_by(permissible: @account)
render json: { error: 'Non permissible resource' }, status: :unauthorized
end
def set_inbox
@inbox = @account.inboxes.find(params[:id])
end
def update_active_state(active)
@inbox.update!(active: active)
render json: { success: true, inbox_id: @inbox.id, active: @inbox.active }
end
end
@@ -1,5 +1,6 @@
class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesController
before_action :contact_inbox, except: [:create]
before_action :ensure_inbox_active, only: [:create, :update]
before_action :process_hmac
def show; end
@@ -50,4 +51,8 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
def permitted_params
params.permit(:identifier, :identifier_hash, :email, :name, :avatar_url, :phone_number, custom_attributes: {})
end
def ensure_inbox_active
render_inbox_disabled_error unless @inbox_channel.inbox.active?
end
end
@@ -1,6 +1,7 @@
class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::InboxesController
include Events::Types
before_action :set_conversation, only: [:toggle_typing, :update_last_seen, :show, :toggle_status]
before_action :ensure_inbox_active, only: [:create, :toggle_typing, :toggle_status]
def index
@conversations = @contact_inbox.hmac_verified? ? @contact_inbox.contact.conversations : @contact_inbox.conversations
@@ -64,4 +65,8 @@ class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::Inbox
def conversation_params
params.permit(custom_attributes: {})
end
def ensure_inbox_active
render_inbox_disabled_error unless @contact_inbox.inbox.active?
end
end
@@ -1,5 +1,6 @@
class Public::Api::V1::Inboxes::MessagesController < Public::Api::V1::InboxesController
before_action :set_message, only: [:update]
before_action :ensure_inbox_active, only: [:create, :update]
def index
@messages = @conversation.nil? ? [] : message_finder.perform
@@ -70,4 +71,8 @@ class Public::Api::V1::Inboxes::MessagesController < Public::Api::V1::InboxesCon
def check_csat_locked
(Time.zone.now.to_date - @message.created_at.to_date).to_i > 14 and @message.content_type == 'input_csat'
end
def ensure_inbox_active
render_inbox_disabled_error unless @conversation.inbox.active?
end
end
@@ -47,18 +47,10 @@ class Webhooks::WhatsappController < ActionController::API
metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata)
return if metadata.blank?
phone_number = normalized_phone_number(metadata[:display_phone_number])
phone_number_id = metadata[:phone_number_id]
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
end
def normalized_phone_number(phone_number)
return if phone_number.blank?
phone_number = phone_number.to_s
phone_number.start_with?('+') ? phone_number : "+#{phone_number}"
Whatsapp::WebhookChannelFinderService.new(
display_phone_number: metadata[:display_phone_number],
phone_number_id: metadata[:phone_number_id]
).perform
end
def inactive_whatsapp_number?
+5
View File
@@ -5,6 +5,7 @@ class WidgetsController < ActionController::Base
before_action :set_global_config
before_action :set_web_widget
before_action :ensure_account_is_active
before_action :ensure_inbox_active
before_action :ensure_location_is_supported
before_action :set_token
before_action :set_contact
@@ -62,6 +63,10 @@ class WidgetsController < ActionController::Base
render json: { error: 'Account is suspended' }, status: :unauthorized unless @web_widget.inbox.account.active?
end
def ensure_inbox_active
head :not_found unless @web_widget.inbox.active?
end
def ensure_location_is_supported; end
def additional_attributes
@@ -1,4 +1,5 @@
<script setup>
import { useI18n } from 'vue-i18n';
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
defineProps({
@@ -7,13 +8,21 @@ defineProps({
default: () => {},
},
});
const { t } = useI18n();
</script>
<template>
<div :title="inbox.name" class="flex items-center gap-0.5 min-w-0">
<div :title="inbox.name" class="flex items-center gap-1 min-w-0">
<ChannelIcon :inbox="inbox" class="size-4 flex-shrink-0 text-n-slate-11" />
<span class="truncate text-body-main text-n-slate-11">
{{ inbox.name }}
</span>
<span
v-if="inbox.active === false"
class="inline-flex h-4 shrink-0 items-center rounded-md bg-n-alpha-2 px-1 text-label-mini font-medium text-n-slate-11"
>
{{ t('INBOX_MGMT.DISABLED') }}
</span>
</div>
</template>
@@ -234,6 +234,7 @@ onMounted(() => resetContacts());
ref="popoverRef"
:align="align"
:show-content-border="false"
:close-on-scroll="false"
@show="onPopoverShow"
@hide="onPopoverHide"
>
@@ -1,7 +1,11 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { vOnClickOutside } from '@vueuse/components';
import { useBreakpoints, breakpointsTailwind } from '@vueuse/core';
import {
useBreakpoints,
breakpointsTailwind,
useEventListener,
} from '@vueuse/core';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
@@ -16,6 +20,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
closeOnScroll: {
type: Boolean,
default: true,
},
showContentBorder: {
type: Boolean,
default: true,
@@ -41,8 +49,12 @@ const { fixedPosition, updatePosition } = useDropdownPosition(
{ align: props.align }
);
const SCROLL_CLOSE_THRESHOLD = 24;
const triggerTopAtOpen = ref(0);
const show = async () => {
isActive.value = true;
triggerTopAtOpen.value = triggerRef.value?.getBoundingClientRect().top ?? 0;
if (!isMobile.value) {
await nextTick();
updatePosition();
@@ -56,6 +68,22 @@ const hide = () => {
emit('hide');
};
// The teleported popover tracks its trigger while ancestors scroll; allow
// small drift (trackpad inertia), but close once the trigger moves further.
useEventListener(
window,
'scroll',
event => {
if (!props.closeOnScroll || !showPopover.value) return;
if (popoverRef.value?.contains(event.target)) return;
const top = triggerRef.value?.getBoundingClientRect().top ?? 0;
if (Math.abs(top - triggerTopAtOpen.value) > SCROLL_CLOSE_THRESHOLD) {
hide();
}
},
{ capture: true, passive: true }
);
const toggle = async () => {
if (isActive.value) hide();
else await show();
@@ -1,4 +1,5 @@
<script setup>
import { useI18n } from 'vue-i18n';
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
defineProps({
@@ -7,13 +8,21 @@ defineProps({
default: () => {},
},
});
const { t } = useI18n();
</script>
<template>
<div :title="inbox.name" class="flex items-center gap-0.5 min-w-0">
<div :title="inbox.name" class="flex items-center gap-1 min-w-0">
<ChannelIcon :inbox="inbox" class="size-4 flex-shrink-0 text-n-slate-11" />
<span class="truncate text-label-small text-n-slate-11">
{{ inbox.name }}
</span>
<span
v-if="inbox.active === false"
class="inline-flex h-4 shrink-0 items-center rounded-md bg-n-alpha-2 px-1 text-label-mini font-medium text-n-slate-11"
>
{{ t('INBOX_MGMT.DISABLED') }}
</span>
</div>
</template>
@@ -1,9 +1,9 @@
import { ref } from 'vue';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAgentsList } from '../useAgentsList';
import { useMapGetter } from 'dashboard/composables/store';
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
import * as agentHelper from 'dashboard/helper/agentHelper';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ref } from 'vue';
import { useAgentsList } from '../useAgentsList';
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
// Mock vue-i18n
vi.mock('vue-i18n', () => ({
@@ -94,6 +94,32 @@ describe('useAgentsList', () => {
expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length);
});
it('keeps nameless agent bots and applies a fallback label', () => {
const namelessBot = {
id: 91,
name: null,
assignee_type: 'AgentBot',
availability_status: 'offline',
};
mockUseMapGetter({
'inboxAssignableAgents/getAssignableAgents': ref(() => [
...allAgentsData,
namelessBot,
]),
});
const { agentsList } = useAgentsList();
// access the computed to trigger evaluation
expect(agentsList.value).toBeDefined();
const passedAgents =
agentHelper.getAgentsByUpdatedPresence.mock.calls[0][0];
expect(passedAgents).toContainEqual({
...namelessBot,
name: '-',
});
});
it('handles empty assignable agents', () => {
mockUseMapGetter({
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
@@ -1,10 +1,10 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import {
getAgentsByUpdatedPresence,
getSortedAgentsByAvailability,
} from 'dashboard/helper/agentHelper';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
/**
* A composable function that provides a list of agents for assignment.
@@ -53,7 +53,11 @@ export function useAgentsList(
* @type {import('vue').ComputedRef<Array>}
*/
const agentsList = computed(() => {
const agents = assignableAgents.value || [];
const agents = (assignableAgents.value || []).map(agent =>
!agent.name && agent.assignee_type === 'AgentBot'
? { ...agent, name: '-' }
: agent
);
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
agents,
currentUser.value,
@@ -7,7 +7,7 @@
export const getAgentsByAvailability = (agents, availability) => {
return agents
.filter(agent => agent.availability_status === availability)
.sort((a, b) => a.name.localeCompare(b.name));
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
};
/**
@@ -26,6 +26,18 @@ describe('agentHelper', () => {
offlineAgentsData
);
});
it('does not throw when an agent has a null name', () => {
const agents = [
{ id: 1, name: null, availability_status: 'offline' },
{ id: 2, name: 'Zoe', availability_status: 'offline' },
];
expect(() => getAgentsByAvailability(agents, 'offline')).not.toThrow();
expect(
getAgentsByAvailability(agents, 'offline').map(agent => agent.id)
).toEqual([1, 2]);
});
});
describe('getSortedAgentsByAvailability', () => {
@@ -4,6 +4,7 @@
"DESCRIPTION": "A channel is the mode of communication your customer chooses to interact with you. An inbox is where you manage interactions for a specific channel. It can include communications from various sources such as email, live chat, and social media.",
"LEARN_MORE": "Learn more about inboxes",
"COUNT": "{n} inbox | {n} inboxes",
"DISABLED": "Disabled",
"SEARCH_PLACEHOLDER": "Search inboxes...",
"NO_RESULTS": "No inboxes found matching your search",
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
@@ -135,7 +135,7 @@ onMounted(() => {
<BaseTableCell class="max-w-0">
<div class="flex items-center gap-4 min-w-0">
<Avatar
:name="bot.name"
:name="bot.name || ''"
:src="bot.thumbnail"
:size="40"
class="flex-shrink-0"
@@ -142,10 +142,20 @@ const openDelete = inbox => {
>
<ChannelIcon class="size-6 text-n-slate-10" :inbox="inbox" />
</div>
<div class="flex flex-col items-start gap-1">
<span class="block text-heading-3 text-n-slate-12 capitalize">
{{ inbox.name }}
</span>
<div class="flex flex-col items-start gap-1 min-w-0">
<div class="flex items-center gap-1.5 min-w-0">
<span
class="block text-heading-3 text-n-slate-12 capitalize truncate"
>
{{ inbox.name }}
</span>
<span
v-if="inbox.active === false"
class="inline-flex h-5 shrink-0 items-center rounded-md bg-n-alpha-2 px-1.5 text-label-mini font-medium text-n-slate-11"
>
{{ $t('INBOX_MGMT.DISABLED') }}
</span>
</div>
<ChannelName
:channel-type="inbox.channel_type"
:medium="inbox.medium"
@@ -68,6 +68,10 @@ const isAgentBot = computed(
() => props.selectedItem?.assignee_type === 'AgentBot'
);
const selectedItemName = computed(() =>
!props.selectedItem?.name && isAgentBot.value ? '-' : props.selectedItem?.name
);
const selectedThumbnail = computed(
() => props.selectedItem?.thumbnail || props.selectedItem?.avatar_url
);
@@ -95,16 +99,16 @@ const selectedThumbnail = computed(
<h4
v-else
class="items-center overflow-hidden text-sm leading-tight whitespace-nowrap text-ellipsis text-n-slate-12"
:title="selectedItem.name"
:title="selectedItemName"
>
{{ selectedItem.name }}
{{ selectedItemName }}
</h4>
</div>
<Avatar
v-if="hasValue && hasThumbnail && (isAgentBot || !hasIcon)"
:src="selectedThumbnail"
:status="selectedItem.availability_status"
:name="selectedItem.name"
:name="selectedItemName"
:icon-name="isAgentBot ? 'i-lucide-bot' : undefined"
:size="24"
hide-offline-status
@@ -53,7 +53,9 @@ export default {
computed: {
filteredOptions() {
return this.options.filter(option => {
return option.name.toLowerCase().includes(this.search.toLowerCase());
return (option.name || '')
.toLowerCase()
.includes(this.search.toLowerCase());
});
},
noResult() {
+37 -12
View File
@@ -11,6 +11,8 @@ import { IFrameHelper } from '../helpers/utils';
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
import { emitter } from 'shared/helpers/mitt';
const TRANSCRIPT_COOLDOWN_MS = 15000;
export default {
components: {
ChatInputWrap,
@@ -24,6 +26,9 @@ export default {
data() {
return {
inReplyTo: null,
isSendingTranscript: false,
transcriptCooldown: false,
transcriptCooldownTimer: null,
};
},
computed: {
@@ -57,6 +62,9 @@ export default {
mounted() {
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.toggleReplyTo);
},
beforeUnmount() {
clearTimeout(this.transcriptCooldownTimer);
},
methods: {
...mapActions('conversation', ['sendMessage', 'sendAttachment']),
...mapActions('conversationAttributes', ['getAttributes']),
@@ -90,19 +98,35 @@ export default {
toggleReplyTo(message) {
this.inReplyTo = message;
},
startTranscriptCooldown() {
this.transcriptCooldown = true;
clearTimeout(this.transcriptCooldownTimer);
this.transcriptCooldownTimer = setTimeout(() => {
this.transcriptCooldown = false;
}, TRANSCRIPT_COOLDOWN_MS);
},
async sendTranscript() {
if (this.hasEmail) {
try {
await sendEmailTranscript();
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'),
type: 'success',
});
} catch (error) {
emitter.$emit(BUS_EVENTS.SHOW_ALERT, {
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'),
});
}
if (
!this.hasEmail ||
this.isSendingTranscript ||
this.transcriptCooldown
) {
return;
}
this.isSendingTranscript = true;
try {
await sendEmailTranscript();
this.startTranscriptCooldown();
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'),
type: 'success',
});
} catch (error) {
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'),
});
} finally {
this.isSendingTranscript = false;
}
},
},
@@ -144,6 +168,7 @@ export default {
v-if="showEmailTranscriptButton"
type="clear"
class="font-normal"
:disabled="isSendingTranscript || transcriptCooldown"
@click="sendTranscript"
>
{{ $t('EMAIL_TRANSCRIPT.BUTTON_TEXT') }}
+1
View File
@@ -4,6 +4,7 @@ class ConversationReplyEmailJob < ApplicationJob
def perform(conversation_id, last_queued_id)
conversation = Conversation.find(conversation_id)
return unless conversation.account.active?
return unless conversation.inbox.active?
if conversation.messages.incoming&.last&.content_type == 'incoming_email'
ConversationReplyMailer.with(account: conversation.account).reply_without_summary(conversation, last_queued_id).deliver_later
@@ -13,6 +13,7 @@ class Inboxes::FetchImapEmailInboxesJob < ApplicationJob
def should_fetch_emails?(inbox)
return false if inbox.account.suspended?
return false unless inbox.active?
return false unless inbox.channel.imap_enabled
return false if inbox.channel.reauthorization_required?
+1 -1
View File
@@ -25,7 +25,7 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
private
def should_fetch_email?(channel)
channel.imap_enabled? && !channel.reauthorization_required?
channel.inbox.active? && channel.imap_enabled? && !channel.reauthorization_required?
end
def process_email_for_channel(channel, interval)
+58
View File
@@ -1,6 +1,8 @@
class SendReplyJob < ApplicationJob
queue_as :high
INBOX_DISABLED_ERROR = 'This inbox is currently disabled'.freeze
CHANNEL_SERVICES = {
'Channel::TwitterProfile' => ::Twitter::SendOnTwitterService,
'Channel::TwilioSms' => ::Twilio::SendOnTwilioService,
@@ -18,6 +20,7 @@ class SendReplyJob < ApplicationJob
def perform(message_id)
message = Message.find(message_id)
channel_name = message.conversation.inbox.channel.class.to_s
return handle_disabled_inbox(message, channel_name) unless message.inbox.active?
return send_on_facebook_page(message) if channel_name == 'Channel::FacebookPage'
@@ -29,6 +32,61 @@ class SendReplyJob < ApplicationJob
private
def handle_disabled_inbox(message, channel_name)
return unless send_service_available?(channel_name)
return unless deliverable_reply?(message, channel_name)
mark_message_failed(message)
end
def send_service_available?(channel_name)
channel_name == 'Channel::FacebookPage' || CHANNEL_SERVICES.key?(channel_name)
end
def deliverable_reply?(message, channel_name)
return false unless deliverable_message?(message)
return message.email_notifiable_message? if email_channel?(channel_name)
return email_notification_deliverable?(message, channel_name) if email_notification_channel?(channel_name)
true
end
def deliverable_message?(message)
return false if message.private?
return false unless message.outgoing? || message.template?
return false if message.source_id.present?
return false if message.content_type == 'voice_call'
true
end
def email_channel?(channel_name)
channel_name == 'Channel::Email'
end
def email_notification_channel?(channel_name)
%w[Channel::WebWidget Channel::Api].include?(channel_name)
end
def email_notification_deliverable?(message, channel_name)
return false unless message.email_notifiable_message?
return false if message.conversation.contact.email.blank?
return false unless message.account.within_email_rate_limit?
email_notification_enabled?(message.inbox, channel_name)
end
def email_notification_enabled?(inbox, channel_name)
return inbox.channel.continuity_via_email if channel_name == 'Channel::WebWidget'
return inbox.account.feature_enabled?('email_continuity_on_api_channel') if channel_name == 'Channel::Api'
false
end
def mark_message_failed(message)
Messages::StatusUpdateService.new(message, 'failed', INBOX_DISABLED_ERROR).perform
end
def send_on_facebook_page(message)
if message.conversation.additional_attributes['type'] == 'instagram_direct_message'
::Instagram::Messenger::SendOnInstagramService.new(message: message).perform
+6 -4
View File
@@ -57,9 +57,11 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
next if channel.blank?
if (event_name = event_name(messaging))
send(event_name, messaging, channel)
end
event_name = event_name(messaging)
next if event_name.blank?
next unless channel.inbox.active? || event_name == :read
send(event_name, messaging, channel)
end
end
@@ -126,7 +128,7 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
end
def event_name(messaging)
@event_name ||= SUPPORTED_EVENTS.find { |key| messaging.key?(key) }
SUPPORTED_EVENTS.find { |key| messaging.key?(key) }
end
def message(messaging, channel)
+1
View File
@@ -4,6 +4,7 @@ class Webhooks::LineEventsJob < ApplicationJob
def perform(params: {}, signature: '', post_body: '')
@params = params
return unless valid_event_payload?
return unless @channel.inbox.active?
return unless valid_post_body?(post_body, signature)
Line::IncomingMessageService.new(inbox: @channel.inbox, params: @params['line'].with_indifferent_access).perform
+5
View File
@@ -8,6 +8,7 @@ class Webhooks::SmsEventsJob < ApplicationJob
channel = Channel::Sms.find_by(phone_number: params[:to])
return unless channel
return if incoming_event?(params) && !channel.inbox.active?
process_event_params(channel, params)
end
@@ -25,4 +26,8 @@ class Webhooks::SmsEventsJob < ApplicationJob
def delivery_event?(params)
params[:type] == 'message-delivered' || params[:type] == 'message-failed'
end
def incoming_event?(params)
params[:type] == 'message-received'
end
end
+7 -2
View File
@@ -6,7 +6,7 @@ class Webhooks::TelegramEventsJob < ApplicationJob
channel = Channel::Telegram.find_by(bot_token: params[:bot_token])
if channel_is_inactive?(channel)
if channel_is_inactive?(channel, params)
log_inactive_channel(channel, params)
return
end
@@ -16,13 +16,18 @@ class Webhooks::TelegramEventsJob < ApplicationJob
private
def channel_is_inactive?(channel)
def channel_is_inactive?(channel, params)
return true if channel.blank?
return true unless channel.account.active?
return true unless channel.inbox.active? || update_message_event?(params)
false
end
def update_message_event?(params)
params.dig(:telegram, :edited_message).present? || params.dig(:telegram, :edited_business_message).present?
end
def log_inactive_channel(channel, params)
message = if channel&.id
"Account #{channel.account.id} is not active for channel #{channel.id}"
+1
View File
@@ -21,6 +21,7 @@ class Webhooks::TiktokEventsJob < MutexApplicationJob
def channel_is_inactive?
return true if channel.blank?
return true unless channel.account.active?
return true unless channel.inbox.active? || event_name == 'im_mark_read_msg'
false
end
+21 -7
View File
@@ -8,7 +8,7 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
def perform(params = {})
channel = find_channel_from_whatsapp_business_payload(params)
if channel_is_inactive?(channel)
if channel_is_inactive?(channel, params)
Rails.logger.warn("Inactive WhatsApp channel: #{channel&.phone_number || "unknown - #{params[:phone_number]}"}")
return
end
@@ -124,15 +124,29 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
].compact_blank.first
end
def channel_is_inactive?(channel)
def channel_is_inactive?(channel, params)
return true if channel.blank?
# Only skip for embedded signup when reauth is required; manual flow uses API keys and should still receive webhooks
return true if channel.reauthorization_required? && embedded_signup_channel?(channel)
return true unless channel.account.active?
return true unless channel.inbox.active? || existing_message_update_event?(params)
false
end
def status_update_event?(params)
value = params.dig(:entry, 0, :changes, 0, :value) || params
value[:statuses].present?
end
def existing_message_update_event?(params)
status_update_event?(params) || call_event?(params)
end
def call_event?(params)
params.dig(:entry, 0, :changes, 0, :field) == 'calls'
end
def embedded_signup_channel?(channel)
(channel.provider_config || {}).to_h['source'] == 'embedded_signup'
end
@@ -153,11 +167,11 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
end
def get_channel_from_wb_payload(wb_params)
phone_number = "+#{wb_params[:entry].first[:changes].first.dig(:value, :metadata, :display_phone_number)}"
phone_number_id = wb_params[:entry].first[:changes].first.dig(:value, :metadata, :phone_number_id)
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
# validate to ensure the phone number id matches the whatsapp channel
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
metadata = wb_params[:entry].first[:changes].first.dig(:value, :metadata) || {}
Whatsapp::WebhookChannelFinderService.new(
display_phone_number: metadata[:display_phone_number],
phone_number_id: metadata[:phone_number_id]
).perform
end
end
+2
View File
@@ -63,6 +63,8 @@ class AgentBotListener < BaseListener
private
def agent_bots_for(inbox, conversation = nil)
return [] unless inbox.active?
bots = []
bots << conversation.assignee_agent_bot if conversation&.assignee_agent_bot.present?
inbox_bot = active_inbox_agent_bot(inbox)
+8 -2
View File
@@ -18,6 +18,7 @@ class AutomationRuleListener < BaseListener
def message_created(event)
message = event.data[:message]
return unless message.inbox.active?
return if ignore_message_created_event?(event)
account = message.try(:account)
@@ -39,10 +40,11 @@ class AutomationRuleListener < BaseListener
def process_conversation_event(event, event_name)
return if performed_by_automation?(event)
auto_reply_skip_events = %w[conversation_created conversation_opened]
return if auto_reply_skip_events.include?(event_name) && ignore_auto_reply_event?(event)
return if auto_reply_event?(event, event_name)
conversation = event.data[:conversation]
return unless conversation.inbox.active?
account = conversation.account
changed_attributes = event.data[:changed_attributes]
@@ -74,6 +76,10 @@ class AutomationRuleListener < BaseListener
event.data[:performed_by].present? && event.data[:performed_by].instance_of?(AutomationRule)
end
def auto_reply_event?(event, event_name)
%w[conversation_created conversation_opened].include?(event_name) && ignore_auto_reply_event?(event)
end
def ignore_auto_reply_event?(event)
conversation = event.data[:conversation]
conversation.additional_attributes['auto_reply'].present?
+1
View File
@@ -3,6 +3,7 @@ class CsatSurveyListener < BaseListener
conversation = extract_conversation_and_account(event)[0]
return unless conversation.resolved?
return unless conversation.inbox.active?
CsatSurveyService.new(conversation: conversation).perform
end
+1
View File
@@ -6,6 +6,7 @@ class ReplyMailbox < ApplicationMailbox
def process
# Return early if no conversation was found (e.g., notification emails, suspended accounts)
return unless @conversation
return unless @conversation.inbox.active?
# Wrap everything in a transaction to ensure atomicity
# This prevents orphan conversations if message/attachment creation fails
+1
View File
@@ -58,6 +58,7 @@ class Campaign < ApplicationRecord
def trigger!
return unless one_off?
return unless feature_enabled?
return unless inbox.active?
return unless mark_processing!
execute_campaign
@@ -7,6 +7,8 @@ class Line::IncomingMessageService
LINE_STICKER_IMAGE_URL = 'https://stickershop.line-scdn.net/stickershop/v1/sticker/%s/android/sticker.png'.freeze
def perform
return unless @inbox.active?
# probably test events
return if params[:events].blank?
@@ -22,6 +22,7 @@ class Mailbox::ConversationFinderStrategies::NewConversationStrategy < Mailbox::
# The actual persistence happens in ReplyMailbox within a transaction that includes message creation.
def find
return nil unless @channel # No valid channel found
return nil unless @inbox.active?
return nil unless incoming_email_from_valid_email? # Skip edge cases
# Check if conversation already exists by in_reply_to
@@ -4,6 +4,8 @@ class Sms::IncomingMessageService
pattr_initialize [:inbox!, :params!]
def perform
return unless @inbox.active?
set_contact
set_conversation
@message = @conversation.messages.create!(
@@ -7,6 +7,8 @@ class Telegram::IncomingMessageService
pattr_initialize [:inbox!, :params!]
def perform
return unless @inbox.active?
# chatwoot doesn't support group conversations at the moment
transform_business_message!
return unless private_message?
+2
View File
@@ -4,6 +4,8 @@ class Tiktok::MessageService
pattr_initialize [:channel!, :content!, :outgoing_echo]
def perform
return unless channel.inbox.active?
if outgoing_message?
message = find_message(tt_conversation_id, tt_message_id)
return if message.present?
@@ -7,6 +7,7 @@ class Twilio::IncomingMessageService
def perform
return if twilio_channel.blank?
return unless inbox.active?
set_contact
set_conversation
@@ -5,6 +5,8 @@ class Twitter::DirectMessageParserService < Twitter::WebhooksBaseService
return if source_app_id == parent_app_id
set_inbox
return unless @inbox.active?
ensure_contacts
set_conversation
@message = @conversation.messages.create!(
@@ -4,6 +4,7 @@ class Twitter::TweetParserService < Twitter::WebhooksBaseService
def perform
set_inbox
return unless @inbox.active?
return if !tweets_enabled? || message_already_exist? || user_has_blocked?
create_message
@@ -13,6 +13,8 @@ class Whatsapp::IncomingMessageBaseService
if processed_params.try(:[], :statuses).present?
process_statuses
elsif messages_data.present?
return unless @inbox.active?
process_messages
end
end
@@ -6,12 +6,10 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
end
def perform_reply
should_send_template_message = template_params.present? || !message.conversation.can_reply?
if should_send_template_message
send_template_message
else
send_session_message
end
return send_template_message if template_params.present?
return send_session_message if message.conversation.can_reply?
message.update!(status: :failed, external_error: I18n.t('errors.whatsapp.message_outside_messaging_window'))
end
def send_template_message
@@ -0,0 +1,35 @@
# Resolves the WhatsApp channel for an inbound WhatsApp Cloud webhook. Meta's
# display_phone_number can arrive formatted or in a country-specific variant (e.g. Brazil
# omits the mobile 9, Argentina adds a digit after the country code), so we try the
# raw digits first and then a normalized fallback, accepting only a candidate whose
# phone_number_id matches.
class Whatsapp::WebhookChannelFinderService
def initialize(display_phone_number:, phone_number_id:)
@display_phone_number = display_phone_number
@phone_number_id = phone_number_id
end
def perform
return if digits.blank?
candidates = [
Channel::Whatsapp.find_by(phone_number: "+#{digits}"),
channel_by_normalized_number
]
candidates.compact.find { |channel| channel.provider_config['phone_number_id'] == @phone_number_id }
end
private
def digits
@digits ||= @display_phone_number.to_s.gsub(/[^0-9]/, '')
end
def channel_by_normalized_number
normalizer = Whatsapp::PhoneNumberNormalizationService::NORMALIZERS
.lazy.map(&:new).find { |n| n.handles_country?(digits) }
return unless normalizer
Channel::Whatsapp.find_by(phone_number: "+#{normalizer.normalize(digits)}")
end
end
@@ -2,6 +2,7 @@ json.id resource.id
json.avatar_url resource.try(:avatar_url)
json.channel_id resource.channel_id
json.name resource.name
json.active resource.active
json.channel_type resource.channel_type
json.greeting_enabled resource.greeting_enabled
json.greeting_message resource.greeting_message
+1
View File
@@ -154,6 +154,7 @@ en:
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
message_outside_messaging_window: 'Message not sent because the WhatsApp 24-hour customer service window is closed and no template parameters were provided. Send an approved template message instead.'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
+6
View File
@@ -581,6 +581,12 @@ Rails.application.routes.draw do
end
end
resources :email_channel_migrations, only: [:create]
resources :inboxes, only: [] do
member do
post :disable
post :enable
end
end
end
end
end
@@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddActiveToInboxes < ActiveRecord::Migration[7.0]
def change
add_column :inboxes, :active, :boolean, default: true, null: false
end
end
+1
View File
@@ -1048,6 +1048,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
t.boolean "enable_email_collect", default: true
t.boolean "csat_survey_enabled", default: false
t.boolean "allow_messages_after_resolved", default: true
t.boolean "active", default: true, null: false
t.jsonb "auto_assignment_config", default: {}
t.boolean "lock_to_single_conversation", default: false, null: false
t.bigint "portal_id"
@@ -1,6 +1,7 @@
class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseController
before_action :set_call, only: %i[show accept reject terminate upload_recording]
before_action :set_call_context, only: :initiate
before_action :ensure_inbox_active, only: :initiate
before_action :ensure_calling_enabled, only: :initiate
before_action :ensure_sdp_offer, only: :initiate
before_action :ensure_contact_phone, only: :initiate
@@ -96,6 +97,10 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
render_could_not_create_error(I18n.t('errors.whatsapp.calls.not_enabled'))
end
def ensure_inbox_active
render_inbox_disabled_error unless @inbox.active?
end
def ensure_sdp_offer
return if params[:sdp_offer].present?
@@ -1,6 +1,8 @@
module Enterprise::Api::V1::Accounts::AgentsController
def create
super
return if @agent.blank?
associate_agent_with_custom_role
end
@@ -90,10 +90,10 @@ class Twilio::VoiceController < ApplicationController
from_number.start_with?('client:')
end
# A fresh contact-initiated leg on an inbox with inbound calls turned off.
# A fresh contact-initiated leg on an inbox that cannot receive calls.
# 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?
twilio_direction == 'inbound' && !agent_leg?(twilio_from) && (!inbox.active? || !inbox.channel.inbound_calls_enabled?)
end
def reject_twiml
@@ -16,6 +16,7 @@ class Voice::OutboundCallBuilder
def perform!
raise ArgumentError, 'Contact phone number required' if contact.phone_number.blank?
raise ArgumentError, 'Agent required' if user.blank?
raise CustomExceptions::InboxDisabled unless inbox.active?
# Claim for the caller if a reused conversation is unassigned at trigger time; wins over auto-assignment.
# New conversations set the assignee at creation instead (see create_conversation!).
@@ -75,7 +75,7 @@ class Whatsapp::IncomingCallService
end
def create_inbound_call(payload)
unless inbox.channel.inbound_calls_enabled?
unless inbox.active? && 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
+19
View File
@@ -0,0 +1,19 @@
# frozen_string_literal: true
class CustomExceptions::InboxDisabled < CustomExceptions::Base
def initialize(data = {})
super
end
def message
'This inbox is currently disabled'
end
def error_code
'inbox_disabled'
end
def http_status
:forbidden
end
end
+18
View File
@@ -23,6 +23,12 @@ RSpec.describe AgentBuilder, type: :model do
end
describe '#perform' do
it 'locks the account while checking and creating the agent' do
expect(account).to receive(:with_lock).and_call_original
agent_builder.perform
end
context 'when user does not exist' do
it 'creates a new user' do
expect { agent_builder.perform }.to change(User, :count).by(1)
@@ -67,5 +73,17 @@ RSpec.describe AgentBuilder, type: :model do
expect(user.encrypted_password).not_to be_empty
end
end
context 'when the account has reached its agent limit' do
before do
allow(account).to receive(:usage_limits).and_return({ agents: account.account_users.count })
end
it 'raises a limit exceeded error without creating a user' do
expect { agent_builder.perform }.to raise_error(described_class::LimitExceededError, described_class::LIMIT_EXCEEDED_MESSAGE)
expect(User.from_email(email)).to be_nil
end
end
end
end
@@ -13,7 +13,9 @@ RSpec.describe 'Linear Integration API', type: :request do
end
describe 'DELETE /api/v1/accounts/:account_id/integrations/linear' do
it 'deletes the linear integration' do
let(:admin) { create(:user, account: account, role: :administrator) }
it 'deletes the linear integration when the user is an administrator' do
# Stub the HTTP call to Linear's revoke endpoint
allow(HTTParty).to receive(:post).with(
'https://api.linear.app/oauth/revoke',
@@ -21,11 +23,19 @@ RSpec.describe 'Linear Integration API', type: :request do
).and_return(instance_double(HTTParty::Response, success?: true))
delete "/api/v1/accounts/#{account.id}/integrations/linear",
headers: agent.create_new_auth_token,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(account.hooks.count).to eq(0)
end
it 'returns unauthorized for an agent and keeps the integration' do
delete "/api/v1/accounts/#{account.id}/integrations/linear",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
expect(account.hooks.count).to eq(1)
end
end
describe 'GET /api/v1/accounts/:account_id/integrations/linear/teams' do
@@ -159,15 +159,17 @@ RSpec.describe 'Shopify Integration API', type: :request do
end
describe 'DELETE /api/v1/accounts/:account_id/integrations/shopify' do
let(:admin) { create(:user, account: account, role: :administrator) }
before do
create(:integrations_hook, :shopify, account: account)
end
context 'when it is an authenticated user' do
context 'when it is an administrator' do
it 'deletes the shopify integration' do
expect do
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
headers: agent.create_new_auth_token,
headers: admin.create_new_auth_token,
as: :json
end.to change { account.hooks.count }.by(-1)
@@ -175,6 +177,18 @@ RSpec.describe 'Shopify Integration API', type: :request do
end
end
context 'when it is an agent' do
it 'returns unauthorized and keeps the integration' do
expect do
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
headers: agent.create_new_auth_token,
as: :json
end.not_to(change { account.hooks.count })
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
@@ -31,6 +31,18 @@ RSpec.describe '/api/v1/widget/config', type: :request do
response_data = response.parsed_body
expect(response_data.keys).to include(*response_keys)
end
it 'does not initialize config or create a contact when the inbox is disabled' do
web_widget.inbox.update!(active: false)
expect do
post '/api/v1/widget/config',
params: params,
as: :json
end.not_to change(Contact, :count)
expect(response).to have_http_status(:forbidden)
end
end
context 'with correct website token and valid X-Auth-Token' do
@@ -48,6 +60,17 @@ RSpec.describe '/api/v1/widget/config', type: :request do
expect(response_data['contact']['pubsub_token']).to eq(contact_inbox.pubsub_token)
end
it 'does not initialize config when the inbox is disabled' do
web_widget.inbox.update!(active: false)
post '/api/v1/widget/config',
params: params,
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:forbidden)
end
it 'returns 401 if account is suspended' do
account.update!(status: :suspended)
@@ -37,6 +37,18 @@ RSpec.describe '/api/v1/widget/contacts', type: :request do
expect(ContactIdentifyAction).to have_received(:new).with(expected_params)
expect(identify_action).to have_received(:perform)
end
it 'does not update the contact when the inbox is disabled' do
web_widget.inbox.update!(active: false)
patch '/api/v1/widget/contact',
params: params,
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:forbidden)
expect(ContactIdentifyAction).not_to have_received(:new)
end
end
context 'with update contact' do
@@ -255,6 +267,18 @@ RSpec.describe '/api/v1/widget/contacts', type: :request do
expect(response).to have_http_status(:unauthorized)
end
it 'does not set user when the inbox is disabled' do
web_widget.inbox.update!(active: false)
patch '/api/v1/widget/contact/set_user',
params: params.merge(identifier_hash: correct_identifier_hash),
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:forbidden)
expect(ContactIdentifyAction).not_to have_received(:new)
end
end
end
@@ -263,19 +287,32 @@ RSpec.describe '/api/v1/widget/contacts', type: :request do
context 'with invalid website token' do
it 'returns unauthorized' do
post '/api/v1/widget/destroy_custom_attributes', params: { website_token: '' }
post '/api/v1/widget/contact/destroy_custom_attributes', params: { website_token: '' }
expect(response).to have_http_status(:not_found)
end
end
context 'with correct website token' do
it 'calls destroy custom attributes' do
post '/api/v1/widget/destroy_custom_attributes',
post '/api/v1/widget/contact/destroy_custom_attributes',
params: params,
headers: { 'X-Auth-Token' => token },
as: :json
expect(contact.reload.custom_attributes).to eq({})
end
it 'does not destroy custom attributes when the inbox is disabled' do
contact.update!(custom_attributes: { 'test' => 'value' })
web_widget.inbox.update!(active: false)
post '/api/v1/widget/contact/destroy_custom_attributes',
params: params,
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:forbidden)
expect(contact.reload.custom_attributes).to eq('test' => 'value')
end
end
end
end
@@ -43,6 +43,18 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do
expect(json_response['id']).to eq(conversation.display_id)
expect(json_response['status']).to eq(conversation.status)
end
it 'returns the conversation when the inbox is disabled' do
web_widget.inbox.update!(active: false)
get '/api/v1/widget/conversations',
headers: { 'X-Auth-Token' => token },
params: { website_token: web_widget.website_token },
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['id']).to eq(conversation.display_id)
end
end
context 'with a conversation but invalid source id' do
@@ -62,6 +74,19 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do
end
describe 'POST /api/v1/widget/conversations' do
it 'does not create a conversation when the inbox is disabled' do
web_widget.inbox.update!(active: false)
expect do
post '/api/v1/widget/conversations',
headers: { 'X-Auth-Token' => token },
params: conversation_params,
as: :json
end.not_to change(Conversation, :count)
expect(response).to have_http_status(:forbidden)
end
it 'creates a conversation with correct details' do
post '/api/v1/widget/conversations',
headers: { 'X-Auth-Token' => token },
@@ -244,11 +269,40 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do
expect(conversation.reload.contact_last_seen_at).not_to be_nil
end
it 'updates last seen when the inbox is disabled' do
current_time = DateTime.now.utc
allow(DateTime).to receive(:now).and_return(current_time)
web_widget.inbox.update!(active: false)
expect(Conversations::UpdateMessageStatusJob).to receive(:perform_later).with(conversation.id, current_time)
post '/api/v1/widget/conversations/update_last_seen',
headers: { 'X-Auth-Token' => token },
params: { website_token: web_widget.website_token },
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.contact_last_seen_at).not_to be_nil
end
end
end
describe 'POST /api/v1/widget/conversations/transcript' do
context 'with a conversation' do
it 'does not send transcript email when the inbox is disabled' do
web_widget.inbox.update!(active: false)
contact.update!(email: 'test@test.com')
expect(ConversationReplyMailer).not_to receive(:with)
post '/api/v1/widget/conversations/transcript',
headers: { 'X-Auth-Token' => token },
params: { website_token: web_widget.website_token },
as: :json
expect(response).to have_http_status(:forbidden)
end
it 'sends transcript email' do
contact.update(email: 'test@test.com')
mailer = double
@@ -34,6 +34,24 @@ RSpec.describe '/api/v1/widget/direct_uploads', type: :request do
json_response = response.parsed_body
expect(json_response['content_type']).to eq('image/png')
end
it 'does not create direct upload when the inbox is disabled' do
web_widget.inbox.update!(active: false)
post api_v1_widget_direct_uploads_url,
params: {
website_token: web_widget.website_token,
blob: {
filename: 'avatar.png',
byte_size: '1234',
checksum: 'dsjbsdhbfif3874823mnsdbf',
content_type: 'image/png'
}
},
headers: { 'X-Auth-Token' => token }
expect(response).to have_http_status(:forbidden)
end
end
end
end
@@ -34,6 +34,20 @@ RSpec.describe '/api/v1/widget/events', type: :request do
.with(params[:name], anything, contact_inbox: contact_inbox,
event_info: { test_id: 'test', browser_language: nil, widget_language: nil, browser: anything })
end
it 'does not dispatch events when the inbox is disabled' do
token
web_widget.inbox.update!(active: false)
expect(Rails.configuration.dispatcher).not_to receive(:dispatch)
post '/api/v1/widget/events',
params: params,
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:forbidden)
end
end
end
end
@@ -70,6 +70,17 @@ RSpec.describe '/api/v1/widget/integrations/dyte', type: :request do
}
)
end
it 'does not add a participant when the inbox is disabled' do
web_widget.inbox.update!(active: false)
post add_participant_to_meeting_api_v1_widget_integrations_dyte_url,
headers: { 'X-Auth-Token' => token },
params: { website_token: web_widget.website_token, message_id: integration_message.id },
as: :json
expect(response).to have_http_status(:forbidden)
end
end
end
end
@@ -39,6 +39,18 @@ RSpec.describe '/api/v1/widget/labels', type: :request do
expect(conversation.reload.label_list.count).to eq 1
expect(conversation.reload.label_list.first).to eq 'customer-support'
end
it 'does not add labels when the inbox is disabled' do
web_widget.inbox.update!(active: false)
post '/api/v1/widget/labels',
params: params,
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:forbidden)
expect(conversation.reload.label_list.count).to eq 0
end
end
context 'with invalid website token' do
@@ -67,6 +79,18 @@ RSpec.describe '/api/v1/widget/labels', type: :request do
expect(response).to have_http_status(:success)
expect(conversation.reload.label_list.count).to eq 0
end
it 'does not remove labels when the inbox is disabled' do
web_widget.inbox.update!(active: false)
delete "/api/v1/widget/labels/#{params[:label]}",
params: params,
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:forbidden)
expect(conversation.reload.label_list.count).to eq 1
end
end
context 'with invalid website token' do
@@ -28,6 +28,18 @@ RSpec.describe '/api/v1/widget/messages', type: :request do
expect(json_response['meta']).not_to be_empty
end
it 'returns messages when the inbox is disabled' do
web_widget.inbox.update!(active: false)
get api_v1_widget_messages_url,
params: { website_token: web_widget.website_token },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload'].length).to eq(4)
end
it 'returns empty messages', :skip_before do
get api_v1_widget_messages_url,
params: { website_token: web_widget.website_token },
@@ -43,6 +55,20 @@ RSpec.describe '/api/v1/widget/messages', type: :request do
describe 'POST /api/v1/widget/messages' do
context 'when post request is made' do
it 'does not create message in conversation when the inbox is disabled' do
web_widget.inbox.update!(active: false)
message_params = { content: 'hello world', timestamp: Time.current }
expect do
post api_v1_widget_messages_url,
params: { website_token: web_widget.website_token, message: message_params },
headers: { 'X-Auth-Token' => token },
as: :json
end.not_to change(Message, :count)
expect(response).to have_http_status(:forbidden)
end
it 'creates message in conversation' do
conversation.destroy! # Test all params
message_params = { content: 'hello world', timestamp: Time.current }
@@ -213,6 +239,21 @@ RSpec.describe '/api/v1/widget/messages', type: :request do
end
describe 'PUT /api/v1/widget/messages' do
context 'when the inbox is disabled' do
it 'does not update the message' do
message = create(:message, content_type: 'input_email', account: account, inbox: web_widget.inbox, conversation: conversation)
web_widget.inbox.update!(active: false)
put api_v1_widget_message_url(message.id),
params: { website_token: web_widget.website_token, contact: { email: Faker::Internet.email } },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:forbidden)
expect(message.reload.submitted_email).to be_nil
end
end
context 'when put request targets a message from another visitor in the same inbox' do
it 'does not update the foreign message' do
other_contact = create(:contact, account: account, email: nil)
@@ -139,5 +139,18 @@ RSpec.describe 'Public Inbox Contact Conversations API', type: :request do
expect(response).to have_http_status(:success)
expect(conversation.reload.contact_last_seen_at).not_to eq contact_last_seen_at
end
it 'updates the last seen when the inbox is disabled' do
current_time = DateTime.now.utc
allow(DateTime).to receive(:now).and_return(current_time)
api_channel.inbox.update!(active: false)
expect(Conversations::UpdateMessageStatusJob).to receive(:perform_later).with(conversation.id, current_time)
post update_last_seen_path
expect(response).to have_http_status(:success)
expect(conversation.reload.contact_last_seen_at).not_to be_nil
end
end
end
@@ -21,6 +21,27 @@ RSpec.describe 'Agents API', type: :request do
expect(response).to have_http_status(:payment_required)
expect(response.body).to include('Account limit exceeded. Please purchase more licenses')
end
it 'prevents adding an agent if the last seat is consumed before creation' do
account.update!(limits: { agents: account.account_users.count + 1 })
competing_agent_created = false
allow(AgentBuilder).to receive(:new).and_wrap_original do |method, *args|
unless competing_agent_created
create(:user, account: account, role: :agent)
competing_agent_created = true
end
method.call(*args)
end
post "/api/v1/accounts/#{account.id}/agents", params: params, headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:payment_required)
expect(response.body).to include('Account limit exceeded. Please purchase more licenses')
expect(User.from_email(params[:email])).to be_nil
expect(account.account_users.count).to eq(account.usage_limits[:agents])
end
end
end
@@ -208,6 +208,18 @@ RSpec.describe 'WhatsApp Calls API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq(I18n.t('errors.whatsapp.calls.not_enabled'))
end
it 'returns 403 before initiating the provider call when the inbox is disabled' do
inbox.update!(active: false)
allow(provider_service).to receive(:initiate_call)
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
params: { conversation_id: initiate_conversation.display_id, sdp_offer: 'sdp_offer' },
headers: agent.create_new_auth_token
expect(response).to have_http_status(:forbidden)
expect(provider_service).not_to have_received(:initiate_call)
end
end
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/:id/upload_recording' do
@@ -129,6 +129,23 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
expect(response).to have_http_status(:ok)
expect(response.body).to include('<Reject')
end
it 'rejects the inbound contact leg without building a call when the inbox is disabled' do
inbox.update!(active: 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
@@ -127,5 +127,20 @@ RSpec.describe Voice::OutboundCallBuilder do
)
end.to raise_error(ArgumentError, 'Agent required')
end
it 'raises before initiating the provider call when the inbox is disabled' do
inbox.update!(active: false)
expect do
described_class.perform!(
account: account,
inbox: inbox,
user: user,
contact: contact
)
end.to raise_error(CustomExceptions::InboxDisabled)
expect(channel).not_to have_received(:initiate_call)
end
end
end
@@ -44,6 +44,19 @@ describe Whatsapp::IncomingCallService do
end
end
context 'when the inbox is disabled' do
it 'rejects the call with Meta without creating a Call or Conversation' do
inbox.update!(active: false)
provider_service = instance_double(Whatsapp::Providers::WhatsappCloudService, reject_call: true)
allow(inbox.channel).to receive(:provider_service).and_return(provider_service)
params = call_payload(event: 'connect', session: { sdp: "v=0\r\n...sdp...", sdp_type: 'offer' })
expect { described_class.new(inbox: inbox, params: params).perform }
.to not_change(Call, :count).and not_change(Conversation, :count)
expect(provider_service).to have_received(:reject_call).with(provider_call_id)
end
end
describe 'inbound connect' do
let(:sdp_offer) { "v=0\r\n...sdp..." }
let!(:agent) { create(:user, account: account) }
@@ -30,4 +30,12 @@ RSpec.describe ConversationReplyEmailJob, type: :job do
described_class.perform_now(conversation.id, 123)
expect(mailer).to have_received(:reply_without_summary)
end
it 'does not send an email when the inbox is disabled before the job runs' do
conversation.inbox.update!(active: false)
described_class.perform_now(conversation.id, 123)
expect(ConversationReplyMailer).not_to have_received(:with)
end
end

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