Compare commits

...
Author SHA1 Message Date
Pranav 76ec732640 feat(slack): add alerts-only integration mode
Adds an integration mode setting to the Slack integration. In the new
alerts_only mode, conversations continue to sync to the connected Slack
channel, but messages sent in Slack threads are never delivered to the
customer, so teams can use the channel for alerts and internal
discussion. Existing hooks are backfilled to two_way, keeping current
behavior unchanged.

Also fixes channel selection replacing hook settings wholesale, which
would have wiped the stored integration mode.
2026-07-22 15:23:36 -07:00
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
37 changed files with 458 additions and 151 deletions
+19 -3
View File
@@ -2,6 +2,14 @@
# It initializes with necessary attributes and provides a perform method
# to create a user and account user in a transaction.
class AgentBuilder
LIMIT_EXCEEDED_MESSAGE = 'Account limit exceeded. Please purchase more licenses'.freeze
class LimitExceededError < StandardError
def initialize
super(AgentBuilder::LIMIT_EXCEEDED_MESSAGE)
end
end
# Initializes an AgentBuilder with necessary attributes.
# @param email [String] the email of the user.
# @param name [String] the name of the user.
@@ -14,15 +22,23 @@ class AgentBuilder
# Creates a user and account user in a transaction.
# @return [User] the created user.
def perform
ActiveRecord::Base.transaction do
@user = find_or_create_user
create_account_user
account.with_lock do
raise LimitExceededError unless can_add_agent?
ActiveRecord::Base.transaction do
@user = find_or_create_user
create_account_user
end
end
@user
end
private
def can_add_agent?
account.usage_limits[:agents] > account.account_users.count
end
# Finds a user by email or creates a new one with a temporary password.
# @return [User] the found or created user.
def find_or_create_user
@@ -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)
@@ -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 }) {
@@ -29,6 +29,13 @@ class IntegrationsAPI extends ApiClient {
return axios.post(`${this.baseUrl()}/integrations/hooks`, hookData);
}
updateHook(hookId, hookData) {
return axios.patch(
`${this.baseUrl()}/integrations/hooks/${hookId}`,
hookData
);
}
deleteHook(hookId) {
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
}
@@ -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';
@@ -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.
@@ -132,6 +132,22 @@
"TITLE": "Delete the integration",
"MESSAGE": "Are you sure you want to delete the integration? Doing so will result in the loss of access to conversations on your Slack workspace."
},
"INTEGRATION_MODE": {
"TITLE": "Integration mode",
"DESCRIPTION": "Choose how your team uses the connected Slack channel.",
"TWO_WAY": {
"LABEL": "Two-way communication",
"DESCRIPTION": "Replies in Slack threads are sent to the customer. Start a message with 'note:' to keep it private."
},
"ALERTS_ONLY": {
"LABEL": "Alerts only",
"DESCRIPTION": "Conversations are synced to Slack for internal discussion, but messages sent in Slack threads never reach the customer. Replies to customers are sent from the dashboard."
},
"API": {
"SUCCESS_MESSAGE": "Integration mode updated successfully",
"ERROR_MESSAGE": "There was an error updating the integration mode, please try again"
}
},
"HELP_TEXT": {
"TITLE": "How to use the Slack Integration?",
"BODY": "With this integration, all of your incoming conversations will be synced to the ***{selectedChannelName}*** channel in your Slack workspace. You can manage all your customer conversations right within the channel and never miss a message.\n\nHere are the main features of the integration:\n\n**Respond to conversations from within Slack:** To respond to a conversation in the ***{selectedChannelName}*** Slack channel, simply type out your message and send it as a thread. This will create a response back to the customer through Chatwoot. It's that simple!\n\n **Create private notes:** If you want to create private notes instead of replies, start your message with ***`note:`***. This ensures that your message is kept private and won't be visible to the customer.\n\n**Associate an agent profile:** If the person who replied on Slack has an agent profile in Chatwoot under the same email, the replies will be associated with that agent profile automatically. This means you can easily track who said what and when. On the other hand, when the replier doesn't have an associated agent profile, the replies will appear from the bot profile to the customer.",
@@ -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>
@@ -5,6 +5,7 @@ import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import Integration from './Integration.vue';
import SelectChannelWarning from './Slack/SelectChannelWarning.vue';
import SlackIntegrationMode from './Slack/SlackIntegrationMode.vue';
import SlackIntegrationHelpText from './Slack/SlackIntegrationHelpText.vue';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
@@ -103,11 +104,12 @@ onMounted(() => {
),
}"
/>
<div v-if="areHooksAvailable" class="flex-1">
<div v-if="areHooksAvailable" class="flex-1 space-y-5">
<SelectChannelWarning
v-if="!isIntegrationHookEnabled"
:has-connected-a-channel="hasConnectedAChannel"
/>
<SlackIntegrationMode v-if="isIntegrationHookEnabled" :hook="hook" />
<SlackIntegrationHelpText
:selected-channel-name="selectedChannelName"
/>
@@ -61,7 +61,7 @@ const updateIntegration = async () => {
<template>
<div
class="px-6 py-4 mb-4 outline outline-n-container outline-1 bg-n-card rounded-xl"
class="px-6 py-4 outline outline-n-container outline-1 bg-n-card rounded-xl"
>
<div class="flex">
<div class="flex-shrink-0">
@@ -0,0 +1,91 @@
<script setup>
import { computed } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
const props = defineProps({
hook: {
type: Object,
required: true,
},
});
const store = useStore();
const { t } = useI18n();
const modeOptions = computed(() => [
{
value: 'two_way',
label: t('INTEGRATION_SETTINGS.SLACK.INTEGRATION_MODE.TWO_WAY.LABEL'),
description: t(
'INTEGRATION_SETTINGS.SLACK.INTEGRATION_MODE.TWO_WAY.DESCRIPTION'
),
},
{
value: 'alerts_only',
label: t('INTEGRATION_SETTINGS.SLACK.INTEGRATION_MODE.ALERTS_ONLY.LABEL'),
description: t(
'INTEGRATION_SETTINGS.SLACK.INTEGRATION_MODE.ALERTS_ONLY.DESCRIPTION'
),
},
]);
const selectedMode = computed(
() => props.hook.settings?.integration_mode || 'two_way'
);
const updateMode = async mode => {
if (mode === selectedMode.value) return;
try {
await store.dispatch('integrations/updateHook', {
hookId: props.hook.id,
settings: { ...props.hook.settings, integration_mode: mode },
});
useAlert(
t('INTEGRATION_SETTINGS.SLACK.INTEGRATION_MODE.API.SUCCESS_MESSAGE')
);
} catch (error) {
useAlert(
t('INTEGRATION_SETTINGS.SLACK.INTEGRATION_MODE.API.ERROR_MESSAGE')
);
}
};
</script>
<template>
<div
class="px-6 py-5 outline outline-n-container outline-1 bg-n-card rounded-xl"
>
<h5 class="mb-1 text-n-slate-12 text-heading-2">
{{ t('INTEGRATION_SETTINGS.SLACK.INTEGRATION_MODE.TITLE') }}
</h5>
<p class="text-n-slate-11 text-body-main">
{{ t('INTEGRATION_SETTINGS.SLACK.INTEGRATION_MODE.DESCRIPTION') }}
</p>
<div class="flex flex-col gap-3 mt-4">
<label
v-for="option in modeOptions"
:key="option.value"
class="flex items-start gap-3 cursor-pointer"
>
<input
type="radio"
name="slack-integration-mode"
:value="option.value"
:checked="selectedMode === option.value"
class="mt-1"
@change="updateMode(option.value)"
/>
<div>
<span class="text-n-slate-12 text-body-main font-medium">
{{ option.label }}
</span>
<p class="mb-0 text-n-slate-11 text-body-main">
{{ option.description }}
</p>
</div>
</label>
</div>
</div>
</template>
@@ -12,6 +12,7 @@ const state = {
isFetchingItem: false,
isUpdating: false,
isCreatingHook: false,
isUpdatingHook: false,
isDeletingHook: false,
isCreatingSlack: false,
isUpdatingSlack: false,
@@ -124,6 +125,17 @@ export const actions = {
throw error;
}
},
updateHook: async ({ commit }, { hookId, ...hookData }) => {
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isUpdatingHook: true });
try {
const response = await IntegrationsAPI.updateHook(hookId, hookData);
commit(types.default.UPDATE_INTEGRATION_HOOKS, response.data);
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isUpdatingHook: false });
} catch (error) {
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isUpdatingHook: false });
throw error;
}
},
deleteHook: async ({ commit }, { appId, hookId }) => {
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isDeletingHook: true });
try {
@@ -155,6 +167,17 @@ export const mutations = {
return record;
});
},
[types.default.UPDATE_INTEGRATION_HOOKS]: ($state, data) => {
$state.records = $state.records.map(record => {
if (record.id === data.app_id) {
return {
...record,
hooks: record.hooks.map(hook => (hook.id === data.id ? data : hook)),
};
}
return record;
});
},
[types.default.DELETE_INTEGRATION_HOOKS]: ($state, { appId, hookId }) => {
$state.records = $state.records.map(record => {
if (record.id === appId) {
@@ -126,6 +126,7 @@ export default {
ADD_INTEGRATION: 'ADD_INTEGRATION',
DELETE_INTEGRATION: 'DELETE_INTEGRATION',
ADD_INTEGRATION_HOOKS: 'ADD_INTEGRATION_HOOKS',
UPDATE_INTEGRATION_HOOKS: 'UPDATE_INTEGRATION_HOOKS',
DELETE_INTEGRATION_HOOKS: 'DELETE_INTEGRATION_HOOKS',
// WebHook
+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 -1
View File
@@ -79,7 +79,7 @@ slack:
action: https://slack.com/oauth/v2/authorize?scope=commands,chat:write,channels:read,channels:manage,channels:join,groups:read,groups:write,im:write,mpim:write,users:read,users:read.email,chat:write.customize,channels:history,groups:history,mpim:history,im:history,files:read,files:write
hook_type: account
allow_multiple_hooks: false
visible_properties: ['channel_name']
visible_properties: ['channel_name', 'integration_mode']
dialogflow:
id: dialogflow
logo: dialogflow.png
+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
@@ -0,0 +1,15 @@
class BackfillIntegrationModeOnSlackHooks < ActiveRecord::Migration[7.1]
def up
Integrations::Hook.where(app_id: 'slack').find_each do |hook|
next if hook.settings&.key?('integration_mode')
# update_column skips validations like ensure_feature_enabled, which would
# fail for accounts that disabled the slack_integration feature after connecting.
hook.update_column(:settings, (hook.settings || {}).merge('integration_mode' => 'two_way')) # rubocop:disable Rails/SkipsModelValidations
end
end
def down
# no-op: a missing integration_mode already behaves as two_way
end
end
+1 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
ActiveRecord::Schema[7.1].define(version: 2026_07_22_000000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -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
@@ -7,7 +7,11 @@ class Captain::AssistantPolicy < ApplicationPolicy
true
end
def stats?
def metrics?
true
end
def faq_stats?
true
end
+1 -1
View File
@@ -64,7 +64,7 @@ class Integrations::Slack::ChannelBuilder
return if channel.blank?
slack_client.conversations_join(channel: channel[:id]) if channel[:is_private] == false
@hook.update!(reference_id: channel[:id], settings: { channel_name: channel[:name] }, status: 'enabled')
@hook.update!(reference_id: channel[:id], settings: hook.settings.merge('channel_name' => channel[:name]), status: 'enabled')
@hook
end
end
@@ -68,7 +68,13 @@ class Integrations::Slack::IncomingMessageBuilder
end
def process_message_payload?
thread_timestamp_available? && supported_message? && integration_hook
thread_timestamp_available? && supported_message? && integration_hook && !alerts_only_mode?
end
# In alerts_only mode, the Slack channel is a notification/internal discussion
# channel — thread replies are never synced back to the conversation.
def alerts_only_mode?
integration_hook.settings['integration_mode'] == 'alerts_only'
end
def link_shared?
+1 -4
View File
@@ -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
@@ -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