Compare commits

..
Author SHA1 Message Date
2a1dd481e9 test(playwright): add agent onboarding and inbox creation UI tests (#14707)
Frontend Lint & Test / test (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Run Chatwoot CE spec / backend-tests (0, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (1, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (10, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (11, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (12, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (13, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (14, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (15, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (2, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (3, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (4, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (5, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (6, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (7, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (8, 16) (push) Waiting to run
Run Chatwoot CE spec / backend-tests (9, 16) (push) Waiting to run
Build Chatwoot / build (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Failing after 15m13s
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Failing after 14m33s
Run Chatwoot CE spec / lint-backend (push) In progress
Run Chatwoot CE spec / lint-frontend (push) In progress
Run Chatwoot CE spec / frontend-tests (push) In progress
Adds E2E UI tests for two core Phase 2 flows, building on the Playwright
setup from #13578.

**Agent onboarding** — validates the Agents settings page, Add Agent
modal elements, form validation (name + email required, submit disabled
until valid), and cancel behaviour.

**Inbox creation** — walks through the full API channel inbox creation
journey: channel selection → form fill → agent assignment → finish
screen.

## What changed

New UI component objects (`tests/playwright/components/ui/`):
- `agent-page.component.ts`
- `add-agent-modal.component.ts`
- `add-agents-form.component.ts`
- `settings-inbox-page.component.ts`
- `channel-selector.component.ts`
- `api-channel-form.component.ts`
- `finish-setup.component.ts`

New test specs (`tests/playwright/tests/e2e/ui/`):
- `agent-onboarding-flow-ui-validation.spec.ts`
- `inbox-creation-flow.spec.ts`

Updated `components/ui/index.ts` barrel export to include all new
components.

## How to test

```bash
cd tests/playwright
npx playwright test tests/e2e/ui/
```

All 5 tests pass locally (3 login + 2 new flows).

Closes part of the Phase 2 scope from the [Playwright E2E discussion
#13500](https://github.com/orgs/chatwoot/discussions/13500).

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-07-23 23:24:03 +05:30
Sivin VargheseandGitHub a98666030b chore: Calls page UI improvements (#15129) 2026-07-23 18:45:56 +05:30
Nicky DuijfandGitHub bae20ca83e feat(conversation): add label search to right-click context menu (#15084) 2026-07-23 15:10:46 +05:30
Sony Mathew 954e5844a8 Merge branch 'release/4.16.1' into develop 2026-07-23 13:58:16 +05:30
Sony Mathew 0efab5fb43 Bump version to 4.16.1 2026-07-23 13:57:15 +05:30
Muhsin KelothandGitHub 34ad78b122 fix(instagram): remove resolved restriction banners (#15136) 2026-07-23 12:56:21 +05:30
Shivam MishraandGitHub ddb0535a93 perf: reuse resolved count for reopen rate (#15122)
This improves the Captain overview by loading reporting metrics and FAQ
stats from separate endpoints. Range changes now refresh only the
metrics, while reopen-rate calculation reuses the resolved conversation
count to avoid redundant database queries.

## What changed

- Split Captain overview metrics and FAQ stats into separate APIs.
- Fetch FAQ stats independently from range-based metrics.
- Reuse resolved conversation totals when calculating reopen rate.
- Skip the reopen query when there are no resolved conversations.
2026-07-22 22:03:25 +05:30
Sivin VargheseandGitHub 42cbf7d3b9 fix: stray backslash after hard breaks before formatted list items (#15112) 2026-07-22 20:07:00 +05:30
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
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
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
97 changed files with 1274 additions and 1321 deletions
+1 -1
View File
@@ -1 +1 @@
4.16.0
4.16.1
+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
@@ -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)
@@ -3,7 +3,6 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
before_action :check_authorization
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
before_action :ensure_execution_delay_allowed, only: [:create, :update]
def index
@automation_rules = Current.account.automation_rules
@@ -49,9 +48,6 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
def clone
automation_rule = Current.account.automation_rules.find_by(id: params[:automation_rule_id])
new_rule = automation_rule.dup
# dup copies execution_delay; drop it when the feature is off so clone can't create new
# delayed rules that create/update would reject.
new_rule.execution_delay = nil unless delayed_automations_enabled?
new_rule.save!
@automation_rule = new_rule
end
@@ -59,27 +55,13 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
private
def automation_rules_permit
permitted_attributes = [:name, :description, :event_name, :active]
permitted_attributes << :execution_delay if delayed_automations_enabled?
params.permit(
*permitted_attributes,
:name, :description, :event_name, :active,
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
actions: [:action_name, { action_params: [] }]
)
end
def ensure_execution_delay_allowed
return if delayed_automations_enabled?
return if params[:execution_delay].blank?
render json: { error: 'Delayed automations are not enabled for this account.' }, status: :unprocessable_entity
end
def delayed_automations_enabled?
Current.account.feature_enabled?('delayed_automations')
end
def fetch_automation_rule
@automation_rule = Current.account.automation_rules.find_by(id: params[:id])
end
@@ -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
@@ -26,13 +26,20 @@ class CaptainAssistant extends ApiClient {
});
}
getStats({ assistantId, range, signal }) {
getMetrics({ assistantId, range, signal }) {
const requestConfig = {
params: { range, timezone_offset: getTimezoneOffset() },
};
if (signal) requestConfig.signal = signal;
return axios.get(`${this.url}/${assistantId}/stats`, requestConfig);
return axios.get(`${this.url}/${assistantId}/metrics`, requestConfig);
}
getFaqStats({ assistantId, signal }) {
const requestConfig = {};
if (signal) requestConfig.signal = signal;
return axios.get(`${this.url}/${assistantId}/faq_stats`, requestConfig);
}
getSummary({ assistantId, range, stats }) {
@@ -28,7 +28,12 @@ const inboxes = computed(() => {
return {
name: inbox.name,
id: inbox.id,
icon: getInboxIconByType(inbox.channelType, inbox.medium, 'line'),
icon: getInboxIconByType(
inbox.channelType,
inbox.medium,
'line',
inbox.voiceEnabled
),
};
});
});
@@ -3,6 +3,7 @@ import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { relativeDayTimestamp } from 'shared/helpers/timeHelper';
import { getInboxVoiceIcon } from 'dashboard/helper/inbox';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import AudioPlayer from 'dashboard/components-next/audio/AudioPlayer.vue';
@@ -60,7 +61,7 @@ const resultLabel = computed(() => {
});
const providerIcon = computed(() =>
props.call.provider === 'whatsapp' ? 'i-woot-whatsapp' : 'i-lucide-phone'
getInboxVoiceIcon(props.call.inbox.channelType, props.call.inbox.medium)
);
const createdAtLabel = computed(() =>
@@ -150,7 +151,7 @@ const conversationRoute = computed(() => ({
<div
class="hidden items-center gap-x-1.5 gap-y-2.5 border-b border-n-weak lg:flex lg:items-center lg:gap-1.5"
>
<div class="flex items-center gap-2.5 min-w-0 w-40 shrink-0 py-3.5">
<div class="flex items-center gap-2.5 min-w-0 w-52 shrink-0 py-3.5">
<Avatar
:src="call.contact.avatar"
:name="contactName"
@@ -169,9 +170,12 @@ const conversationRoute = computed(() => ({
>
<div class="flex items-center gap-x-2 min-w-0 lg:contents py-3.5">
<CallStatusBadge :kind="kind" class="shrink-0" />
<template v-if="agentActionLabel">
<div
v-if="agentActionLabel"
class="gap-x-1.5 min-w-0 flex items-center"
>
<span
class="text-label-small text-n-slate-10 truncate min-w-0 shrink min-w-8"
class="text-label-small text-n-slate-10 truncate shrink min-w-8 xl:min-w-14"
>
{{ agentActionLabel }}
</span>
@@ -192,7 +196,7 @@ const conversationRoute = computed(() => ({
{{ call.agent.name }}
</span>
</span>
</template>
</div>
<span
v-else-if="resultLabel"
class="text-body-main truncate text-n-slate-10 min-w-0 shrink-[20]"
@@ -212,10 +216,10 @@ const conversationRoute = computed(() => ({
content: call.inbox.name,
delay: { show: 500, hide: 0 },
}"
class="flex items-center gap-1.5 justify-start min-w-14 shrink-[100] py-3.5"
class="flex items-center gap-1 justify-end w-40 min-w-4 shrink-[100] py-3.5"
>
<Icon :icon="providerIcon" class="size-4 text-n-slate-11 shrink-0" />
<span class="text-body-main truncate text-n-slate-11">
<span class="text-body-main truncate text-n-slate-11 min-w-0">
{{ call.inbox.name }}
</span>
</div>
@@ -232,7 +236,7 @@ const conversationRoute = computed(() => ({
content: createdAtLabel,
delay: { show: 500, hide: 0 },
}"
class="text-label-small text-end text-n-slate-11 truncate py-3.5 tabular-nums justify-self-end w-16 shrink-0"
class="text-label-small text-end text-n-slate-11 truncate py-3.5 tabular-nums justify-self-end min-w-16 max-w-20 shrink-0"
>
{{ createdAtLabel }}
</span>
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({
// Null while a fetch is in flight so stale counts are never shown.
@@ -117,10 +118,16 @@ const moreFiltersSections = computed(() => [
},
]);
const selectedAssignee = computed(
() => props.agents.find(agent => agent.id === assigneeId.value) || null
);
const selectedAssigneeLabel = computed(
() =>
props.agents.find(agent => agent.id === assigneeId.value)?.name ||
t('CALLS_PAGE.FILTERS.ASSIGNEE')
() => selectedAssignee.value?.name || t('CALLS_PAGE.FILTERS.ASSIGNEE')
);
const isOtherActivitySelected = computed(() =>
OTHER_ACTIVITIES.includes(activity.value)
);
const hasMoreFilters = computed(() => Boolean(inboxId.value));
@@ -143,7 +150,7 @@ const applyMoreFilter = ({ action, value }) => {
<template>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-3">
<div class="flex flex-wrap items-center gap-2">
<span v-if="!activity" class="text-heading-3 text-n-slate-11 shrink-0">
{{
totalCount === null
@@ -157,13 +164,13 @@ const applyMoreFilter = ({ action, value }) => {
color="blue"
size="sm"
:icon="ACTIVITY_ICONS[activity]"
class="shrink-0"
class="shrink-0 !h-7 !px-2"
@click="setActivity(null)"
>
{{ activeChipLabel }}
<Icon icon="i-lucide-x" />
</Button>
<div class="w-px h-4 bg-n-strong shrink-0" />
<div class="w-px h-3.5 mx-1 bg-n-strong shrink-0" />
<Button
v-for="chip in inactiveChips"
:key="chip"
@@ -172,7 +179,7 @@ const applyMoreFilter = ({ action, value }) => {
size="sm"
:icon="ACTIVITY_ICONS[chip]"
:label="activityLabel(chip)"
class="shrink-0 text-n-slate-12"
class="shrink-0 text-n-slate-11 !h-7 !px-2"
@click="setActivity(chip)"
/>
<OnClickOutside
@@ -184,7 +191,10 @@ const applyMoreFilter = ({ action, value }) => {
color="slate"
size="sm"
icon="i-lucide-phone"
class="text-n-slate-12"
class="!h-7 !px-2"
:class="
isOtherActivitySelected ? 'text-n-slate-12' : 'text-n-slate-11'
"
@click="toggleMenu('activity')"
>
{{ t('CALLS_PAGE.FILTERS.OTHER_ACTIVITY') }}
@@ -208,10 +218,19 @@ const applyMoreFilter = ({ action, value }) => {
variant="outline"
color="slate"
size="sm"
icon="i-lucide-user-round-cog"
class="max-w-52 text-n-slate-12"
icon="i-woot-empty-assignee"
class="max-w-52 !h-7 !px-2"
:class="assigneeId ? 'text-n-slate-12' : 'text-n-slate-11'"
@click="toggleMenu('assignee')"
>
<template v-if="selectedAssignee" #icon>
<Avatar
:src="selectedAssignee.thumbnail"
:name="selectedAssignee.name"
:size="16"
rounded-full
/>
</template>
<span class="truncate">{{ selectedAssigneeLabel }}</span>
<Icon icon="i-lucide-chevron-down" class="text-n-slate-11 shrink-0" />
</Button>
@@ -228,8 +247,9 @@ const applyMoreFilter = ({ action, value }) => {
variant="outline"
size="sm"
icon="i-lucide-list-filter"
class="!h-7 !px-2"
:color="hasMoreFilters ? 'blue' : 'slate'"
:class="hasMoreFilters ? '' : 'text-n-slate-12'"
:class="hasMoreFilters ? '' : 'text-n-slate-11'"
@click="toggleMenu('more')"
>
{{ t('CALLS_PAGE.FILTERS.MORE_FILTERS') }}
@@ -83,8 +83,12 @@ const campaignStatus = computed(() => {
const inboxName = computed(() => props.inbox?.name || '');
const inboxIcon = computed(() => {
const { medium, channel_type: type } = props.inbox;
return getInboxIconByType(type, medium);
const {
medium,
channel_type: type,
voice_enabled: voiceEnabled,
} = props.inbox;
return getInboxIconByType(type, medium, 'fill', voiceEnabled);
});
</script>
@@ -48,8 +48,8 @@ const inbox = computed(() => props.stateInbox);
const inboxName = computed(() => inbox.value?.name);
const inboxIcon = computed(() => {
const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium);
const { channelType, medium, voiceEnabled } = inbox.value;
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
});
const lastActivityAt = computed(() => {
@@ -49,8 +49,8 @@ const isUnread = computed(() => !props.inboxItem?.readAt);
const inbox = computed(() => props.stateInbox);
const inboxIcon = computed(() => {
const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium);
const { channelType, medium, voiceEnabled } = inbox.value;
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
});
const hasSlaThreshold = computed(() => {
@@ -37,10 +37,11 @@ const transformInbox = ({
channelType,
phoneNumber,
medium,
voiceEnabled,
...rest
}) => ({
id,
icon: getInboxIconByType(channelType, medium, 'line'),
icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled),
label: generateLabelForContactableInboxesList({
name,
email,
@@ -54,6 +55,7 @@ const transformInbox = ({
phoneNumber,
channelType,
medium,
voiceEnabled,
...rest,
});
@@ -79,6 +79,20 @@ describe('composeConversationHelper', () => {
channelType: INBOX_TYPES.EMAIL,
});
});
it('uses the voice glyph for a voice-enabled inbox', () => {
const inboxes = [
{
id: 2,
name: 'WhatsApp Cloud',
channelType: INBOX_TYPES.WHATSAPP,
voiceEnabled: true,
},
];
const result = helpers.buildContactableInboxesList(inboxes);
expect(result[0].icon).toBe('i-woot-whatsapp-voice');
});
});
describe('getCapitalizedNameFromEmail', () => {
@@ -117,8 +117,8 @@ const downloadRecording = () => {
>
<template #icon>
<Icon
:icon="isPlaying ? 'i-lucide-pause' : 'i-lucide-play'"
class="size-4 flex-shrink-0"
:icon="isPlaying ? 'i-woot-audio-pause' : 'i-woot-audio-play'"
class="size-4 flex-shrink-0 text-n-slate-11"
/>
</template>
</Button>
@@ -127,10 +127,10 @@ const downloadRecording = () => {
min="0"
:max="duration || 0"
:value="currentTime"
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-1 rounded-lg appearance-none cursor-pointer bg-n-slate-12/30 accent-n-slate-11"
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-0.5 rounded-full appearance-none cursor-pointer bg-n-slate-12/30 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-2 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-n-slate-11 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:size-2 [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-n-slate-11"
@input="seek"
/>
<span class="text-sm tabular-nums text-n-slate-11 shrink-0">
<span class="text-label-small tabular-nums text-n-slate-11 shrink-0">
{{ displayedTime }}
</span>
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
@@ -58,8 +58,12 @@ const menuItems = computed(() => [
]);
const icon = computed(() => {
const { medium, channel_type: type } = props.inbox;
return getInboxIconByType(type, medium, 'outline');
const {
medium,
channel_type: type,
voice_enabled: voiceEnabled,
} = props.inbox;
return getInboxIconByType(type, medium, 'outline', voiceEnabled);
});
const handleAction = ({ action, value }) => {
@@ -1,6 +1,5 @@
<script setup>
import { computed, toRef } from 'vue';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
import { useChannelIcon, useChannelBrandIcon } from './provider';
import Icon from 'next/icon/Icon.vue';
@@ -21,7 +20,6 @@ defineOptions({ inheritAttrs: false });
const inboxRef = toRef(props, 'inbox');
const hasVoiceBadge = computed(() => isVoiceCallEnabled(props.inbox));
const channelIcon = useChannelIcon(inboxRef);
const brandIcon = useChannelBrandIcon(inboxRef);
@@ -31,13 +29,7 @@ const icon = computed(() =>
</script>
<template>
<span class="relative inline-flex" v-bind="$attrs">
<span class="inline-flex" v-bind="$attrs">
<Icon :icon="icon" class="size-full" />
<span
v-if="hasVoiceBadge"
class="absolute top-0 ltr:right-0 rtl:left-0 inline-flex items-center justify-center size-2 rounded-full bg-n-surface-1"
>
<Icon icon="i-lucide-audio-lines" class="size-1.5 text-n-slate-12" />
</span>
</span>
</template>
@@ -1,4 +1,9 @@
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
import {
INBOX_TYPES,
TWILIO_CHANNEL_MEDIUM,
isVoiceCallEnabled,
getInboxVoiceIcon,
} from 'dashboard/helper/inbox';
import { computed } from 'vue';
const channelTypeIconMap = {
@@ -61,16 +66,9 @@ export function useChannelIcon(inbox) {
icon = 'i-woot-whatsapp';
}
// Native Twilio voice inbox: a TwilioSms with voice enabled (and no WhatsApp medium)
// is presented as a Voice channel, so show the phone icon.
const voiceEnabled =
inboxDetails.voice_enabled || inboxDetails.voiceEnabled;
if (
type === INBOX_TYPES.TWILIO &&
voiceEnabled &&
inboxDetails.medium !== TWILIO_CHANNEL_MEDIUM.WHATSAPP
) {
icon = 'i-woot-voice';
// Voice-enabled inboxes use the combined channel + voice-wave badge glyph.
if (isVoiceCallEnabled(inboxDetails)) {
icon = getInboxVoiceIcon(type, inboxDetails.medium);
}
return icon ?? 'i-ri-global-fill';
@@ -19,13 +19,32 @@ describe('useChannelIcon', () => {
expect(icon).toBe('i-woot-whatsapp');
});
it('returns correct icon for voice-enabled Twilio channel', () => {
it('returns the voice-call glyph for a voice-enabled Twilio channel', () => {
const inbox = {
channel_type: 'Channel::TwilioSms',
voice_enabled: true,
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-voice');
expect(icon).toBe('i-woot-voice-call');
});
it('returns the WhatsApp voice glyph for a voice-enabled WhatsApp channel', () => {
const inbox = {
channel_type: 'Channel::Whatsapp',
voice_enabled: true,
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-whatsapp-voice');
});
it('returns the WhatsApp voice glyph for a voice-enabled Twilio WhatsApp channel', () => {
const inbox = {
channel_type: 'Channel::TwilioSms',
medium: 'whatsapp',
voice_enabled: true,
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-whatsapp-voice');
});
it('returns correct icon for Line channel', () => {
@@ -457,10 +457,11 @@ function handleReplyTo() {
const avatarInfo = computed(() => {
if (props.contentAttributes?.externalEcho) {
const { name, avatar_url, channel_type, medium } = inbox.value;
const { name, avatar_url, channel_type, medium, voice_enabled } =
inbox.value;
const iconName = avatar_url
? null
: getInboxIconByType(channel_type, medium);
: getInboxIconByType(channel_type, medium, 'fill', voice_enabled);
return {
name: iconName ? '' : name || t('CONVERSATION.NATIVE_APP'),
src: avatar_url || '',
@@ -77,6 +77,15 @@ const handleClose = () => {
emit('close');
};
const handleFocusOut = event => {
// Keep the menu open while focus stays inside it (e.g. the label search
// input); close it once focus leaves the menu entirely.
if (menuRef.value?.contains(event.relatedTarget)) {
return;
}
handleClose();
};
onUnmounted(() => {
isLocked.value = false;
});
@@ -89,7 +98,7 @@ onUnmounted(() => {
class="fixed outline-none z-[9999] cursor-pointer"
:style="position"
tabindex="0"
@blur="handleClose"
@focusout="handleFocusOut"
>
<slot />
</div>
@@ -8,6 +8,8 @@ import {
EditorState,
Selection,
imageResizeView,
toggleMark,
wrapInList,
} from '@chatwoot/prosemirror-schema';
import {
suggestionsPlugin,
@@ -17,8 +19,6 @@ import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
import embedPreviewPlugin from '@chatwoot/prosemirror-schema/src/plugins/embedPreview';
import trailingParagraphPlugin from '@chatwoot/prosemirror-schema/src/plugins/trailingParagraph';
import { embeds as markdownEmbeds } from 'dashboard/helper/markdownEmbeds';
import { toggleMark } from 'prosemirror-commands';
import { wrapInList } from 'prosemirror-schema-list';
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { isEscape } from 'shared/helpers/KeyboardHelpers';
@@ -33,9 +33,7 @@ import {
// constants
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants, {
META_RESTRICTION_STATUS_URL,
} from 'dashboard/constants/globals';
import wootConstants from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
@@ -95,7 +93,6 @@ export default {
currentUserId: 'getCurrentUserID',
listLoadingStatus: 'getAllMessagesLoaded',
currentAccountId: 'getCurrentAccountId',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
isOpen() {
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
@@ -173,13 +170,6 @@ export default {
instagramInbox
);
},
isInstagramRestrictionBannerVisible() {
return this.isOnChatwootCloud && this.isAnInstagramChannel;
},
instagramRestrictionStatusUrl() {
return META_RESTRICTION_STATUS_URL;
},
replyWindowBannerMessage() {
if (this.isAWhatsAppChannel) {
return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
@@ -464,15 +454,7 @@ export default {
>
<div ref="topBannerRef">
<Banner
v-if="isInstagramRestrictionBannerVisible"
color-scheme="warning"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.INSTAGRAM_RESTRICTION_BANNER')"
:href-link="instagramRestrictionStatusUrl"
:href-link-text="$t('CONVERSATION.INSTAGRAM_RESTRICTION_STATUS_LINK')"
/>
<Banner
v-else-if="!currentChat.can_reply"
v-if="!currentChat.can_reply"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="replyWindowBannerMessage"
@@ -7,10 +7,13 @@ import {
getSortedAgentsByAvailability,
getAgentsByUpdatedPresence,
} from 'dashboard/helper/agentHelper.js';
import { picoSearch } from '@scmmishra/pico-search';
import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
import NextInput from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const MENU = {
MARK_AS_READ: 'mark-as-read',
@@ -31,6 +34,8 @@ export default {
MenuItem,
MenuItemWithSubmenu,
AgentLoadingPlaceholder,
NextInput,
Icon,
},
props: {
chatId: {
@@ -87,6 +92,7 @@ export default {
data() {
return {
MENU,
labelSearchQuery: '',
STATUS_TYPE: wootConstants.STATUS_TYPE,
readOption: {
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_READ'),
@@ -216,6 +222,14 @@ export default {
// Don't show snooze if the conversation is already snoozed/resolved/pending
return this.status === wootConstants.STATUS_TYPE.OPEN;
},
filteredLabels() {
const labels = this.labelSearchQuery
? picoSearch(this.labels, this.labelSearchQuery, ['title'])
: this.labels;
// Assigned labels first, keeping each group's existing order.
const isAssigned = label => this.conversationLabels.includes(label.title);
return [...labels].sort((a, b) => isAssigned(b) - isAssigned(a));
},
},
mounted() {
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
@@ -335,21 +349,49 @@ export default {
:option="labelMenuConfig"
:sub-menu-available="!!labels.length"
>
<MenuItem
v-for="label in labels"
:key="label.id"
:option="generateMenuLabelConfig(label, 'label')"
:variant="
conversationLabels.includes(label.title)
? 'label-assigned'
: 'label'
"
@click.stop="
conversationLabels.includes(label.title)
? $emit('removeLabel', label)
: $emit('assignLabel', label)
"
/>
<div class="pb-1 w-[12.5rem]">
<NextInput
v-model="labelSearchQuery"
type="search"
size="sm"
class="w-full"
custom-input-class="!ps-8 !text-xs"
:placeholder="$t('CONVERSATION.CARD_CONTEXT_MENU.SEARCH_LABELS')"
@click.stop
@keydown.stop
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute z-10 -translate-y-1/2 pointer-events-none size-3.5 text-n-slate-10 top-1/2 start-2"
/>
</template>
</NextInput>
</div>
<div class="overflow-x-hidden overflow-y-auto max-h-[12.5rem]">
<MenuItem
v-for="label in filteredLabels"
:key="label.id"
:option="generateMenuLabelConfig(label, 'label')"
:variant="
conversationLabels.includes(label.title)
? 'label-assigned'
: 'label'
"
@mousedown.prevent
@click.stop="
conversationLabels.includes(label.title)
? $emit('removeLabel', label)
: $emit('assignLabel', label)
"
/>
<p
v-if="!filteredLabels.length"
class="px-2 py-2 m-0 text-xs text-center text-n-slate-11"
>
{{ $t('CONVERSATION.CARD_CONTEXT_MENU.NO_LABELS_FOUND') }}
</p>
</div>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.AGENT])"
@@ -15,7 +15,7 @@ defineProps({
</script>
<template>
<div class="menu text-n-slate-12 min-h-7 min-w-0" role="button">
<div class="menu group text-n-slate-12 min-h-7 min-w-0" role="button">
<fluent-icon
v-if="variant === 'icon' && option.icon"
:icon="option.icon"
@@ -52,7 +52,7 @@ defineProps({
<Icon
v-if="variant === 'label-assigned'"
icon="i-lucide-check"
class="flex-shrink-0 size-3.5 mr-1"
class="flex-shrink-0 size-3.5 text-n-brand group-hover:text-white"
/>
</div>
</template>
@@ -78,5 +78,3 @@ export default {
},
};
export const DEFAULT_REDIRECT_URL = '/app/';
export const META_RESTRICTION_STATUS_URL =
'https://status.chatwoot.com/incident/948346';
-1
View File
@@ -14,7 +14,6 @@ export const FEATURE_FLAGS = {
CRM: 'crm',
CUSTOM_ATTRIBUTES: 'custom_attributes',
DATA_IMPORT: 'data_import',
DELAYED_AUTOMATIONS: 'delayed_automations',
API_AND_WEBHOOKS: 'api_and_webhooks',
INBOX_MANAGEMENT: 'inbox_management',
INTEGRATIONS: 'integrations',
@@ -208,12 +208,6 @@ export const generateAutomationPayload = payload => {
return automation;
};
export const formatDelay = minutes => {
if (minutes % 1440 === 0) return `${minutes / 1440}d`;
if (minutes % 60 === 0) return `${minutes / 60}h`;
return `${minutes}m`;
};
export const isCustomAttribute = (attrs, key) => {
return attrs.find(attr => attr.key === key);
};
@@ -1,4 +1,6 @@
import {
InputRule,
inputRules,
MessageMarkdownSerializer,
MessageMarkdownTransformer,
messageSchema,
@@ -9,7 +11,6 @@ import * as Sentry from '@sentry/vue';
import camelcaseKeys from 'camelcase-keys';
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
import { InputRule, inputRules } from 'prosemirror-inputrules';
/**
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
+27 -1
View File
@@ -55,11 +55,30 @@ export const getVoiceCallProvider = inbox => {
export const isVoiceCallEnabled = inbox => getVoiceCallProvider(inbox) !== null;
// Combined channel + voice-wave badge glyph per voice-call provider.
export const VOICE_CALL_ICONS = {
[VOICE_CALL_PROVIDERS.WHATSAPP]: 'i-woot-whatsapp-voice',
[VOICE_CALL_PROVIDERS.TWILIO]: 'i-woot-voice-call',
};
export const getVoiceCallIcon = provider =>
VOICE_CALL_ICONS[provider] ?? VOICE_CALL_ICONS[VOICE_CALL_PROVIDERS.TWILIO];
export const TWILIO_CHANNEL_MEDIUM = {
WHATSAPP: 'whatsapp',
SMS: 'sms',
};
export const getInboxVoiceIcon = (channelType, medium) => {
const isWhatsapp =
channelType === INBOX_TYPES.WHATSAPP ||
(channelType === INBOX_TYPES.TWILIO &&
medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP);
return getVoiceCallIcon(
isWhatsapp ? VOICE_CALL_PROVIDERS.WHATSAPP : VOICE_CALL_PROVIDERS.TWILIO
);
};
const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
@@ -182,7 +201,14 @@ export const getInboxClassByType = (type, phoneNumber) => {
}
};
export const getInboxIconByType = (type, medium, variant = 'fill') => {
export const getInboxIconByType = (
type,
medium,
variant = 'fill',
voiceEnabled = false
) => {
if (voiceEnabled) return getInboxVoiceIcon(type, medium);
const iconMap =
variant === 'fill' ? INBOX_ICON_MAP_FILL : INBOX_ICON_MAP_LINE;
const defaultIcon =
@@ -1,8 +1,11 @@
import {
INBOX_TYPES,
VOICE_CALL_PROVIDERS,
getInboxClassByType,
getInboxIconByType,
getInboxVoiceIcon,
getInboxWarningIconClass,
getVoiceCallIcon,
} from '../inbox';
describe('#Inbox Helpers', () => {
@@ -166,4 +169,63 @@ describe('#Inbox Helpers', () => {
);
});
});
describe('getVoiceCallIcon', () => {
it('returns the WhatsApp voice glyph for the whatsapp provider', () => {
expect(getVoiceCallIcon(VOICE_CALL_PROVIDERS.WHATSAPP)).toBe(
'i-woot-whatsapp-voice'
);
});
it('returns the generic voice-call glyph for the twilio provider', () => {
expect(getVoiceCallIcon(VOICE_CALL_PROVIDERS.TWILIO)).toBe(
'i-woot-voice-call'
);
});
it('falls back to the generic voice-call glyph for an unknown provider', () => {
expect(getVoiceCallIcon('unknown')).toBe('i-woot-voice-call');
expect(getVoiceCallIcon(undefined)).toBe('i-woot-voice-call');
});
});
describe('getInboxVoiceIcon', () => {
it('returns the WhatsApp voice glyph for a WhatsApp inbox', () => {
expect(getInboxVoiceIcon(INBOX_TYPES.WHATSAPP)).toBe(
'i-woot-whatsapp-voice'
);
});
it('returns the WhatsApp voice glyph for a Twilio WhatsApp inbox', () => {
expect(getInboxVoiceIcon(INBOX_TYPES.TWILIO, 'whatsapp')).toBe(
'i-woot-whatsapp-voice'
);
});
it('returns the generic voice-call glyph for a Twilio voice inbox', () => {
expect(getInboxVoiceIcon(INBOX_TYPES.TWILIO, 'sms')).toBe(
'i-woot-voice-call'
);
});
});
describe('getInboxIconByType with voice enabled', () => {
it('returns the WhatsApp voice glyph for a voice-enabled WhatsApp inbox', () => {
expect(
getInboxIconByType(INBOX_TYPES.WHATSAPP, undefined, 'line', true)
).toBe('i-woot-whatsapp-voice');
});
it('returns the generic voice-call glyph for a voice-enabled Twilio inbox', () => {
expect(getInboxIconByType(INBOX_TYPES.TWILIO, 'sms', 'line', true)).toBe(
'i-woot-voice-call'
);
});
it('returns the normal channel icon when voice is not enabled', () => {
expect(
getInboxIconByType(INBOX_TYPES.WHATSAPP, undefined, 'line', false)
).toBe('i-woot-whatsapp');
});
});
});
@@ -28,35 +28,6 @@
"PLACEHOLDER": "Please select one",
"ERROR": "Event is required"
},
"EXECUTE": {
"LABEL": "Delayed execution",
"AFTER_DELAY": "Run after",
"UNITS": {
"MINUTES": "Minutes",
"HOURS": "Hours",
"DAYS": "Days"
},
"ERROR": "Delay must be between 10 minutes and 30 days",
"ENDS_IF_LABEL": "Won't run if",
"ENDS_IF": {
"STATUS": "the conversation's status changes, or its conditions no longer match.",
"CUSTOMER_REPLY": "the customer replies, or the conditions no longer match.",
"AGENT_REPLY": "an agent replies, or the conditions no longer match.",
"GENERIC": "there is a new reply on the conversation, or the conditions no longer match."
},
"HELP_TEXT": "Only applies to conversations with activity after the rule is created."
},
"TRIGGER": {
"LABEL": "Trigger",
"WHEN_LABEL": "When",
"STATUS_LABEL": "Status is",
"INBOX_LABEL": "Inbox",
"OPTIONS": {
"CUSTOMER_UNRESPONSIVE": "Customer unresponsive",
"AGENT_UNRESPONSIVE": "Teammate unresponsive",
"CONVERSATION_STATUS": "Conversation in a status"
}
},
"CONDITIONS": {
"LABEL": "Conditions"
},
@@ -78,13 +49,7 @@
"CREATED_ON": "Created on",
"ACTIONS": "Actions"
},
"404": "No automation rules found",
"SECTIONS": {
"INSTANT": "Automations",
"DELAYED": "Delayed execution"
},
"DELAY_BADGE": "Runs after {delay}",
"DELAY_DISABLED_BANNER": "Delayed execution is disabled for this account. Delayed rules won't run until it is enabled again."
"404": "No automation rules found"
},
"DELETE": {
"TITLE": "Delete Automation Rule",
@@ -44,8 +44,6 @@
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You wont be able to send messages from this conversation anymore.",
"INSTAGRAM_RESTRICTION_BANNER": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
"INSTAGRAM_RESTRICTION_STATUS_LINK": "View status update",
"REPLYING_TO": "You are replying to:",
"REMOVE_SELECTION": "Remove Selection",
"DOWNLOAD": "Download",
@@ -195,6 +193,8 @@
},
"ASSIGN_AGENT": "Assign agent",
"ASSIGN_LABEL": "Assign label",
"SEARCH_LABELS": "Search labels",
"NO_LABELS_FOUND": "No labels found",
"AGENTS_LOADING": "Loading agents...",
"ASSIGN_TEAM": "Assign team",
"DELETE": "Delete conversation",
@@ -58,9 +58,7 @@
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
"ERROR_AUTH": "There was an error connecting to Instagram, please try again",
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You wont be able to send/receive Instagram messages from this inbox anymore.",
"SETTINGS_RESTRICTED_WARNING": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
"STATUS_LINK": "View status update"
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You wont be able to send/receive Instagram messages from this inbox anymore."
},
"TIKTOK": {
"CONTINUE_WITH_TIKTOK": "Continue with TikTok",
@@ -87,8 +87,8 @@ const inboxName = computed(() => props.inbox?.name);
const inboxIcon = computed(() => {
if (!inbox.value) return null;
const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium);
const { channelType, medium, voiceEnabled } = inbox.value;
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
});
</script>
@@ -65,8 +65,8 @@ const inboxName = computed(() => inbox.value?.name);
const inboxIcon = computed(() => {
if (!inbox.value) return null;
const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium);
const { channelType, medium, voiceEnabled } = inbox.value;
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
});
const fileAttachments = computed(() => {
@@ -129,22 +129,22 @@ onMounted(async () => {
v-else
class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1"
>
<header class="px-6 pt-6 pb-4 shrink-0">
<div class="w-full">
<header class="shrink-0">
<div class="w-full px-6 pt-6">
<h1 class="text-xl font-medium text-n-slate-12">
{{ t('CALLS_PAGE.HEADER') }}
</h1>
<CallsFilterBar
v-model:activity="activity"
v-model:assignee-id="assigneeId"
v-model:inbox-id="inboxId"
class="mt-5"
:total-count="isFetching ? null : meta.count"
:agents="agents"
:inboxes="voiceInboxes"
:show-assignee="isAdmin"
/>
</div>
<CallsFilterBar
v-model:activity="activity"
v-model:assignee-id="assigneeId"
v-model:inbox-id="inboxId"
class="mt-5 pb-4 border-b border-n-weak mx-6"
:total-count="isFetching ? null : meta.count"
:agents="agents"
:inboxes="voiceInboxes"
:show-assignee="isAdmin"
/>
</header>
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full">
@@ -26,25 +26,28 @@ const canDrilldown = computed(() => checkPermissions(['administrator']));
const selectedRange = ref('this_month');
const assistantId = computed(() => route.params.assistantId);
const stats = ref(null);
const isFetching = ref(false);
const metricStats = ref(null);
const faqStats = ref(null);
const isFetchingMetrics = ref(false);
// Increments on every fetch so a response (or retry) from a superseded
// range/assistant can't clobber the latest request's state.
let fetchToken = 0;
let abortController = null;
let metricsFetchToken = 0;
let faqStatsFetchToken = 0;
let metricsAbortController = null;
let faqStatsAbortController = null;
const fetchStats = async () => {
fetchToken += 1;
const token = fetchToken;
abortController?.abort();
abortController = new AbortController();
const { signal } = abortController;
stats.value = null;
isFetching.value = true;
const fetchMetrics = async () => {
metricsFetchToken += 1;
const token = metricsFetchToken;
metricsAbortController?.abort();
metricsAbortController = new AbortController();
const { signal } = metricsAbortController;
metricStats.value = null;
isFetchingMetrics.value = true;
const requestStats = () =>
CaptainAssistant.getStats({
const requestMetrics = () =>
CaptainAssistant.getMetrics({
assistantId: assistantId.value,
range: selectedRange.value,
signal,
@@ -52,25 +55,54 @@ const fetchStats = async () => {
let data = null;
try {
({ data } = await requestStats());
({ data } = await requestMetrics());
} catch {
// One silent retry before giving up, unless the request was aborted.
try {
if (token === fetchToken && !signal.aborted)
({ data } = await requestStats());
if (token === metricsFetchToken && !signal.aborted)
({ data } = await requestMetrics());
} catch {
data = null;
}
}
if (token !== fetchToken || signal.aborted) return;
stats.value = data;
isFetching.value = false;
if (token !== metricsFetchToken || signal.aborted) return;
metricStats.value = data;
isFetchingMetrics.value = false;
};
onUnmounted(() => abortController?.abort());
const fetchFaqStats = async () => {
faqStatsFetchToken += 1;
const token = faqStatsFetchToken;
faqStatsAbortController?.abort();
faqStatsAbortController = new AbortController();
const { signal } = faqStatsAbortController;
faqStats.value = null;
watch([selectedRange, assistantId], fetchStats, { immediate: true });
try {
const { data } = await CaptainAssistant.getFaqStats({
assistantId: assistantId.value,
signal,
});
if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = data;
} catch {
if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = null;
}
};
const summaryStats = computed(() => {
if (!metricStats.value || !faqStats.value) return null;
return { ...metricStats.value, knowledge: faqStats.value };
});
onUnmounted(() => {
metricsAbortController?.abort();
faqStatsAbortController?.abort();
});
watch([selectedRange, assistantId], fetchMetrics, { immediate: true });
watch(assistantId, fetchFaqStats, { immediate: true });
// `direction` says whether a rising trend is good ('up'), bad ('down'), or
// neutral, so we can colour the delta independently of its sign.
@@ -90,7 +122,7 @@ const formatDuration = hours =>
hours >= 100 ? `${Math.round(hours / 24)}d` : `${hours}h`;
const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => {
const data = stats.value?.[statKey];
const data = metricStats.value?.[statKey];
if (!data) return { value: '—', trend: '', trendGood: null };
const sign = data.trend > 0 ? '+' : '';
@@ -184,9 +216,9 @@ const closeDrilldown = () => {
<div class="flex flex-col gap-6 pb-8">
<InboxBanner />
<CoverageBanner :knowledge="stats?.knowledge" />
<CoverageBanner :knowledge="faqStats ?? undefined" />
<WelcomeCard :range="selectedRange" :stats="stats" />
<WelcomeCard :range="selectedRange" :stats="summaryStats" />
<div
class="grid grid-cols-1 gap-px overflow-hidden border rounded-xl sm:grid-cols-2 lg:grid-cols-3 bg-n-weak border-n-weak"
@@ -199,13 +231,15 @@ const closeDrilldown = () => {
:trend="metric.trend"
:hint="metric.hint"
:trend-good="metric.trendGood"
:loading="isFetching"
:clickable="canDrilldown && Boolean(metric.metric) && !isFetching"
:loading="isFetchingMetrics"
:clickable="
canDrilldown && Boolean(metric.metric) && !isFetchingMetrics
"
@click="openDrilldown(metric)"
/>
</div>
<KnowledgeCard :knowledge="stats?.knowledge" />
<KnowledgeCard :knowledge="faqStats ?? undefined" />
<QuickLinks />
</div>
@@ -79,13 +79,15 @@ const breadcrumbItems = computed(() => {
});
const buildInboxList = allInboxes =>
allInboxes?.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
name,
id,
email,
phoneNumber,
icon: getInboxIconByType(channelType, medium, 'line'),
})) || [];
allInboxes?.map(
({ name, id, email, phoneNumber, channelType, medium, voiceEnabled }) => ({
name,
id,
email,
phoneNumber,
icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled),
})
) || [];
const policyInboxes = computed(() =>
buildInboxList(selectedPolicy.value?.inboxes)
@@ -64,13 +64,23 @@ const allInboxes = computed(
inboxes.value
?.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
name,
id,
email,
phoneNumber,
icon: getInboxIconByType(channelType, medium, 'line'),
})) || []
.map(
({
name,
id,
email,
phoneNumber,
channelType,
medium,
voiceEnabled,
}) => ({
name,
id,
email,
phoneNumber,
icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled),
})
) || []
);
const formData = computed(() => ({
@@ -29,7 +29,8 @@ const inboxIcon = computed(() => {
return getInboxIconByType(
props.inbox.channelType,
props.inbox.medium,
'line'
'line',
props.inbox.voiceEnabled
);
});
@@ -10,7 +10,6 @@ const START_VALUE = {
name: null,
description: null,
event_name: 'conversation_created',
execution_delay: null,
conditions: [
{
attribute_key: 'status',
@@ -6,11 +6,6 @@ import { useOperators } from 'dashboard/components-next/filter/operators';
import ConditionRow from 'dashboard/components-next/filter/ConditionRow.vue';
import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
import MultiSelect from 'dashboard/components-next/filter/inputs/MultiSelect.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import {
generateAutomationPayload,
@@ -19,7 +14,6 @@ import {
showActionInput,
} from 'dashboard/helper/automationHelper';
import { validateAutomation } from 'dashboard/helper/validations';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { AUTOMATION_RULE_EVENTS, AUTOMATION_ACTION_TYPES } from './constants';
const props = defineProps({
@@ -77,28 +71,6 @@ const INPUT_TYPE_MAP = {
date: 'date',
};
const DEFAULT_DELAY_MINUTES = 240; // 4 hours
const MIN_DELAY_MINUTES = 10;
const MAX_DELAY_MINUTES = 43200; // 30 days
// A delayed rule is expressed as one meaningful trigger instead of a raw event + conditions. Each
// trigger maps to the automation's event_name plus a preset condition: message_type for the two
// unresponsive cases (reply-chase / awaiting-agent), or a chosen status for conversation_updated.
const DELAYED_TRIGGERS = [
{ key: 'conversation_status', eventName: 'conversation_updated' },
{
key: 'customer_unresponsive',
eventName: 'message_created',
messageType: 'outgoing',
},
{
key: 'agent_unresponsive',
eventName: 'message_created',
messageType: 'incoming',
},
];
const DEFAULT_TRIGGER = DELAYED_TRIGGERS[0].key;
const DEFAULT_TRIGGER_STATUS = 'pending';
const { t } = useI18n();
const { isCloudFeatureEnabled } = useAccount();
const { operators } = useOperators();
@@ -106,7 +78,6 @@ const { operators } = useOperators();
const dialogRef = ref(null);
const conditionsRef = useTemplateRef('conditionsRef');
const errors = ref({});
const isDelayed = ref(false);
const isEditMode = computed(() => props.mode === 'edit');
@@ -213,172 +184,6 @@ const hasActionErrors = computed(() =>
Object.keys(errors.value).some(key => key.startsWith('action_'))
);
const allowsDelayedExecution = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.DELAYED_AUTOMATIONS)
);
// Trigger controls for the delayed flow. They own event_name + conditions while the wait is on.
// selectedTrigger / triggerStatus are plain values (FilterSelect); triggerInboxes is an array of
// { id, name } (MultiSelect) — empty means the rule applies to every inbox.
const selectedTrigger = ref(DEFAULT_TRIGGER);
const triggerStatus = ref(DEFAULT_TRIGGER_STATUS);
const triggerInboxes = ref([]);
const isStatusTrigger = computed(
() => selectedTrigger.value === 'conversation_status'
);
// FilterSelect expects { label, value }.
const statusSelectOptions = computed(() =>
(props.getConditionDropdownValues('status') || [])
.filter(option => option.id !== 'all')
.map(option => ({ value: option.id, label: option.name }))
);
const triggerSelectOptions = computed(() =>
DELAYED_TRIGGERS.map(trigger => ({
value: trigger.key,
label: t(
`AUTOMATION.ADD.FORM.TRIGGER.OPTIONS.${trigger.key.toUpperCase()}`
),
}))
);
// MultiSelect expects (and returns) { id, name } options.
const inboxOptions = computed(
() => props.getConditionDropdownValues('inbox_id') || []
);
// What ends the wait, mirroring the backend episode that arms the rule. Shown to the user so
// they can predict when the rule runs. Conversation rules key on status; message rules key on
// the reply that ends the wait (customer reply for outgoing, agent reply for incoming).
const waitEndsKey = computed(() => {
if (eventName.value !== 'message_created') return 'STATUS';
const messageType = (automation.value?.conditions || []).find(
condition => condition.attribute_key === 'message_type'
);
const raw = Array.isArray(messageType?.values)
? messageType.values[0]
: messageType?.values;
// Raw create-mode values are strings ('outgoing'); edit-mode values are option objects.
const value = raw && typeof raw === 'object' ? raw.id : raw;
if (value === 'outgoing') return 'CUSTOMER_REPLY';
if (value === 'incoming') return 'AGENT_REPLY';
return 'GENERIC';
});
// DurationInput holds the wait in minutes and clamps to [MIN, MAX]; the unit is display-only.
const delayMinutes = ref(DEFAULT_DELAY_MINUTES);
const delayUnit = ref(DURATION_UNITS.HOURS);
const executionDelayInvalid = computed(
() => isDelayed.value && !Number.isFinite(delayMinutes.value)
);
// Show the wait in the largest whole unit (240 min → 4 hours). Passed in by open() rather than
// read from `automation`, whose model prop only settles a tick later.
const syncDelayFromDelay = delay => {
isDelayed.value = Boolean(delay);
const minutes = delay || DEFAULT_DELAY_MINUTES;
if (minutes % 1440 === 0) delayUnit.value = DURATION_UNITS.DAYS;
else if (minutes % 60 === 0) delayUnit.value = DURATION_UNITS.HOURS;
else delayUnit.value = DURATION_UNITS.MINUTES;
delayMinutes.value = minutes;
};
watch([isDelayed, delayMinutes], () => {
if (!automation.value || !allowsDelayedExecution.value) return;
automation.value.execution_delay = isDelayed.value
? delayMinutes.value
: null;
});
const buildTriggerCondition = (attributeKey, values) => ({
attribute_key: attributeKey,
filter_operator: 'equal_to',
values,
query_operator: 'and',
custom_attribute_type: '',
});
// Write the selected trigger (plus optional inbox scope) onto the rule's event_name + conditions.
const applyDelayedTrigger = () => {
const trigger = DELAYED_TRIGGERS.find(
item => item.key === selectedTrigger.value
);
if (!automation.value || !trigger) return;
automation.value.event_name = trigger.eventName;
const conditions = [
trigger.messageType
? buildTriggerCondition('message_type', trigger.messageType)
: buildTriggerCondition('status', triggerStatus.value),
];
// A private note is an outgoing message, so without this an internal note would read as a reply
// and arm the customer-unresponsive wait. Incoming messages are never private.
if (trigger.messageType === 'outgoing') {
conditions.push(buildTriggerCondition('private_note', [false]));
}
if (triggerInboxes.value.length) {
conditions.push(
buildTriggerCondition(
'inbox_id',
triggerInboxes.value.map(inbox => inbox.id)
)
);
}
automation.value.conditions = conditions;
};
// A single value is a raw string in create mode and an option object ({ id }) after edit-mode
// formatting; return its plain value either way.
const rawConditionValue = condition => {
const raw = Array.isArray(condition?.values)
? condition.values[0]
: condition?.values;
return raw && typeof raw === 'object' ? raw.id : raw;
};
// Populate the trigger controls from an existing delayed rule when editing.
const hydrateTriggerFromAutomation = () => {
const conditions = automation.value?.conditions || [];
const byKey = key => conditions.find(c => c.attribute_key === key);
const messageType = rawConditionValue(byKey('message_type'));
if (messageType === 'incoming') selectedTrigger.value = 'agent_unresponsive';
else if (messageType === 'outgoing')
selectedTrigger.value = 'customer_unresponsive';
else {
selectedTrigger.value = 'conversation_status';
triggerStatus.value =
rawConditionValue(byKey('status')) || DEFAULT_TRIGGER_STATUS;
}
const inboxValues = byKey('inbox_id')?.values || [];
const inboxIds = inboxValues.map(value =>
value && typeof value === 'object' ? value.id : value
);
triggerInboxes.value = inboxOptions.value.filter(inbox =>
inboxIds.includes(inbox.id)
);
};
// Turning the wait on (create) sets the default trigger's event + conditions.
watch(isDelayed, delayed => {
if (delayed && automation.value && !isEditMode.value) applyDelayedTrigger();
});
// Any trigger-control change re-derives event_name + conditions. After hydration this simply
// re-writes the same values, so it stays idempotent (no reference change → no loop).
watch([selectedTrigger, triggerStatus, triggerInboxes], () => {
if (isDelayed.value) applyDelayedTrigger();
});
// Opening an existing delayed rule mirrors its event/conditions into the trigger controls.
watch(
() => automation.value,
() => {
if (isDelayed.value && automation.value) hydrateTriggerFromAutomation();
}
);
watch(
() => automation.value,
() => {
@@ -411,9 +216,8 @@ const syncCustomAttributeTypes = () => {
});
};
const open = (executionDelay = null) => {
const open = () => {
resetValidation();
syncDelayFromDelay(executionDelay);
dialogRef.value?.open();
};
@@ -426,13 +230,8 @@ const emitSaveAutomation = () => {
syncCustomAttributeTypes();
const conditionsValid = isConditionsValid();
errors.value = validateAutomation(automation.value);
if (allowsDelayedExecution.value && executionDelayInvalid.value) {
errors.value.execution_delay = true;
}
if (Object.keys(errors.value).length === 0 && conditionsValid) {
const payload = generateAutomationPayload(automation.value);
// The API rejects the param when the feature is off; existing values are kept server-side.
if (!allowsDelayedExecution.value) delete payload.execution_delay;
emit('save', payload, props.mode);
}
};
@@ -467,163 +266,84 @@ defineExpose({ open, close });
:error="errors.description ? $t('AUTOMATION.ADD.FORM.DESC.ERROR') : ''"
:placeholder="$t('AUTOMATION.ADD.FORM.DESC.PLACEHOLDER')"
/>
<!-- Wait Start (choose the delay first, then the trigger) -->
<div v-if="allowsDelayedExecution" class="mb-6">
<div class="flex items-center justify-between gap-4">
<label class="mb-0" :class="{ error: errors.execution_delay }">
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.LABEL') }}
</label>
<ToggleSwitch v-model="isDelayed" />
</div>
<div v-if="isDelayed" class="flex flex-wrap items-center gap-2 mt-2">
<span class="text-sm text-n-slate-11">
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.AFTER_DELAY') }}
</span>
<div class="flex items-center gap-2 w-64">
<DurationInput
v-model="delayMinutes"
v-model:unit="delayUnit"
:min="MIN_DELAY_MINUTES"
:max="MAX_DELAY_MINUTES"
/>
</div>
</div>
<span
v-if="isDelayed && executionDelayInvalid"
class="text-xs text-n-ruby-9"
>
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.ERROR') }}
</span>
</div>
<!-- Wait End -->
<!-- Delayed trigger: a curated event + condition, in place of raw Event/Conditions -->
<div v-if="isDelayed" class="mb-6">
<label class="mb-1">
{{ $t('AUTOMATION.ADD.FORM.TRIGGER.LABEL') }}
</label>
<div
class="flex flex-col gap-3 p-4 outline outline-1 -outline-offset-1 rounded-xl outline-n-weak dark:outline-n-strong"
>
<div class="flex items-center gap-3 min-h-8">
<span class="w-20 shrink-0 text-sm text-n-slate-11">
{{ $t('AUTOMATION.ADD.FORM.TRIGGER.WHEN_LABEL') }}
</span>
<FilterSelect
v-model="selectedTrigger"
:options="triggerSelectOptions"
/>
</div>
<div v-if="isStatusTrigger" class="flex items-center gap-3 min-h-8">
<span class="w-20 shrink-0 text-sm text-n-slate-11">
{{ $t('AUTOMATION.ADD.FORM.TRIGGER.STATUS_LABEL') }}
</span>
<FilterSelect
v-model="triggerStatus"
:options="statusSelectOptions"
/>
</div>
<div class="flex items-center gap-3 min-h-8">
<span class="w-20 shrink-0 text-sm text-n-slate-11">
{{ $t('AUTOMATION.ADD.FORM.TRIGGER.INBOX_LABEL') }}
</span>
<MultiSelect v-model="triggerInboxes" :options="inboxOptions" />
</div>
</div>
<p class="text-xs text-n-slate-11 pt-2 mb-0">
<span class="text-n-slate-12 font-medium">
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.ENDS_IF_LABEL') }}
</span>
{{ $t(`AUTOMATION.ADD.FORM.EXECUTE.ENDS_IF.${waitEndsKey}`) }}
</p>
<p class="text-xs text-n-slate-11 pt-1 mb-0">
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.HELP_TEXT') }}
</p>
</div>
<!-- Instant flow: raw Event + Conditions -->
<template v-else>
<div class="mb-6">
<label :class="{ error: errors.event_name }">
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
<select
v-model="automation.event_name"
class="m-0"
@change="onEventChange()"
<div class="mb-6">
<label :class="{ error: errors.event_name }">
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
<select
v-model="automation.event_name"
class="m-0"
@change="onEventChange()"
>
<option
v-for="event in automationRuleEvents"
:key="event.key"
:value="event.key"
>
<option
v-for="event in automationRuleEvents"
:key="event.key"
:value="event.key"
>
{{ event.value }}
</option>
</select>
<span v-if="errors.event_name" class="message">
{{ $t('AUTOMATION.ADD.FORM.EVENT.ERROR') }}
</span>
</label>
<p
v-if="!isEditMode && hasAutomationMutated"
class="text-xs text-right text-n-teal-10 pt-1"
>
{{ $t('AUTOMATION.FORM.RESET_MESSAGE') }}
</p>
</div>
<!-- Conditions Start -->
<section class="mb-5">
<label>
{{ $t('AUTOMATION.ADD.FORM.CONDITIONS.LABEL') }}
</label>
<ul
class="grid gap-4 list-none p-3 mb-4 outline outline-1 rounded-xl -outline-offset-1"
:class="
hasConditionErrors
? 'outline-n-ruby-5 bg-n-ruby-2/50'
: 'outline-n-weak dark:outline-n-strong'
"
>
<template v-for="(condition, i) in automation.conditions" :key="i">
<ConditionRow
v-if="i === 0"
ref="conditionsRef"
v-model:attribute-key="automation.conditions[i].attribute_key"
v-model:filter-operator="
automation.conditions[i].filter_operator
"
v-model:values="automation.conditions[i].values"
:filter-types="filterTypes"
:show-query-operator="false"
@remove="removeFilter(i)"
/>
<ConditionRow
v-else
ref="conditionsRef"
v-model:attribute-key="automation.conditions[i].attribute_key"
v-model:filter-operator="
automation.conditions[i].filter_operator
"
v-model:query-operator="
automation.conditions[i - 1].query_operator
"
v-model:values="automation.conditions[i].values"
:filter-types="filterTypes"
show-query-operator
@remove="removeFilter(i)"
/>
</template>
<div>
<NextButton
icon="i-lucide-plus"
blue
faded
sm
:label="$t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL')"
@click="appendNewCondition"
/>
</div>
</ul>
</section>
<!-- Conditions End -->
</template>
{{ event.value }}
</option>
</select>
<span v-if="errors.event_name" class="message">
{{ $t('AUTOMATION.ADD.FORM.EVENT.ERROR') }}
</span>
</label>
<p
v-if="!isEditMode && hasAutomationMutated"
class="text-xs text-right text-n-teal-10 pt-1"
>
{{ $t('AUTOMATION.FORM.RESET_MESSAGE') }}
</p>
</div>
<!-- Conditions Start -->
<section class="mb-5">
<label>
{{ $t('AUTOMATION.ADD.FORM.CONDITIONS.LABEL') }}
</label>
<ul
class="grid gap-4 list-none p-3 mb-4 outline outline-1 rounded-xl -outline-offset-1"
:class="
hasConditionErrors
? 'outline-n-ruby-5 bg-n-ruby-2/50'
: 'outline-n-weak dark:outline-n-strong'
"
>
<template v-for="(condition, i) in automation.conditions" :key="i">
<ConditionRow
v-if="i === 0"
ref="conditionsRef"
v-model:attribute-key="automation.conditions[i].attribute_key"
v-model:filter-operator="automation.conditions[i].filter_operator"
v-model:values="automation.conditions[i].values"
:filter-types="filterTypes"
:show-query-operator="false"
@remove="removeFilter(i)"
/>
<ConditionRow
v-else
ref="conditionsRef"
v-model:attribute-key="automation.conditions[i].attribute_key"
v-model:filter-operator="automation.conditions[i].filter_operator"
v-model:query-operator="
automation.conditions[i - 1].query_operator
"
v-model:values="automation.conditions[i].values"
:filter-types="filterTypes"
show-query-operator
@remove="removeFilter(i)"
/>
</template>
<div>
<NextButton
icon="i-lucide-plus"
blue
faded
sm
:label="$t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL')"
@click="appendNewCondition"
/>
</div>
</ul>
</section>
<!-- Conditions End -->
<!-- Actions Start -->
<section>
<label>
@@ -1,7 +1,6 @@
<script setup>
import { computed } from 'vue';
import { messageStamp } from 'shared/helpers/timeHelper';
import { formatDelay } from 'dashboard/helper/automationHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
import { BaseTableRow, BaseTableCell } from 'dashboard/components-next/table';
@@ -44,16 +43,6 @@ const automationActive = computed({
<span class="text-body-main text-n-slate-12 truncate">
{{ automation.name }}
</span>
<span
v-if="automation.execution_delay"
class="text-xs px-1.5 py-0.5 rounded-md bg-n-alpha-2 text-n-slate-11 whitespace-nowrap flex-shrink-0"
>
{{
$t('AUTOMATION.LIST.DELAY_BADGE', {
delay: formatDelay(automation.execution_delay),
})
}}
</span>
<div class="w-px h-3 rounded-lg bg-n-weak flex-shrink-0" />
<span class="text-body-main text-n-slate-11 truncate">
{{ automation.description }}
@@ -34,33 +34,29 @@ const {
const { formatAutomation } = useEditableAutomation();
const syncAutomationFromSelected = (source = props.selectedResponse) => {
if (!source?.conditions) return;
manifestCustomAttributes();
automation.value = formatAutomation(
source,
allCustomAttributes.value,
automationTypes,
AUTOMATION_ACTION_TYPES
);
};
// Format from the rule passed to open(): the prop updates a tick later, so at open() time
// automation still holds the previously selected rule (its execution_delay hydrates the form).
const open = rule => {
syncAutomationFromSelected(rule);
formRef.value?.open(rule?.execution_delay);
};
const open = () => formRef.value?.open();
const close = () => formRef.value?.close();
const onSave = (payload, mode) => {
emit('saveAutomation', payload, mode);
};
watch(() => props.selectedResponse, syncAutomationFromSelected, {
immediate: true,
});
watch(
() => props.selectedResponse,
value => {
if (!value?.conditions) return;
manifestCustomAttributes();
automation.value = formatAutomation(
value,
allCustomAttributes.value,
automationTypes,
AUTOMATION_ACTION_TYPES
);
},
{ immediate: true }
);
defineExpose({ open, close });
</script>
@@ -35,31 +35,6 @@ const filteredRecords = computed(() => {
if (!query) return records.value;
return picoSearch(records.value, query, ['name', 'description']);
});
// Delayed (wait) rules run on a different lifecycle, so list them in their own section.
const hasDelayedRecords = computed(() =>
records.value.some(automation => automation.execution_delay)
);
const sections = computed(() => {
const instant = [];
const delayed = [];
filteredRecords.value.forEach(automation =>
(automation.execution_delay ? delayed : instant).push(automation)
);
return [
{
key: 'instant',
label: t('AUTOMATION.LIST.SECTIONS.INSTANT'),
items: instant,
},
{
key: 'delayed',
label: t('AUTOMATION.LIST.SECTIONS.DELAYED'),
items: delayed,
},
].filter(section => section.items.length);
});
const uiFlags = computed(() => getters['automations/getUIFlags'].value);
const accountId = computed(() => getters.getCurrentAccountId.value);
@@ -77,14 +52,6 @@ const isSLAEnabled = computed(() =>
getters['accounts/isFeatureEnabledonAccount'].value(accountId.value, 'sla')
);
const showDelayDisabledBanner = computed(
() =>
!getters['accounts/isFeatureEnabledonAccount'].value(
accountId.value,
'delayed_automations'
) && records.value.some(automation => automation.execution_delay)
);
onMounted(() => {
store.dispatch('inboxes/get');
store.dispatch('agents/get');
@@ -107,7 +74,7 @@ const hideAddPopup = () => {
const openEditPopup = response => {
selectedAutomation.value = { ...response };
editDialogRef.value?.open(response);
editDialogRef.value?.open();
};
const hideEditPopup = () => {
editDialogRef.value?.close();
@@ -161,11 +128,11 @@ const submitAutomation = async (payload, mode) => {
hideAddPopup();
hideEditPopup();
} catch (error) {
const fallbackMessage =
const errorMessage =
mode === 'edit'
? t('AUTOMATION.EDIT.API.ERROR_MESSAGE')
: t('AUTOMATION.ADD.API.ERROR_MESSAGE');
useAlert(error?.response?.data?.error || fallbackMessage);
useAlert(errorMessage);
}
};
const toggleAutomation = async ({ id, name, status }) => {
@@ -245,49 +212,25 @@ const tableHeaders = computed(() => {
</BaseSettingsHeader>
</template>
<template #body>
<div
v-if="showDelayDisabledBanner"
class="px-4 py-3 mb-4 text-sm rounded-lg bg-n-amber-3 text-n-amber-12"
>
{{ $t('AUTOMATION.LIST.DELAY_DISABLED_BANNER') }}
</div>
<template v-if="filteredRecords.length">
<div
v-for="section in sections"
:key="section.key"
class="mb-6 last:mb-0"
>
<h4
v-if="hasDelayedRecords"
class="mb-2 text-sm font-medium text-n-slate-11"
>
{{ section.label }}
</h4>
<BaseTable :headers="tableHeaders" :items="section.items">
<template #row="{ items }">
<AutomationRuleRow
v-for="automation in items"
:key="automation.id"
:automation="automation"
:loading="loading[automation.id]"
@clone="cloneAutomation"
@toggle="toggleAutomation"
@edit="openEditPopup"
@delete="openDeletePopup"
/>
</template>
</BaseTable>
</div>
</template>
<BaseTable
v-else
:headers="tableHeaders"
:items="[]"
:items="filteredRecords"
:no-data-message="
searchQuery ? $t('AUTOMATION.NO_RESULTS') : $t('AUTOMATION.LIST.404')
"
>
<template #row />
<template #row="{ items }">
<AutomationRuleRow
v-for="automation in items"
:key="automation.id"
:automation="automation"
:loading="loading[automation.id]"
@clone="cloneAutomation"
@toggle="toggleAutomation"
@edit="openEditPopup"
@delete="openDeletePopup"
/>
</template>
</BaseTable>
</template>
@@ -103,6 +103,7 @@ export default {
<label :class="{ error: v$.selectedAgentIds.$error }">
{{ $t('INBOX_MGMT.ADD.AGENTS.TITLE') }}
<div
data-testid="agent-selector"
class="rounded-xl outline outline-1 -outline-offset-1 outline-n-weak hover:outline-n-strong px-2 py-2"
>
<TagInput
@@ -4,8 +4,6 @@ import { shouldBeUrl } from 'shared/helpers/Validators';
import { useAlert } from 'dashboard/composables';
import { useVuelidate } from '@vuelidate/core';
import Avatar from 'next/avatar/Avatar.vue';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
@@ -46,11 +44,9 @@ import SelectInput from 'dashboard/components-next/select/Select.vue';
import Widget from 'dashboard/modules/widget-preview/components/Widget.vue';
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
export default {
components: {
Banner,
BotConfiguration,
CollaboratorsPage,
ConfigurationPage,
@@ -84,7 +80,6 @@ export default {
WhatsappManualMigrationBanner,
Widget,
AccessToken,
Icon,
},
mixins: [inboxMixin],
setup() {
@@ -286,8 +281,12 @@ export default {
return this.$store.getters['inboxes/getInbox'](this.currentInboxId);
},
inboxIcon() {
const { medium, channel_type: type } = this.inbox;
return getInboxIconByType(type, medium, 'line');
const {
medium,
channel_type: type,
voice_enabled: voiceEnabled,
} = this.inbox;
return getInboxIconByType(type, medium, 'line', voiceEnabled);
},
bannerMaxWidth() {
const narrowTabs = ['collaborators', 'bot-configuration'];
@@ -348,12 +347,6 @@ export default {
instagramUnauthorized() {
return this.isAnInstagramChannel && this.inbox.reauthorization_required;
},
showInstagramRestrictionSettingsBanner() {
return this.isOnChatwootCloud && this.isAnInstagramChannel;
},
metaRestrictionStatusUrl() {
return META_RESTRICTION_STATUS_URL;
},
tiktokUnauthorized() {
return this.isATiktokChannel && this.inbox.reauthorization_required;
},
@@ -820,29 +813,6 @@ export default {
:class="bannerMaxWidth"
@start="openWhatsAppManualMigrationDialog"
/>
<Banner
v-if="showInstagramRestrictionSettingsBanner"
color="amber"
class="mx-6 mb-4 max-w-4xl"
>
<div class="flex items-start gap-3 text-start">
<Icon
icon="i-lucide-triangle-alert"
class="flex-shrink-0 size-4 mt-0.5"
/>
<span>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.SETTINGS_RESTRICTED_WARNING') }}
<a
:href="metaRestrictionStatusUrl"
class="link underline"
rel="noopener noreferrer nofollow"
target="_blank"
>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
</a>
</span>
</div>
</Banner>
<div
v-if="selectedTabKey === 'inbox-settings'"
+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,68 +0,0 @@
class AutomationRules::ProcessPendingExecutionJob < ApplicationJob
queue_as :medium
discard_on ActiveJob::DeserializationError
def perform(pending_execution)
return if delayed_automations_disabled?
# Account flag off pauses (not skips): leave the row pending so re-enabling resumes it.
return unless pending_execution.account.feature_enabled?('delayed_automations')
# Atomic claim: a duplicate enqueue (overlapping sweep or stale reclaim) loses here and returns.
return unless pending_execution.claim!
skip_reason = skip_reason_for(pending_execution)
return pending_execution.update!(status: :skipped, skip_reason: skip_reason) if skip_reason
execute(pending_execution)
rescue StandardError => e
# Row stays `processing`; the next sweep reclaims and retries it once the lock goes stale.
ChatwootExceptionTracker.new(e, account: pending_execution.account).capture_exception
end
private
def skip_reason_for(pending_execution)
return 'expired' if pending_execution.due_at < AutomationRulePendingExecution::DUE_WINDOW.ago
structural_skip_reason(pending_execution) || behavioral_skip_reason(pending_execution)
end
def structural_skip_reason(pending_execution)
rule = pending_execution.automation_rule
return 'rule_inactive' if rule.nil? || !rule.active?
return 'conversation_gone' if pending_execution.conversation.nil?
nil
end
def behavioral_skip_reason(pending_execution)
return 'episode_moved' unless pending_execution.episode_current?
return AutomationRulePendingExecution::CONDITIONS_CHANGED_SKIP unless conditions_still_match?(pending_execution)
nil
end
def conditions_still_match?(pending_execution)
AutomationRules::ConditionsFilterService.new(
pending_execution.automation_rule,
pending_execution.conversation,
{ message: pending_execution.message }
).perform.present?
end
# Marked before the actions run: a row that dies here stays `executing`, which no sweep reclaims,
# so a message/email/webhook is never sent twice. Everything up to this point is still retryable.
def execute(pending_execution)
pending_execution.update!(status: :executing)
AutomationRules::ActionService.new(
pending_execution.automation_rule,
pending_execution.account,
pending_execution.conversation
).perform
pending_execution.update!(status: :executed)
end
def delayed_automations_disabled?
GlobalConfig.get('DISABLE_DELAYED_AUTOMATIONS')['DISABLE_DELAYED_AUTOMATIONS']
end
end
@@ -1,11 +0,0 @@
class AutomationRules::ResumePausedExecutionsJob < ApplicationJob
# Enqueued the moment the account flag flips back on, ahead of the next sweep, so overdue rows
# are rescheduled before that sweep's per-row jobs could mark them expired.
queue_as :medium
discard_on ActiveJob::DeserializationError
def perform(account)
AutomationRulePendingExecution.reschedule_paused(account)
end
end
@@ -1,34 +0,0 @@
class AutomationRules::TriggerPendingExecutionsJob < ApplicationJob
queue_as :scheduled_jobs
DEFAULT_SWEEP_LIMIT = 1000
def perform
return if delayed_automations_disabled?
started_at = Time.current
purged = AutomationRulePendingExecution.purge_terminal!
rows = AutomationRulePendingExecution.sweepable.for_enabled_accounts.order(:due_at).limit(sweep_limit).to_a
rows.each { |row| AutomationRules::ProcessPendingExecutionJob.perform_later(row) }
log_summary(enqueued: rows.size, capped: rows.size >= sweep_limit, purged: purged,
abandoned: AutomationRulePendingExecution.abandoned.count, started_at: started_at)
end
private
def delayed_automations_disabled?
GlobalConfig.get('DISABLE_DELAYED_AUTOMATIONS')['DISABLE_DELAYED_AUTOMATIONS']
end
def sweep_limit
(InstallationConfig.find_by(name: 'AUTOMATION_PENDING_EXECUTIONS_SWEEP_LIMIT')&.value || DEFAULT_SWEEP_LIMIT).to_i
end
def log_summary(enqueued:, capped:, purged:, abandoned:, started_at:)
summary = { event: 'completed', enqueued: enqueued, capped: capped, purged: purged, abandoned: abandoned,
duration_ms: ((Time.current - started_at) * 1000).round }
Rails.logger.info("[AutomationRules::TriggerPendingExecutionsJob] #{summary.to_json}")
end
end
-3
View File
@@ -19,9 +19,6 @@ class TriggerScheduledItemsJob < ApplicationJob
# Job to sync whatsapp templates
Channels::Whatsapp::TemplatesSyncSchedulerJob.perform_later
# Job to trigger pending executions
AutomationRules::TriggerPendingExecutionsJob.perform_later
end
end
+5 -28
View File
@@ -30,7 +30,7 @@ class AutomationRuleListener < BaseListener
rules.each do |rule|
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, message.conversation,
{ message: message, changed_attributes: changed_attributes }).perform
execute_rule(rule, account, message.conversation, message: message) if conditions_match.present?
::AutomationRules::ActionService.new(rule, account, message.conversation).perform if conditions_match.present?
end
end
@@ -46,36 +46,13 @@ class AutomationRuleListener < BaseListener
account = conversation.account
changed_attributes = event.data[:changed_attributes]
rules = conversation_rules(event_name, account)
return if rules.blank?
return unless rule_present?(event_name, account)
rules = current_account_rules(event_name, account)
rules.each do |rule|
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
execute_rule(rule, account, conversation) if conditions_match.present?
end
end
# A delayed conversation rule reads as "the conversation has been in this status for N minutes",
# so a conversation created in that status must arm it too. Creation never dispatches
# CONVERSATION_UPDATED, and both paths key the episode on the same status_changed_at, so a later
# update arming the same episode is deduped by the unique index.
def conversation_rules(event_name, account)
rules = current_account_rules(event_name, account)
return rules unless event_name == 'conversation_created'
rules + current_account_rules('conversation_updated', account).where.not(execution_delay: nil)
end
# Delayed rules record a pending execution instead of acting; the sweep re-checks and
# runs them at due time. Flag off means no arming and no immediate fallback — a delayed
# message silently becoming instant is worse than skipping.
def execute_rule(rule, account, conversation, message: nil)
if rule.execution_delay.present?
return unless account.feature_enabled?('delayed_automations')
AutomationRulePendingExecution.schedule(rule: rule, conversation: conversation, message: message)
else
::AutomationRules::ActionService.new(rule, account, conversation).perform
AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
end
end
-6
View File
@@ -65,7 +65,6 @@ class Account < ApplicationRecord
has_many :articles, dependent: :destroy_async, class_name: '::Article'
has_many :assignment_policies, dependent: :destroy_async
has_many :automation_rules, dependent: :destroy_async
has_many :automation_rule_pending_executions, dependent: :delete_all
has_many :macros, dependent: :destroy_async
has_many :campaigns, dependent: :destroy_async
has_many :canned_responses, dependent: :destroy_async
@@ -112,7 +111,6 @@ class Account < ApplicationRecord
before_validation :validate_limit_keys
after_create_commit :notify_creation
after_update_commit :clear_unread_conversation_counts_cache, if: :saved_change_to_feature_conversation_unread_counts?
after_update_commit :resume_delayed_automations, if: -> { saved_change_to_feature_delayed_automations? && feature_delayed_automations? }
after_destroy :remove_account_sequences
def agents
@@ -191,10 +189,6 @@ class Account < ApplicationRecord
::Conversations::UnreadCounts::Store.clear_account!(id)
end
def resume_delayed_automations
AutomationRules::ResumePausedExecutionsJob.perform_later(self)
end
trigger.after(:insert).for_each(:row) do
"execute format('create sequence IF NOT EXISTS conv_dpid_seq_%s', NEW.id);"
end
+10 -51
View File
@@ -2,17 +2,16 @@
#
# Table name: automation_rules
#
# id :bigint not null, primary key
# actions :jsonb not null
# active :boolean default(TRUE), not null
# conditions :jsonb not null
# description :text
# event_name :string not null
# execution_delay :integer
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# id :bigint not null, primary key
# actions :jsonb not null
# active :boolean default(TRUE), not null
# conditions :jsonb not null
# description :text
# event_name :string not null
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
@@ -22,13 +21,7 @@ class AutomationRule < ApplicationRecord
include Rails.application.routes.url_helpers
include Reauthorizable
EXECUTION_DELAY_RANGE = (10..43_200) # minutes: 10 min to 30 days
# Conversation-level delayed rules key their episode on status; only status and attributes
# that never change after the delay (inbox) are safe to also filter on.
DELAYED_CONVERSATION_ATTRIBUTES = %w[status inbox_id].freeze
belongs_to :account
has_many :pending_executions, class_name: 'AutomationRulePendingExecution', dependent: :delete_all
has_many_attached :files
validate :json_conditions_format
@@ -36,13 +29,8 @@ class AutomationRule < ApplicationRecord
validate :query_operator_presence
validate :query_operator_value
validates :account_id, presence: true
validates :execution_delay, numericality: { only_integer: true, in: EXECUTION_DELAY_RANGE }, allow_nil: true
validate :execution_delay_supported_conditions
validate :execution_delay_supported_event
after_update_commit :reauthorized!, if: -> { saved_change_to_conditions? }
# Discard rows armed under the old definition; they re-arm on the next matching event.
after_update :discard_stale_pending_executions, if: :execution_config_changed?
scope :active, -> { where(active: true) }
@@ -107,35 +95,6 @@ class AutomationRule < ApplicationRecord
end
end
# The fire-time re-check cannot reconstruct changed_attributes, so delayed rules
# cannot use attribute_changed conditions.
def execution_delay_supported_conditions
return if execution_delay.blank? || conditions.blank?
return if conditions.none? { |obj| obj['filter_operator'] == 'attribute_changed' }
errors.add(:execution_delay, 'cannot be used with attribute_changed conditions.')
end
# Conversation-level episodes key on status_changed_at alone. Mutable attributes would collapse
# distinct periods into one episode, so only status and immutable filters (inbox) are allowed.
def execution_delay_supported_event
return if execution_delay.blank? || conditions.blank? || event_name == 'message_created'
return if conditions.all? { |obj| DELAYED_CONVERSATION_ATTRIBUTES.include?(obj['attribute_key']) }
errors.add(:execution_delay, 'only supports status and inbox conditions for conversation-level events.')
end
def execution_config_changed?
saved_change_to_execution_delay? || saved_change_to_event_name? ||
saved_change_to_conditions? || saved_change_to_actions?
end
def discard_stale_pending_executions
# armed = pending + processing, the rows the sweep would otherwise still run. Rows already
# executing are left alone: their actions are in flight and cannot be called back.
pending_executions.armed.delete_all
end
def validate_single_condition(condition)
query_operator = condition['query_operator']
@@ -1,204 +0,0 @@
# == Schema Information
#
# Table name: automation_rule_pending_executions
#
# id :bigint not null, primary key
# due_at :datetime not null
# episode_key :string not null
# skip_reason :string
# status :integer default("pending"), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# automation_rule_id :bigint not null
# conversation_id :bigint not null
# message_id :bigint
#
# Indexes
#
# index_automation_rule_pending_executions_on_account_id (account_id)
# index_automation_rule_pending_executions_on_automation_rule_id (automation_rule_id)
# index_automation_rule_pending_executions_on_conversation_id (conversation_id)
# index_automation_rule_pending_executions_on_status_and_due_at (status,due_at)
# uniq_automation_pending_execution_episode (automation_rule_id,conversation_id,episode_key) UNIQUE
#
class AutomationRulePendingExecution < ApplicationRecord
# Rows older than this never fire (bounds backlog replay after downtime).
DUE_WINDOW = 3.days
# A processing row whose lock is older than this is treated as abandoned and reclaimed.
STALE_PROCESSING_TIMEOUT = 15.minutes
# Terminal rows are purged after this to keep the table bounded.
RETENTION_WINDOW = 30.days
# Skip reason for a row cancelled only because conditions no longer matched at fire time; unlike
# other terminal reasons, a later qualifying message can re-arm it (see .schedule).
CONDITIONS_CHANGED_SKIP = 'conditions_changed'.freeze
belongs_to :automation_rule
belongs_to :conversation
belongs_to :account
belongs_to :message, optional: true
# `processing` is claimed but not yet acting, so it is safe to reclaim and retry. `executing`
# means the actions are running: a row that dies there is never replayed, because the actions
# are customer-facing (messages, emails, webhooks) and repeating them is worse than dropping them.
enum status: { pending: 0, processing: 1, executed: 2, skipped: 3, executing: 4 }
# Rows a sweep should hand to a worker: due pending rows, plus processing rows whose lock went stale.
scope :sweepable, lambda {
pending.where(due_at: ..Time.current).or(processing.where(updated_at: ...STALE_PROCESSING_TIMEOUT.ago))
}
# Rows whose worker died mid-action. Nothing reclaims them; the sweep only counts them so a
# crash that strands customer-facing actions is visible instead of silent.
scope :abandoned, -> { executing.where(updated_at: ...STALE_PROCESSING_TIMEOUT.ago) }
# Non-terminal rows still bound to fire (a stale processing row is reclaimed by the sweep).
scope :armed, -> { where(status: [statuses[:pending], statuses[:processing]]) }
# Excludes rows whose account paused delayed automations, so one disabled account's backlog
# can't fill the sweep limit and starve enabled accounts (paused rows resume on re-enable).
scope :for_enabled_accounts, -> { joins(:account).merge(Account.feature_delayed_automations) }
def self.schedule(rule:, conversation:, message: nil)
# status_changed_at is only written from this feature onwards, so a conversation that predates it
# has no status clock. Anchoring on created_at would make every old conversation instantly
# overdue and fire on the next sweep; leave them for their next status change to arm.
return if message.nil? && conversation.status_changed_at.blank?
key = arm_episode_key_for(conversation, message)
anchor = arm_anchor_for(conversation, message)
create!(
automation_rule: rule, conversation: conversation, account_id: conversation.account_id,
message_id: message&.id, episode_key: key, due_at: rule.execution_delay.minutes.since(anchor)
)
rescue ActiveRecord::RecordNotUnique
rearm_or_advance_episode(rule, conversation, key, message, anchor)
end
# The episode is already armed. Status episodes keep their first clock (a status change would
# give a new key), so only message episodes advance or re-arm here.
def self.rearm_or_advance_episode(rule, conversation, key, message, anchor)
return unless message
due_at = rule.execution_delay.minutes.since(anchor)
row = find_by!(automation_rule_id: rule.id, conversation_id: conversation.id, episode_key: key)
# The lock (and the reload it does) makes the compare-and-write atomic. Two listeners racing on
# the same episode would otherwise both read the old message_id and let whichever wrote last
# win, so an older message could overwrite a newer one and pull due_at backwards.
row.with_lock do
# Jobs can arrive out of order; only a strictly newer message advances or re-arms, so a late
# older message can't pull due_at backwards and fire before the delay elapses.
next unless message.id > row.message_id
if row.condition_skipped?
# A message episode key can recur (no new incoming reply) while conditions swing back into
# match, so a later qualifying message re-arms the condition-only skip instead of dropping.
row.update!(status: :pending, skip_reason: nil, due_at: due_at, message_id: message.id)
elsif row.pending? || row.stale_processing?
# Track the newest qualifying message. Reply-chase advances due_at with each agent reply;
# awaiting-agent keeps its first clock (its anchor is the stable waiting_since, so due_at is
# unchanged). Re-anchoring a row whose worker died mid-run back to pending also keeps a
# stale reclaim from firing the old clock instead of the latest one.
row.update!(status: :pending, due_at: due_at, message_id: message.id)
end
end
end
# The wait is measured from when the qualifying event happened, not when this (possibly
# backlogged or retried) listener runs, so a late dispatch still fires on schedule. Mirrors
# the timestamps the episode keys track.
def self.arm_anchor_for(conversation, message)
if message.nil?
conversation.status_changed_at
elsif message.incoming?
conversation.waiting_since.presence || message.created_at
else
message.created_at
end
end
# waiting_since is written just after MESSAGE_CREATED dispatches, so it can still be nil when
# an awaiting-agent episode arms. It becomes the starting message's created_at, so use that
# here; the strict fire-time key (episode_key_for) then matches once waiting_since is settled.
def self.arm_episode_key_for(conversation, message)
return episode_key_for(conversation, message) unless message&.incoming? && conversation.waiting_since.blank?
"awaiting_agent:#{microsecond_stamp(message.created_at)}"
end
# Microsecond integer, not a float: epoch seconds carry ~16 significant digits, past float64's
# precision, so an in-memory timestamp (arm time) and its DB-reloaded value (fire time) would
# round to different floats. strftime is exact on both. Sub-second distinguishes rapid episodes.
def self.microsecond_stamp(time)
time&.strftime('%s%6N') || '0'
end
# Episode keys identify one qualifying stretch of conversation state; when the recomputed
# key no longer matches, the episode ended and the pending action is cancelled at fire time.
def self.episode_key_for(conversation, message)
if message.nil?
# Sub-second precision so a resolve→reopen inside one second still ends the episode.
# Integer microseconds (not a float) so an in-memory arm and a DB-reloaded fire agree.
"status:#{microsecond_stamp(conversation.status_changed_at)}"
elsif message.incoming?
# waiting_since is cleared on agent/bot reply, so a reply invalidates this episode. Strict
# here: at fire time a nil waiting_since means the agent replied (episode ended).
"awaiting_agent:#{microsecond_stamp(conversation.waiting_since)}"
else
# A new customer message changes the max incoming id, invalidating this episode.
"reply_chase:#{conversation.messages.incoming.maximum(:id) || 0}"
end
end
def self.purge_terminal!
where(status: [statuses[:executed], statuses[:skipped]], updated_at: ...RETENTION_WINDOW.ago)
.in_batches(of: 1000).delete_all
end
# Rows that came due while an account had delayed automations paused would expire the moment
# the sweep reaches them on resume. Reset their clock so pause/resume replays them (still
# subject to the fire-time episode/condition re-checks) instead of silently dropping them.
def self.reschedule_paused(account)
overdue = pending.where(account_id: account.id, due_at: ...DUE_WINDOW.ago)
overdue.find_each { |row| row.update!(due_at: Time.current) }
end
# Atomic claim: only one worker can move a row into processing, so a row re-enqueued by an
# overlapping sweep (or after a stale reclaim) cannot double-execute. Refreshing updated_at
# renews the lock, keeping the row out of the stale window while this worker holds it.
def claim!
with_lock do
next false unless claimable?
update!(status: :processing, updated_at: Time.current)
true
end
end
def episode_current?
self.class.episode_key_for(conversation, message) == episode_key
end
# The claim renews updated_at, so a processing row past the timeout means its worker died. Only
# then may a re-arm take the row back: pulling a live worker's row to pending would let the sweep
# claim it and run the same actions alongside the worker still executing them.
def stale_processing?
processing? && updated_at < STALE_PROCESSING_TIMEOUT.ago
end
def condition_skipped?
skipped? && skip_reason == CONDITIONS_CHANGED_SKIP
end
def terminal?
executed? || skipped?
end
private
def claimable?
# due_at guard: a reply-chase reschedule can push due_at forward after this row was enqueued;
# such a row must wait for a later sweep instead of firing early.
(pending? && due_at <= Time.current) || stale_processing?
end
end
-7
View File
@@ -15,7 +15,6 @@
# priority :integer
# snoozed_until :datetime
# status :integer default("open"), not null
# status_changed_at :datetime
# uuid :uuid not null
# waiting_since :datetime
# created_at :datetime not null
@@ -125,10 +124,8 @@ class Conversation < ApplicationRecord
has_many :notifications, as: :primary_actor, dependent: :destroy_async
has_many :attachments, through: :messages
has_many :reporting_events, dependent: :destroy_async
has_many :automation_rule_pending_executions, dependent: :delete_all
before_save :ensure_snooze_until_reset
before_save :set_status_changed_at
before_create :determine_conversation_status
before_create :ensure_waiting_since
@@ -275,10 +272,6 @@ class Conversation < ApplicationRecord
self.snoozed_until = nil unless snoozed?
end
def set_status_changed_at
self.status_changed_at = Time.current if new_record? || status_changed?
end
def ensure_waiting_since
self.waiting_since = created_at
end
@@ -7,5 +7,4 @@ json.conditions automation_rule.conditions
json.actions automation_rule.actions
json.created_on automation_rule.created_at.to_i
json.active automation_rule.active?
json.execution_delay automation_rule.execution_delay
json.files automation_rule.file_base_data if automation_rule.files.any?
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.16.0'
version: '4.16.1'
development:
<<: *shared
-5
View File
@@ -268,8 +268,3 @@
display_name: WhatsApp Embedded Signup Flow
enabled: false
column: feature_flags_ext_1
- name: delayed_automations
display_name: Delayed Automations
enabled: false
chatwoot_internal: true
column: feature_flags_ext_1
-7
View File
@@ -567,10 +567,3 @@
value: 'https://us.cloud.langfuse.com'
locked: false
## ---- End of LLM Observability ---- ##
- name: DISABLE_DELAYED_AUTOMATIONS
display_title: 'Disable delayed automations'
description: 'Emergency stop for delayed automation rules: halts the pending-execution sweep and per-row execution within one tick'
value: false
locked: false
type: boolean
+2 -1
View File
@@ -66,7 +66,8 @@ Rails.application.routes.draw do
resources :assistants do
member do
post :playground
get :stats
get :metrics
get :faq_stats
get :summary
get :drilldown
end
@@ -1,5 +0,0 @@
class AddExecutionDelayToAutomationRules < ActiveRecord::Migration[7.0]
def change
add_column :automation_rules, :execution_delay, :integer
end
end
@@ -1,5 +0,0 @@
class AddStatusChangedAtToConversations < ActiveRecord::Migration[7.0]
def change
add_column :conversations, :status_changed_at, :datetime
end
end
@@ -1,21 +0,0 @@
class CreateAutomationRulePendingExecutions < ActiveRecord::Migration[7.0]
def change
create_table :automation_rule_pending_executions do |t|
t.references :automation_rule, null: false
t.references :conversation, null: false
t.references :account, null: false
t.bigint :message_id
t.datetime :due_at, null: false
t.string :episode_key, null: false
t.integer :status, null: false, default: 0
t.string :skip_reason
t.timestamps
end
add_index :automation_rule_pending_executions, [:status, :due_at]
add_index :automation_rule_pending_executions,
[:automation_rule_id, :conversation_id, :episode_key],
unique: true, name: 'uniq_automation_pending_execution_episode'
end
end
-20
View File
@@ -277,24 +277,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
t.index ["user_id", "user_type"], name: "user_index"
end
create_table "automation_rule_pending_executions", force: :cascade do |t|
t.bigint "automation_rule_id", null: false
t.bigint "conversation_id", null: false
t.bigint "account_id", null: false
t.bigint "message_id"
t.datetime "due_at", null: false
t.string "episode_key", null: false
t.integer "status", default: 0, null: false
t.string "skip_reason"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_automation_rule_pending_executions_on_account_id"
t.index ["automation_rule_id", "conversation_id", "episode_key"], name: "uniq_automation_pending_execution_episode", unique: true
t.index ["automation_rule_id"], name: "index_automation_rule_pending_executions_on_automation_rule_id"
t.index ["conversation_id"], name: "index_automation_rule_pending_executions_on_conversation_id"
t.index ["status", "due_at"], name: "index_automation_rule_pending_executions_on_status_and_due_at"
end
create_table "automation_rules", force: :cascade do |t|
t.bigint "account_id", null: false
t.string "name", null: false
@@ -305,7 +287,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.boolean "active", default: true, null: false
t.integer "execution_delay"
t.index ["account_id"], name: "index_automation_rules_on_account_id"
end
@@ -807,7 +788,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
t.datetime "waiting_since"
t.text "cached_label_list"
t.bigint "assignee_agent_bot_id"
t.datetime "status_changed_at"
t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true
t.index ["account_id", "id"], name: "index_conversations_on_id_and_account_id"
t.index ["account_id", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx"
@@ -37,6 +37,23 @@ class Captain::AssistantStatsBuilder
build_metrics(current, previous)
end
# Approved/pending FAQ counts and the document total in a single round trip.
def faq_stats
approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick(
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"),
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['pending']})"),
Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})")
)
total = approved + pending
{
approved: approved,
pending: pending,
documents: documents,
coverage: total.zero? ? 0 : (approved.to_f / total * 100).round
}
end
private
attr_reader :window
@@ -56,8 +73,7 @@ class Captain::AssistantStatsBuilder
handoff_rate: pack(current[:handoff], previous[:handoff], :point),
hours_saved: pack(current[:hours_saved], previous[:hours_saved], :percent),
reopen_rate: pack(current[:reopen], previous[:reopen], :point),
conversation_depth: pack(current[:depth], previous[:depth], :absolute),
knowledge: knowledge
conversation_depth: pack(current[:depth], previous[:depth], :absolute)
}
end
@@ -73,7 +89,7 @@ class Captain::AssistantStatsBuilder
auto_resolution: rate(resolution[:resolved], handled),
handoff: rate(resolution[:handoff], handled),
hours_saved: (public_count * SECONDS_SAVED_PER_REPLY / 3600.0).round,
reopen: reopen_rate(range),
reopen: reopen_rate(range, resolution[:resolved]),
depth: depth_conversations.zero? ? 0 : (public_count.to_f / depth_conversations).round(1)
}
end
@@ -158,7 +174,9 @@ class Captain::AssistantStatsBuilder
# derived from the assistant's handled conversations (not current inbox membership) so a later
# inbox reassignment doesn't drop historical resolves, and covers both the evaluated (inference)
# and time-based (bot) resolve paths so the denominator matches auto_resolution_rate.
def reopen_rate(range)
def reopen_rate(range, resolved_count)
return 0 if resolved_count.zero?
resolved_scope = account.reporting_events
.where(name: RESOLVED_EVENT_NAMES, created_at: range,
conversation_id: handled_scope(range).select(:conversation_id))
@@ -178,24 +196,7 @@ class Captain::AssistantStatsBuilder
'ON resolves.conversation_id = reporting_events.conversation_id ' \
'AND reporting_events.event_end_time >= resolves.event_end_time')
.distinct.count('reporting_events.conversation_id')
rate(reopened, resolved_scope.distinct.count(:conversation_id))
end
# Approved/pending FAQ counts and the document total in a single round trip.
def knowledge
approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick(
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"),
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['pending']})"),
Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})")
)
total = approved + pending
{
approved: approved,
pending: pending,
documents: documents,
coverage: total.zero? ? 0 : (approved.to_f / total * 100).round
}
rate(reopened, resolved_count)
end
def rate(numerator, denominator)
@@ -1,7 +1,7 @@
class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :stats, :summary, :drilldown]
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :metrics, :faq_stats, :summary, :drilldown]
def index
@assistants = account_assistants.ordered
@@ -42,10 +42,14 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
@tools = assistant.available_agent_tools
end
def stats
def metrics
render json: Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]).metrics
end
def faq_stats
render json: Captain::AssistantStatsBuilder.new(@assistant).faq_stats
end
def summary
window = Captain::AssistantStatsWindow.new(params[:range], params[:timezone_offset])
result = cached_or_generated_summary(window, summary_stats)
@@ -1,6 +1,8 @@
module Enterprise::Api::V1::Accounts::AgentsController
def create
super
return if @agent.blank?
associate_agent_with_custom_role
end
+1 -1
View File
@@ -62,7 +62,7 @@ class CallFinder
end
def paginated_calls
@calls.includes(:contact, :inbox, :conversation, :accepted_by_agent)
@calls.includes(:contact, :conversation, :accepted_by_agent, inbox: :channel)
.order(created_at: :desc)
.page(@params[:page] || 1)
.per(RESULTS_PER_PAGE)
@@ -7,7 +7,11 @@ class Captain::AssistantPolicy < ApplicationPolicy
true
end
def stats?
def metrics?
true
end
def faq_stats?
true
end
@@ -19,6 +19,8 @@ end
json.inbox do
json.id call.inbox_id
json.name call.inbox.name
json.channel_type call.inbox.channel_type
json.medium call.inbox.channel.try(:medium)
end
if call.accepted_by_agent
+2 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "4.16.0",
"version": "4.16.1",
"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.22",
"@chatwoot/prosemirror-schema": "1.3.23",
"@chatwoot/utils": "^0.0.56",
"@formkit/core": "^1.7.2",
"@formkit/vue": "^1.7.2",
@@ -86,9 +86,6 @@
"mitt": "^3.0.1",
"opus-recorder": "^8.0.5",
"pinia": "^3.0.4",
"prosemirror-commands": "^1.7.1",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-schema-list": "^1.5.1",
"qrcode": "^1.5.4",
"semver": "7.6.3",
"snakecase-keys": "^8.0.1",
+6 -22
View File
@@ -25,8 +25,8 @@ importers:
specifier: 1.2.3
version: 1.2.3
'@chatwoot/prosemirror-schema':
specifier: 1.3.22
version: 1.3.22
specifier: 1.3.23
version: 1.3.23
'@chatwoot/utils':
specifier: ^0.0.56
version: 0.0.56
@@ -180,15 +180,6 @@ importers:
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2))
prosemirror-commands:
specifier: ^1.7.1
version: 1.7.1
prosemirror-inputrules:
specifier: ^1.4.0
version: 1.4.0
prosemirror-schema-list:
specifier: ^1.5.1
version: 1.5.1
qrcode:
specifier: ^1.5.4
version: 1.5.4
@@ -461,8 +452,8 @@ packages:
'@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
'@chatwoot/prosemirror-schema@1.3.22':
resolution: {integrity: sha512-0r+PT8xhQLCKCpoV9k9XVTTRECs/0Nr37wbcLsRS7yvc7WkF9FY05z2hGCRJReWmTOcmmshHtb042LVP+MyB/w==}
'@chatwoot/prosemirror-schema@1.3.23':
resolution: {integrity: sha512-jGxbWELCdlVI64BJiE1wT84ekJHYDXXKiluQIKT3aKPEjPwMR48umKF3A0yHjKoR7IIxCC9oM77TvXOA0ebLtw==}
'@chatwoot/utils@0.0.56':
resolution: {integrity: sha512-A6dmPLfTSrW4qYNY73btyi4PqpfzcXRSaucscZTQdzNqF6G/QUdgnBmHtho8HeiYby/kSHXaSxLJj+0dx3yEQQ==}
@@ -4001,9 +3992,6 @@ packages:
prosemirror-tables@1.5.0:
resolution: {integrity: sha512-VMx4zlYWm7aBlZ5xtfJHpqa3Xgu3b7srV54fXYnXgsAcIGRqKSrhiK3f89omzzgaAgAtDOV4ImXnLKhVfheVNQ==}
prosemirror-transform@1.10.0:
resolution: {integrity: sha512-9UOgFSgN6Gj2ekQH5CTDJ8Rp/fnKR2IkYfGdzzp5zQMFsS4zDllLVx/+jGcX86YlACpG7UR5fwAXiWzxqWtBTg==}
prosemirror-transform@1.12.0:
resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==}
@@ -5136,7 +5124,7 @@ snapshots:
hotkeys-js: 3.8.7
lit: 2.2.6
'@chatwoot/prosemirror-schema@1.3.22':
'@chatwoot/prosemirror-schema@1.3.23':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.7.1
@@ -9035,7 +9023,7 @@ snapshots:
dependencies:
prosemirror-model: 1.22.3
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.0
prosemirror-transform: 1.12.0
prosemirror-state@1.4.3:
dependencies:
@@ -9051,10 +9039,6 @@ snapshots:
prosemirror-transform: 1.12.0
prosemirror-view: 1.34.1
prosemirror-transform@1.10.0:
dependencies:
prosemirror-model: 1.22.3
prosemirror-transform@1.12.0:
dependencies:
prosemirror-model: 1.22.3
+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",
@@ -27,7 +27,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
expect(metrics.keys).to contain_exactly(
:conversations_handled, :auto_resolution_rate, :handoff_rate,
:hours_saved, :reopen_rate, :conversation_depth, :knowledge
:hours_saved, :reopen_rate, :conversation_depth
)
expect(metrics[:conversations_handled]).to include(:current, :previous, :trend)
end
@@ -229,7 +229,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
end
end
describe '#metrics knowledge' do
describe '#faq_stats' do
before do
create_list(:captain_assistant_response, 3, assistant: assistant, account: account, status: :approved)
create(:captain_assistant_response, assistant: assistant, account: account, status: :pending)
@@ -237,7 +237,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
end
it 'returns approved, pending, document counts and coverage' do
knowledge = described_class.new(assistant, '30').metrics[:knowledge]
knowledge = described_class.new(assistant).faq_stats
expect(knowledge).to eq(approved: 3, pending: 1, documents: 2, coverage: 75)
end
@@ -245,7 +245,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
it 'reports zero coverage when there are no responses' do
Captain::AssistantResponse.where(assistant: assistant).delete_all
knowledge = described_class.new(assistant, '30').metrics[:knowledge]
knowledge = described_class.new(assistant).faq_stats
expect(knowledge[:coverage]).to eq(0)
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
@@ -32,6 +32,7 @@ RSpec.describe 'Calls API', type: :request do
item = body['payload'].find { |c| c['id'] == agent_call.id }
expect(item['transcript']).to eq('hello world')
expect(item['contact']['phone_number']).to eq(contact.phone_number)
expect(item['inbox']).to include('id' => inbox.id, 'name' => inbox.name, 'channel_type' => inbox.channel_type)
end
it 'scopes the list to calls the agent accepted' do
@@ -12,7 +12,7 @@ RSpec.describe Captain::AssistantPolicy, type: :policy do
let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } }
permissions :index?, :show?, :playground? do
permissions :index?, :show?, :playground?, :metrics?, :faq_stats? do
context 'when administrator' do
it { expect(assistant_policy).to permit(administrator_context, assistant) }
end
@@ -0,0 +1,61 @@
import { Page } from '@playwright/test';
export class AddAgentModal {
private page: Page;
constructor(page: Page) {
this.page = page;
}
getModalTitle() {
return this.page.locator('[data-test-id="modal-header-title"]');
}
getAgentNameInput() {
return this.page.getByRole('textbox', { name: 'Agent Name' });
}
getEmailInput() {
return this.page.getByRole('textbox', { name: 'Email Address' });
}
getRoleCombobox() {
return this.page.getByRole('combobox', { name: 'Role' });
}
getSubmitButton() {
return this.page.locator('form').getByRole('button', { name: 'Add Agent' });
}
getCancelButton() {
return this.page.getByRole('button', { name: 'Cancel' });
}
getSuccessMessage() {
return this.page.getByText('Agent added successfully');
}
async fillAgentName(name: string) {
await this.getAgentNameInput().fill(name);
await this.page.waitForTimeout(1000);
}
async fillEmail(email: string) {
await this.getEmailInput().fill(email);
await this.page.waitForTimeout(1000);
}
async submitForm() {
await this.getSubmitButton().click();
}
async cancelForm() {
await this.getCancelButton().click();
}
async createAgent(name: string, email: string) {
await this.fillAgentName(name);
await this.fillEmail(email);
await this.submitForm();
}
}
@@ -0,0 +1,73 @@
import { Page } from '@playwright/test';
export class AddAgentsForm {
constructor(private page: Page) {}
getPageHeading() {
return this.page
.locator('form')
.getByRole('heading', { name: 'Agents', level: 2, exact: true });
}
getAgentDropdown() {
return this.page.getByPlaceholder('Pick agents for the inbox');
}
getAgentSelector() {
return this.page.getByTestId('agent-selector');
}
getAgentOption(agentName: string) {
return this.getAgentSelector().getByRole('button', {
name: agentName,
exact: true,
});
}
getDropdownButtons() {
return this.getAgentSelector().getByRole('button');
}
getSubmitButton() {
return this.page.getByRole('button', { name: 'Add agents' });
}
async openAgentDropdown() {
await this.getAgentDropdown().click();
}
async selectAgent(agentName: string) {
await this.openAgentDropdown();
await this.getAgentOption(agentName).waitFor({ state: 'visible' });
await this.getAgentOption(agentName).click();
}
async selectAgentByIndex(index: number = 0) {
await this.openAgentDropdown();
const buttons = this.getDropdownButtons();
await buttons.first().waitFor({ state: 'visible' });
await buttons.nth(index).click();
}
async closeDropdown() {
await this.page.keyboard.press('Escape');
}
async submitForm() {
await this.getSubmitButton().click();
}
async addAgents(agentNames: string[]) {
for (const agentName of agentNames) {
await this.selectAgent(agentName);
}
await this.closeDropdown();
await this.submitForm();
}
async addFirstAgent() {
await this.selectAgentByIndex(0);
await this.closeDropdown();
await this.submitForm();
}
}
@@ -0,0 +1,35 @@
import { Page } from '@playwright/test';
export class AgentPage {
private page: Page;
constructor(page: Page) {
this.page = page;
}
async navigate(accountId: number = 1) {
await this.page.goto(`/app/accounts/${accountId}/settings/agents/list`);
}
getPageHeading() {
return this.page.getByRole('heading', { name: 'Agents', level: 1 });
}
getDescriptionText() {
return this.page.getByText(
'An agent is a member of your customer support team who can view and respond to user messages.'
);
}
getLearnLink() {
return this.page.getByRole('link', { name: 'Learn about user roles' });
}
getAddAgentButton() {
return this.page.getByRole('button', { name: 'Add Agent' });
}
async openAddAgentModal() {
await this.getAddAgentButton().click();
}
}
@@ -0,0 +1,41 @@
import { Page } from '@playwright/test';
export class ApiChannelForm {
constructor(private page: Page) {}
getChannelNameInput() {
return this.page.getByRole('textbox', { name: 'Channel Name' });
}
getWebhookUrlInput() {
return this.page.getByRole('textbox', { name: 'Webhook URL' });
}
getSubmitButton() {
return this.page.getByRole('button', { name: 'Create API Channel' });
}
async fillChannelName(name: string) {
await this.getChannelNameInput().fill(name);
}
async fillWebhookUrl(url: string) {
await this.getWebhookUrlInput().fill(url);
}
async submitForm() {
await this.getSubmitButton().click();
}
async createApiChannel(channelName: string, webhookUrl?: string) {
await this.fillChannelName(channelName);
if (webhookUrl) {
await this.fillWebhookUrl(webhookUrl);
}
await this.submitForm();
}
getValidationError() {
return this.page.locator('.message, .error-message').first();
}
}
@@ -0,0 +1,25 @@
import { Page } from '@playwright/test';
export class ChannelSelector {
constructor(private page: Page) {}
getPageHeading() {
return this.page.getByRole('heading', { name: /choose channel/i });
}
getApiChannelCard() {
return this.page.getByRole('button', { name: /API.*Make a custom channel/i });
}
getWebsiteChannelCard() {
return this.page.getByRole('button', { name: /Website.*Create a live-chat widget/i });
}
async selectApiChannel() {
await this.getApiChannelCard().click();
}
async selectWebsiteChannel() {
await this.getWebsiteChannelCard().click();
}
}
@@ -0,0 +1,32 @@
import { Page } from '@playwright/test';
export class FinishSetup {
constructor(private page: Page) {}
getPageHeading() {
return this.page.getByRole('heading', {
name: 'Your Inbox is ready!',
exact: true,
});
}
getGoToInboxButton() {
return this.page.getByRole('button', { name: /go to inbox|view inbox/i });
}
getMoreSettingsButton() {
return this.page.getByRole('button', { name: /more settings|settings/i });
}
getWebhookUrl() {
return this.page.locator('code, pre').filter({ hasText: /http/i }).first();
}
async goToInbox() {
await this.getGoToInboxButton().click();
}
async goToSettings() {
await this.getMoreSettingsButton().click();
}
}
+7
View File
@@ -1 +1,8 @@
export { Login } from './login.component';
export { AgentPage } from './agent-page.component';
export { AddAgentModal } from './add-agent-modal.component';
export { AddAgentsForm } from './add-agents-form.component';
export { SettingsInboxPage } from './settings-inbox-page.component';
export { ChannelSelector } from './channel-selector.component';
export { ApiChannelForm } from './api-channel-form.component';
export { FinishSetup } from './finish-setup.component';
@@ -0,0 +1,57 @@
import { Page } from '@playwright/test';
type DashboardApi = {
delete: (url: string) => Promise<unknown>;
get: (url: string) => Promise<{
data: {
payload: Array<{ id: number }>;
};
}>;
};
export class SettingsInboxPage {
constructor(private page: Page) {}
async navigate(accountId: number = 1) {
await this.page.goto(`/app/accounts/${accountId}/settings/inboxes/list`);
}
getAddInboxButton() {
return this.page.getByRole('link', { name: 'Add Inbox' });
}
async clickAddInboxButton() {
await this.getAddInboxButton().click();
}
getPageHeading() {
return this.page.getByRole('heading', { name: /inboxes/i });
}
async deleteInbox(accountId: number, inboxId: number) {
await this.page.evaluate(
async ({ accountId: currentAccountId, inboxId: currentInboxId }) => {
const api = (window as typeof window & { axios: DashboardApi }).axios;
await api.delete(
`/api/v1/accounts/${currentAccountId}/inboxes/${currentInboxId}`
);
},
{ accountId, inboxId }
);
}
async isInboxPresent(accountId: number, inboxId: number) {
return this.page.evaluate(
async ({ accountId: currentAccountId, inboxId: currentInboxId }) => {
const api = (window as typeof window & { axios: DashboardApi }).axios;
const response = await api.get(
`/api/v1/accounts/${currentAccountId}/inboxes`
);
return response.data.payload.some(
inbox => inbox.id === currentInboxId
);
},
{ accountId, inboxId }
);
}
}
@@ -0,0 +1,60 @@
import { test, expect } from '@playwright/test';
import { AddAgentModal, AgentPage, Login } from '@components/ui';
const TEST_EMAIL = process.env.TEST_USER_EMAIL || 'admin@chatwoot.com';
const TEST_PASSWORD = process.env.TEST_USER_PASSWORD || 'Password123@#';
test.describe('Agent Onboarding - UI', () => {
let loginComponent: Login;
let agentPage: AgentPage;
let addAgentModal: AddAgentModal;
test.beforeEach(async ({ page }) => {
loginComponent = new Login(page);
agentPage = new AgentPage(page);
addAgentModal = new AddAgentModal(page);
await loginComponent.navigate();
await loginComponent.login(TEST_EMAIL, TEST_PASSWORD);
await expect(page).toHaveURL(/\/app\/accounts\/\d+\/dashboard/);
const accountId = Number(page.url().match(/\/app\/accounts\/(\d+)\//)![1]);
await agentPage.navigate(accountId);
});
test('should validate all UI elements on agents page', async () => {
await expect(agentPage.getPageHeading()).toBeVisible();
await expect(agentPage.getDescriptionText()).toBeVisible();
const learnLink = agentPage.getLearnLink();
await expect(learnLink).toBeVisible();
await expect(learnLink).toHaveAttribute('href', 'https://chwt.app/hc/agents');
await expect(agentPage.getAddAgentButton()).toBeVisible();
await agentPage.openAddAgentModal();
await expect(addAgentModal.getModalTitle()).toBeVisible();
await expect(addAgentModal.getModalTitle()).toHaveText('Add agent to your team');
await expect(addAgentModal.getAgentNameInput()).toBeVisible();
await expect(addAgentModal.getEmailInput()).toBeVisible();
await expect(addAgentModal.getRoleCombobox()).toBeVisible();
await expect(addAgentModal.getSubmitButton()).toBeVisible();
await expect(addAgentModal.getCancelButton()).toBeVisible();
await expect(addAgentModal.getSubmitButton()).toBeDisabled();
await addAgentModal.getAgentNameInput().fill('Test');
await expect(addAgentModal.getSubmitButton()).toBeDisabled();
await addAgentModal.getAgentNameInput().clear();
await addAgentModal.getEmailInput().fill('test@example.com');
await expect(addAgentModal.getSubmitButton()).toBeDisabled();
await addAgentModal.getAgentNameInput().fill('Test');
await expect(addAgentModal.getSubmitButton()).toBeEnabled();
await addAgentModal.cancelForm();
await expect(addAgentModal.getModalTitle()).toBeHidden();
});
});
@@ -0,0 +1,103 @@
import { test, expect } from '@playwright/test';
import {
AddAgentsForm,
ApiChannelForm,
ChannelSelector,
FinishSetup,
Login,
SettingsInboxPage,
} from '@components/ui';
const TEST_EMAIL = process.env.TEST_USER_EMAIL || 'admin@chatwoot.com';
const TEST_PASSWORD = process.env.TEST_USER_PASSWORD || 'Password123@#';
test.describe('Inbox Creation - UI Flow', () => {
const testInbox = {
name: `Test Inbox ${Date.now()}`,
webhookUrl: 'https://example.com/webhook',
};
let accountId: number | undefined;
let inboxId: number | undefined;
test.beforeEach(() => {
accountId = undefined;
inboxId = undefined;
});
test.afterEach(async ({ page }) => {
const currentAccountId = accountId;
const currentInboxId = inboxId;
if (!currentAccountId || !currentInboxId) {
return;
}
const settingsInboxPage = new SettingsInboxPage(page);
await settingsInboxPage.deleteInbox(currentAccountId, currentInboxId);
await expect
.poll(
() =>
settingsInboxPage.isInboxPresent(currentAccountId, currentInboxId),
{
message: `Inbox ${currentInboxId} was not deleted`,
timeout: 30_000,
}
)
.toBe(false);
});
test('should complete full inbox creation flow with UI validation', async ({
page,
}) => {
const loginComponent = new Login(page);
await loginComponent.navigate();
await loginComponent.login(TEST_EMAIL, TEST_PASSWORD);
await page.waitForURL(/\/app\/accounts\/\d+\/dashboard/);
accountId = Number(page.url().match(/\/app\/accounts\/(\d+)\//)![1]);
const settingsInboxPage = new SettingsInboxPage(page);
await settingsInboxPage.navigate(accountId);
await expect(settingsInboxPage.getPageHeading()).toBeVisible();
await expect(settingsInboxPage.getAddInboxButton()).toBeVisible();
await settingsInboxPage.clickAddInboxButton();
await page.waitForURL(/\/settings\/inboxes\/new/);
const channelSelector = new ChannelSelector(page);
await expect(channelSelector.getPageHeading()).toBeVisible();
await channelSelector.selectApiChannel();
page.on('response', async response => {
if (
response.url().includes('/api/v1/accounts/') &&
response.url().includes('/inboxes') &&
response.request().method() === 'POST' &&
response.status() === 200
) {
try {
const responseData = await response.json();
if (responseData.id) {
inboxId = responseData.id;
}
} catch {
// ignore non-JSON responses
}
}
});
const apiChannelForm = new ApiChannelForm(page);
await apiChannelForm.fillChannelName(testInbox.name);
await apiChannelForm.fillWebhookUrl(testInbox.webhookUrl);
await apiChannelForm.submitForm();
await expect.poll(() => inboxId).toBeTruthy();
const addAgentsForm = new AddAgentsForm(page);
await expect(addAgentsForm.getPageHeading()).toBeVisible();
await addAgentsForm.addFirstAgent();
await page.waitForURL(/\/settings\/inboxes\/.*\/finish/);
const finishSetup = new FinishSetup(page);
await expect(finishSetup.getPageHeading()).toBeVisible();
});
});
+20
View File
@@ -269,6 +269,16 @@ export const icons = {
width: 24,
height: 24,
},
'voice-call': {
body: `<mask id="cvc" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16"><rect width="16" height="16" fill="#fff"/><circle cx="12" cy="4" r="4" fill="#000"/></mask><g mask="url(#cvc)"><path d="M7.916 10.784a.5.5 0 0 0 .607-.152L8.7 10.4a1 1 0 0 1 .8-.4H11a1 1 0 0 1 1 1v1.5a1 1 0 0 1-1 1 9 9 0 0 1-9-9 1 1 0 0 1 1-1h1.5a1 1 0 0 1 1 1V6a1 1 0 0 1-.4.8l-.234.176a.5.5 0 0 0-.146.616 7 7 0 0 0 3.196 3.192" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"/></g><circle cx="12" cy="4" r="4" fill="currentColor" fill-opacity="0.15"/><path d="M10.4 3.2v1.6M12 2.4v3.2m1.6-2.4v1.6" stroke="currentColor" stroke-width=".833" stroke-linecap="round" stroke-linejoin="round"/>`,
width: 16,
height: 16,
},
'whatsapp-voice': {
body: `<mask id="cwv" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16"><rect width="16" height="16" fill="#fff"/><circle cx="12" cy="4.5" r="4" fill="#000"/></mask><g mask="url(#cwv)"><path fill-rule="evenodd" clip-rule="evenodd" d="M12.349 3.655A5.6 5.6 0 0 0 8.357 2a5.65 5.65 0 0 0-5.643 5.642c0 .995.26 1.966.753 2.821l-.512 1.873a.63.63 0 0 0 .767.775l1.936-.508a5.64 5.64 0 0 0 2.697.687h.002A5.65 5.65 0 0 0 14 7.647a5.6 5.6 0 0 0-1.652-3.992m-3.992 8.682h-.002a4.7 4.7 0 0 1-2.387-.654l-.171-.102-1.776.466.474-1.73-.111-.178a4.7 4.7 0 0 1-.717-2.496 4.697 4.697 0 0 1 4.692-4.69c1.253 0 2.43.489 3.316 1.376a4.66 4.66 0 0 1 1.372 3.317 4.697 4.697 0 0 1-4.69 4.69m2.573-3.513c-.141-.07-.835-.411-.964-.458-.13-.047-.223-.07-.317.07a8 8 0 0 1-.446.553c-.083.094-.165.106-.306.035s-.595-.22-1.134-.7a4.3 4.3 0 0 1-.784-.976c-.082-.141-.009-.218.061-.288.064-.063.141-.165.212-.247.07-.082.094-.141.141-.235s.024-.176-.012-.247c-.035-.07-.317-.765-.434-1.047-.115-.275-.231-.237-.318-.242a6 6 0 0 0-.27-.005.52.52 0 0 0-.376.177c-.13.14-.493.482-.493 1.176 0 .693.505 1.364.575 1.458.071.094.995 1.518 2.409 2.13.336.145.599.232.804.297.337.107.645.092.888.056.27-.041.834-.342.951-.671.118-.33.118-.612.083-.67-.035-.06-.13-.095-.27-.166" fill="currentColor"/></g><circle cx="12" cy="4.5" r="4" fill="currentColor" fill-opacity="0.15"/><path d="M10.4 3.7v1.6M12 2.9v3.2m1.6-2.4v1.6" stroke="currentColor" stroke-width=".833" stroke-linecap="round" stroke-linejoin="round"/>`,
width: 16,
height: 16,
},
instagram: {
body: `<g fill="none" stroke="currentColor"><path d="M12.0003 15.3329C13.8412 15.3329 15.3337 13.8405 15.3337 11.9996C15.3337 10.1586 13.8412 8.66626 12.0003 8.66626C10.1594 8.66626 8.66699 10.1586 8.66699 11.9996C8.66699 13.8405 10.1594 15.3329 12.0003 15.3329Z" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.5 15.3333V8.66667C4.5 6.36548 6.36548 4.5 8.66667 4.5H15.3333C17.6345 4.5 19.5 6.36548 19.5 8.66667V15.3333C19.5 17.6345 17.6345 19.5 15.3333 19.5H8.66667C6.36548 19.5 4.5 17.6345 4.5 15.3333Z" stroke-width="1.5"/><path d="M16.583 7.42552L16.5913 7.41626" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></g>`,
width: 24,
@@ -470,5 +480,15 @@ export const icons = {
width: 24,
height: 24,
},
'audio-play': {
body: `<path d="M3.31445 11.3998V2.6002C3.31445 2.35931 3.39947 2.15725 3.56951 1.99401C3.73955 1.83077 3.93793 1.74944 4.16465 1.75C4.2355 1.75 4.31004 1.76049 4.38826 1.78146C4.46647 1.80243 4.54073 1.83446 4.61101 1.87753L11.5401 6.27732C11.6677 6.36234 11.7635 6.46862 11.8275 6.59615C11.8916 6.72368 11.9233 6.85829 11.9227 6.99999C11.9222 7.14169 11.8904 7.27631 11.8275 7.40384C11.7646 7.53137 11.6688 7.63764 11.5401 7.72266L4.61101 12.1224C4.54016 12.165 4.46591 12.197 4.38826 12.2185C4.3106 12.2401 4.23607 12.2505 4.16465 12.25C3.93793 12.25 3.73955 12.1684 3.56951 12.0051C3.39947 11.8419 3.31445 11.6401 3.31445 11.3998Z" fill="currentColor"/>`,
width: 14,
height: 14,
},
'audio-pause': {
body: `<path d="M10.5001 1.75H8.75008C8.42792 1.75 8.16675 2.01117 8.16675 2.33333V11.6667C8.16675 11.9888 8.42792 12.25 8.75008 12.25H10.5001C10.8222 12.25 11.0834 11.9888 11.0834 11.6667V2.33333C11.0834 2.01117 10.8222 1.75 10.5001 1.75Z" fill="currentColor"/><path d="M5.25008 1.75H3.50008C3.17792 1.75 2.91675 2.01117 2.91675 2.33333V11.6667C2.91675 11.9888 3.17792 12.25 3.50008 12.25H5.25008C5.57225 12.25 5.83342 11.9888 5.83342 11.6667V2.33333C5.83342 2.01117 5.57225 1.75 5.25008 1.75Z" fill="currentColor"/>`,
width: 14,
height: 14,
},
/** Ends */
};