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
12 changed files with 167 additions and 6 deletions
@@ -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}`);
}
@@ -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.",
@@ -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
+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
@@ -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"
+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?