Compare commits

...
Author SHA1 Message Date
Shivam Mishra a0438a8465 fix: help center perms 2025-05-29 13:30:59 +05:30
Sojan JoseandGitHub fdd35ff549 chore: Update Codespaces (#11621)
- Fix issues with the current Chatwoot development codespaces
- Switch from webpacket to vite 
- Add additional configs to make the development easier with codespaces
- toggles v4 feature true as default
2025-05-29 01:45:00 -06:00
Sivin VargheseandGitHub 2ea10ae065 fix: Snackbar notifications hidden behind modal dialogs (#11616) 2025-05-29 12:49:38 +05:30
23a804512a feat: Update the UI to support the change for Copilot as a universal copilot (#11618)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-05-29 12:35:10 +05:30
Shivam MishraandGitHub f6510e0d43 docs: add swagger spec for accounts API (#11620) 2025-05-29 11:23:27 +05:30
Sivin VargheseandGitHub c3d98fc064 chore: Move URL comparison logic to utils (#11617) 2025-05-29 10:19:56 +05:30
Muhsin KelothandGitHub b5ebc47637 fix: Send CSAT survey only when agent can reply in conversation (#11584)
Fixes https://github.com/chatwoot/chatwoot/issues/11569

 ## Problem
On platforms like WhatsApp and Facebook Messenger, customers cannot
reply to messages after 24 hours (or other channel-specific messaging
windows). Despite this limitation, the system continued sending CSAT
surveys to customers outside their messaging window, making it
impossible for them to respond.

  ## Solution
Added a check for `conversation.can_reply?` in the
`should_send_csat_survey?` method. This leverages the existing
`MessageWindowService` which already handles all channel-specific
messaging window logic.
2025-05-28 19:34:11 +05:30
Shivam MishraandGitHub f916fb2924 fix: handle empty customDomain when checking for isInternalLink (#11609)
This PR improves the portal's internal link detection logic to be more
robust when handling empty or undefined configuration values.
Previously, the code could fail when `customDomain` was empty, causing
external links to incorrectly behave as internal links. The fix
introduces a new `isSameOrigin` helper function that safely compares
URLs using proper URL parsing and origin comparison, gracefully handling
edge cases like missing domains, relative paths, and malformed URLs.
This ensures external links consistently open in new tabs regardless of
portal configuration completeness.
2025-05-28 17:56:32 +05:30
Sivin VargheseandGitHub dc335e88c9 fix: External links in widget not opening in new tab (#11608) 2025-05-28 15:15:05 +05:30
b1120ae7fb feat: allow searching articles in omnisearch (#11558)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-05-28 13:50:50 +05:30
443214e9a0 feat: add support for bunny CDN videos (#11601)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-05-28 13:50:43 +05:30
Shivam MishraandGitHub 3ce026e2bc feat: save timezone from leadsquared API (#11583) 2025-05-28 09:46:59 +05:30
PranavandGitHub f42fddd38e feat: Add stores for copilotMessages and copilotThreads (#11603)
- Set up stores for copilotThreads and copilotMessages.
- Add support for upsert messages to the copilotMessages store on
receiving ActionCable events.
- Implement support for the upsert option.
2025-05-27 18:36:32 -06:00
22b5e12a53 fix: use supported access method for schema_format in Rails 7 (#11576)
Description
In Rails 7.1+, accessing `schema_format` via `ActiveRecord::Base` is no
longer supported, causing a method error.
The correct approach is to use
`ActiveRecord.schema_format`, which aligns
with the public API.
This issue occurs locally because the environment uses newer versions of
Ruby and Rails than others.

Fixes: https://github.com/chatwoot/chatwoot/issues/11594

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2025-05-27 15:34:59 -06:00
03bde0a8aa fix: Truncate name in attachment bubble (#11540)
# Pull Request

## Description

This PR fixes a UI issue where very long attachment filenames (sometimes
including parameters or hashes) would overflow or break the layout in
the message bubble. The fix applies Tailwind's truncate utility class to
ensure these filenames are properly truncated with ellipsis, preserving
layout consistency and improving readability.

Fixes #11514 

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

## How Has This Been Tested?

- Manually tested with attachment messages containing long filenames
(e.g., with hashes, query params, or excessive length).
- Verified that filenames are now truncated with ellipsis.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
- [x] New and existing unit tests pass locally with my changes

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-05-27 14:24:43 -06:00
PranavandGitHub 3a0b5f387d fix: Update specs, add background response job implementation for copilot threads (#11600)
- Enable jobs by default when a copilot thread or a message is created.
- Rename thread_id to copilot_thread_id to keep it consistent with the
model name
- Add a spec for search_linear_issues service
2025-05-27 14:10:27 -06:00
Sivin VargheseandGitHub 9bd658137a feat: Scroll lock on message context menu (#11454)
This PR uses `useScrollLock` from `VueUse` to lock scrolling on the
conversation panel when the message context menu is open.
2025-05-23 16:12:18 +05:30
Sojan JoseandGitHub f73c5ef0b8 chore: Add short_description translations for integration apps (#11562)
- Add short_description translations for integration apps (dyte, slack,
dialogflow, google_translate, openai, linear, shopify)
- Remove fallback logic in NewHook.vue since I18n.t() returns
translation key string when missing (making fallback ineffective)
2025-05-23 01:25:13 -07:00
Shivam MishraandGitHub f9fce5e2df style: run rubocop with auto fix (#11563)
- fix rubocop issues
2025-05-23 01:23:33 -07:00
Sojan JoseandGitHub c2d8e2ad77 feat: move Slack config to installation settings (#11548)
- enable Slack Configuration via InstallationConfig
- list Slack integration in super admin settings
2025-05-23 01:07:35 -07:00
PranavandGitHub 03c0a7c62e feat: Add support for more tool, standardize copilot chat service (#11560) 2025-05-23 01:07:07 -07:00
d40a59f7fa feat: automate account deletion (#11406)
- Automate the deletion of accounts that have requested deletion via
account settings.
- Add a Sidekiq job that runs daily to find accounts that have requested
deletion and have passed the 7-day window.
- This job deletes the account and then soft-deletes users if they do
not belong to any other account.
- This job also sends an email to the Chatwoot instance admin for
compliance purposes.
- The Chatwoot instance admin's email is configurable via the
`CHATWOOT_INSTANCE_ADMIN_EMAIL` global config.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2025-05-23 12:58:13 +05:30
141 changed files with 4136 additions and 749 deletions
+11 -1
View File
@@ -4,5 +4,15 @@ FROM ghcr.io/chatwoot/chatwoot_codespace:latest
# Do the set up required for chatwoot app
WORKDIR /workspace
# Copy dependency files first for better caching
COPY package.json pnpm-lock.yaml ./
COPY Gemfile Gemfile.lock ./
# Install dependencies (will be cached if files don't change)
RUN pnpm install --frozen-lockfile && \
gem install bundler && \
bundle install --jobs=$(nproc)
# Copy source code after dependencies are installed
COPY . /workspace
RUN yarn && gem install bundler && bundle install
+65 -42
View File
@@ -1,12 +1,16 @@
ARG VARIANT
ARG VARIANT="ubuntu-22.04"
FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT}
ENV DEBIAN_FRONTEND=noninteractive
ARG NODE_VERSION
ARG RUBY_VERSION
ARG USER_UID
ARG USER_GID
ARG PNPM_VERSION="10.2.0"
ENV PNPM_VERSION ${PNPM_VERSION}
ENV RUBY_CONFIGURE_OPTS=--disable-install-doc
# Update args in docker-compose.yaml to set the UID/GID of the "vscode" user.
RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
@@ -15,61 +19,80 @@ RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
&& chmod -R $USER_UID:$USER_GID /home/vscode; \
fi
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends \
build-essential \
libssl-dev \
zlib1g-dev \
gnupg2 \
tar \
tzdata \
postgresql-client \
libpq-dev \
yarn \
git \
imagemagick \
tmux \
zsh \
git-flow \
npm \
libyaml-dev
RUN NODE_MAJOR=$(echo $NODE_VERSION | cut -d. -f1) \
&& curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
&& apt-get update \
&& apt-get -y install --no-install-recommends \
build-essential \
libssl-dev \
zlib1g-dev \
gnupg \
tar \
tzdata \
postgresql-client \
libpq-dev \
git \
imagemagick \
libyaml-dev \
curl \
ca-certificates \
tmux \
nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Install rbenv and ruby
RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv \
# Install rbenv and ruby for root user first
RUN git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv \
&& echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \
&& echo 'eval "$(rbenv init -)"' >> ~/.bashrc
ENV PATH "/root/.rbenv/bin/:/root/.rbenv/shims/:$PATH"
RUN git clone https://github.com/rbenv/ruby-build.git && \
RUN git clone --depth 1 https://github.com/rbenv/ruby-build.git && \
PREFIX=/usr/local ./ruby-build/install.sh
RUN rbenv install $RUBY_VERSION && \
rbenv global $RUBY_VERSION && \
rbenv versions
# Install overmind
# Set up rbenv for vscode user
RUN su - vscode -c "git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv" \
&& su - vscode -c "echo 'export PATH=\"\$HOME/.rbenv/bin:\$PATH\"' >> ~/.bashrc" \
&& su - vscode -c "echo 'eval \"\$(rbenv init -)\"' >> ~/.bashrc" \
&& su - vscode -c "PATH=\"/home/vscode/.rbenv/bin:\$PATH\" rbenv install $RUBY_VERSION" \
&& su - vscode -c "PATH=\"/home/vscode/.rbenv/bin:\$PATH\" rbenv global $RUBY_VERSION"
# Install overmind and gh in single layer
RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmind-v2.1.0-linux-amd64.gz > overmind.gz \
&& gunzip overmind.gz \
&& sudo mv overmind /usr/local/bin \
&& chmod +x /usr/local/bin/overmind
# Install gh
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& sudo apt update \
&& sudo apt install gh
&& mv overmind /usr/local/bin \
&& chmod +x /usr/local/bin/overmind \
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& apt-get update \
&& apt-get install -y --no-install-recommends gh \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Do the set up required for chatwoot app
WORKDIR /workspace
COPY . /workspace
RUN chown vscode:vscode /workspace
# set up ruby
COPY Gemfile Gemfile.lock ./
RUN gem install bundler && bundle install
# set up node js and pnpm in single layer
RUN npm install -g pnpm@${PNPM_VERSION} \
&& npm cache clean --force
# set up node js
RUN npm install n -g && \
n $NODE_VERSION
RUN npm install --global yarn
RUN yarn
# Switch to vscode user
USER vscode
ENV PATH="/home/vscode/.rbenv/bin:/home/vscode/.rbenv/shims:$PATH"
# Copy dependency files first for better caching
COPY --chown=vscode:vscode Gemfile Gemfile.lock package.json pnpm-lock.yaml ./
# Install dependencies as vscode user
RUN eval "$(rbenv init -)" \
&& gem install bundler -N \
&& bundle install --jobs=$(nproc) \
&& pnpm install --frozen-lockfile
# Copy source code after dependencies are installed
COPY --chown=vscode:vscode . /workspace
+4 -4
View File
@@ -23,15 +23,15 @@
// 5432 postgres
// 6379 redis
// 1025,8025 mailhog
"forwardPorts": [8025, 3000, 3035],
"forwardPorts": [8025, 3000, 3036],
"postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && yarn",
"postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && pnpm install",
"portsAttributes": {
"3000": {
"label": "Rails Server"
},
"3035": {
"label": "Webpack Dev Server"
"3036": {
"label": "Vite Dev Server"
},
"8025": {
"label": "Mailhog UI"
+18
View File
@@ -0,0 +1,18 @@
# Docker Compose file for building the base image in GitHub Actions
# Usage: docker-compose -f .devcontainer/docker-compose.base.yml build base
version: '3'
services:
base:
build:
context: ..
dockerfile: .devcontainer/Dockerfile.base
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
USER_GID: '1000'
image: ghcr.io/chatwoot/chatwoot_codespace:latest
-13
View File
@@ -5,19 +5,6 @@
version: '3'
services:
base:
build:
context: ..
dockerfile: .devcontainer/Dockerfile.base
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
USER_GID: '1000'
image: base:latest
app:
build:
context: ..
+2 -7
View File
@@ -2,12 +2,7 @@ cp .env.example .env
sed -i -e '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env
sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env
sed -i -e '/SMTP_ADDRESS/ s/=.*/=localhost/' .env
sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.githubpreview.dev/" .env
sed -i -e "/WEBPACKER_DEV_SERVER_PUBLIC/ s/=.*/=https:\/\/$CODESPACE_NAME-3035.githubpreview.dev/" .env
# uncomment the webpacker env variable
sed -i -e '/WEBPACKER_DEV_SERVER_PUBLIC/s/^# //' .env
# fix the error with webpacker
echo 'export NODE_OPTIONS=--openssl-legacy-provider' >> ~/.zshrc
sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.app.github.dev/" .env
# codespaces make the ports public
gh codespace ports visibility 3000:public 3035:public 8025:public -c $CODESPACE_NAME
gh codespace ports visibility 3000:public 3036:public 8025:public -c $CODESPACE_NAME
@@ -19,6 +19,5 @@ jobs:
- name: Build the Codespace Base Image
run: |
docker-compose -f .devcontainer/docker-compose.yml build base
docker tag base:latest ghcr.io/chatwoot/chatwoot_codespace:latest
docker compose -f .devcontainer/docker-compose.base.yml build base
docker push ghcr.io/chatwoot/chatwoot_codespace:latest
@@ -15,6 +15,10 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController
@result = search('Message')
end
def articles
@result = search('Article')
end
private
def search(search_type)
@@ -32,22 +32,17 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
end
def allowed_configs
@allowed_configs = case @config
when 'facebook'
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
when 'shopify'
%w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET]
when 'microsoft'
%w[AZURE_APP_ID AZURE_APP_SECRET]
when 'email'
['MAILER_INBOUND_EMAIL_DOMAIN']
when 'linear'
%w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET]
when 'instagram'
%w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
else
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
end
mapping = {
'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT],
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET],
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
}
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
end
end
@@ -0,0 +1,18 @@
/* global axios */
import ApiClient from '../ApiClient';
class CopilotMessages extends ApiClient {
constructor() {
super('captain/copilot_threads', { accountScoped: true });
}
get(threadId) {
return axios.get(`${this.url}/${threadId}/copilot_messages`);
}
create({ threadId, ...rest }) {
return axios.post(`${this.url}/${threadId}/copilot_messages`, rest);
}
}
export default new CopilotMessages();
@@ -0,0 +1,9 @@
import ApiClient from '../ApiClient';
class CopilotThreads extends ApiClient {
constructor() {
super('captain/copilot_threads', { accountScoped: true });
}
}
export default new CopilotThreads();
+9
View File
@@ -40,6 +40,15 @@ class SearchAPI extends ApiClient {
},
});
}
articles({ q, page = 1 }) {
return axios.get(`${this.url}/articles`, {
params: {
q,
page: page,
},
});
}
}
export default new SearchAPI();
@@ -3,7 +3,7 @@
}
.tabs--container--with-border {
@apply border-b border-n-weak;
@apply border-b border-b-n-weak;
}
.tabs--container--compact.tab--chat-type {
@@ -0,0 +1,87 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { computed } from 'vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useMapGetter } from 'dashboard/composables/store';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
const { updateUISettings } = useUISettings();
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showCopilotTab = computed(() =>
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
);
const { uiSettings } = useUISettings();
const isContactSidebarOpen = computed(
() => uiSettings.value.is_contact_sidebar_open
);
const isCopilotPanelOpen = computed(
() => uiSettings.value.is_copilot_panel_open
);
const toggleConversationSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: !isContactSidebarOpen.value,
is_copilot_panel_open: false,
});
};
const handleConversationSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: true,
is_copilot_panel_open: false,
});
};
const handleCopilotSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: true,
});
};
const keyboardEvents = {
'Alt+KeyO': {
action: toggleConversationSidebarToggle,
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div
class="flex flex-col justify-center items-center absolute top-24 ltr:right-2 rtl:left-2 bg-n-solid-2 border border-n-weak rounded-full gap-2 p-1"
>
<Button
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
ghost
slate
sm
class="!rounded-full"
:class="{
'bg-n-alpha-2': isContactSidebarOpen,
}"
icon="i-ph-user-bold"
@click="handleConversationSidebarToggle"
/>
<Button
v-if="showCopilotTab"
v-tooltip.bottom="$t('CONVERSATION.SIDEBAR.COPILOT')"
ghost
slate
class="!rounded-full"
:class="{
'bg-n-alpha-2 !text-n-iris-9': isCopilotPanelOpen,
}"
sm
icon="i-woot-captain"
@click="handleCopilotSidebarToggle"
/>
</div>
</template>
@@ -1,21 +1,29 @@
<script setup>
import CopilotHeader from './CopilotHeader.vue';
import SidebarActionsHeader from './SidebarActionsHeader.vue';
</script>
<template>
<Story
title="Captain/Copilot/CopilotHeader"
title="Components/SidebarActionsHeader"
:layout="{ type: 'grid', width: '800px' }"
>
<!-- Default State -->
<Variant title="Default State">
<CopilotHeader />
<SidebarActionsHeader title="Default State" />
</Variant>
<!-- With New Conversation Button -->
<Variant title="With New Conversation Button">
<!-- eslint-disable-next-line vue/prefer-true-attribute-shorthand -->
<CopilotHeader :has-messages="true" />
<SidebarActionsHeader
title="With New Conversation Button"
:buttons="[
{
key: 'new_conversation',
icon: 'i-lucide-plus',
},
]"
/>
</Variant>
</Story>
</template>
@@ -0,0 +1,47 @@
<script setup>
import Button from './button/Button.vue';
defineProps({
title: {
type: String,
required: true,
},
buttons: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['click', 'close']);
const handleButtonClick = button => {
emit('click', button.key);
};
</script>
<template>
<div
class="flex items-center justify-between px-4 py-2 border-b border-n-weak h-12"
>
<div class="flex items-center justify-between gap-2 flex-1">
<span class="font-medium text-sm text-n-slate-12">{{ title }}</span>
<div class="flex items-center">
<Button
v-for="button in buttons"
:key="button.key"
v-tooltip="button.tooltip"
:icon="button.icon"
ghost
sm
@click="handleButtonClick(button)"
/>
<Button
v-tooltip="$t('GENERAL.CLOSE')"
icon="i-lucide-x"
ghost
sm
@click="$emit('close')"
/>
</div>
</div>
</div>
</template>
@@ -3,13 +3,15 @@ import { nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useTrack } from 'dashboard/composables';
import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { useUISettings } from 'dashboard/composables/useUISettings';
import CopilotInput from './CopilotInput.vue';
import CopilotLoader from './CopilotLoader.vue';
import CopilotAgentMessage from './CopilotAgentMessage.vue';
import CopilotAssistantMessage from './CopilotAssistantMessage.vue';
import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue';
import Icon from '../icon/Icon.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
const props = defineProps({
supportAgent: {
@@ -54,10 +56,6 @@ const useSuggestion = opt => {
useTrack(COPILOT_EVENTS.SEND_SUGGESTED);
};
const handleReset = () => {
emit('reset');
};
const chatContainer = ref(null);
const scrollToBottom = async () => {
@@ -82,6 +80,21 @@ const promptOptions = [
},
];
const { updateUISettings } = useUISettings();
const closeCopilotPanel = () => {
updateUISettings({
is_copilot_panel_open: false,
is_contact_sidebar_open: false,
});
};
const handleSidebarAction = action => {
if (action === 'reset') {
emit('reset');
}
};
watch(
[() => props.messages, () => props.isCaptainTyping],
() => {
@@ -93,6 +106,18 @@ watch(
<template>
<div class="flex flex-col h-full text-sm leading-6 tracking-tight w-full">
<SidebarActionsHeader
:title="$t('CAPTAIN.COPILOT.TITLE')"
:buttons="[
{
key: 'reset',
icon: 'i-lucide-refresh-ccw',
tooltip: $t('CAPTAIN.COPILOT.RESET'),
},
]"
@click="handleSidebarAction"
@close="closeCopilotPanel"
/>
<div ref="chatContainer" class="flex-1 px-4 py-4 space-y-6 overflow-y-auto">
<template v-for="message in messages" :key="message.id">
<CopilotAgentMessage
@@ -139,14 +164,6 @@ watch(
@set-assistant="$event => emit('setAssistant', $event)"
/>
<div v-else />
<button
v-if="messages.length"
class="text-xs flex items-center gap-1 hover:underline"
@click="handleReset"
>
<i class="i-lucide-refresh-ccw" />
<span>{{ $t('CAPTAIN.COPILOT.RESET') }}</span>
</button>
</div>
<CopilotInput class="mb-1 w-full" @send="sendMessage" />
</div>
@@ -1,32 +0,0 @@
<script setup>
import Button from '../button/Button.vue';
defineProps({
hasMessages: {
type: Boolean,
default: false,
},
});
defineEmits(['reset', 'close']);
</script>
<template>
<div
class="flex items-center justify-between px-5 py-2 border-b border-n-weak h-12"
>
<div class="flex items-center justify-between gap-2 flex-1">
<span class="font-medium text-sm text-n-slate-12">
{{ $t('CAPTAIN.COPILOT.TITLE') }}
</span>
<div class="flex items-center">
<Button
v-if="hasMessages"
icon="i-lucide-plus"
ghost
sm
@click="$emit('reset')"
/>
<Button icon="i-lucide-x" ghost sm @click="$emit('close')" />
</div>
</div>
</div>
</template>
@@ -315,11 +315,7 @@ const componentToRender = computed(() => {
});
const shouldShowContextMenu = computed(() => {
return !(
props.status === MESSAGE_STATUS.FAILED ||
props.status === MESSAGE_STATUS.PROGRESS ||
props.contentAttributes?.isUnsupported
);
return !props.contentAttributes?.isUnsupported;
});
const isBubble = computed(() => {
@@ -344,12 +340,23 @@ const contextMenuEnabledOptions = computed(() => {
const hasAttachments = !!(props.attachments && props.attachments.length > 0);
const isOutgoing = props.messageType === MESSAGE_TYPES.OUTGOING;
const isFailedOrProcessing =
props.status === MESSAGE_STATUS.FAILED ||
props.status === MESSAGE_STATUS.PROGRESS;
return {
copy: hasText,
delete: hasText || hasAttachments,
cannedResponse: isOutgoing && hasText,
replyTo: !props.private && props.inboxSupportsReplyTo.outgoing,
delete:
(hasText || hasAttachments) &&
!isFailedOrProcessing &&
!isMessageDeleted.value,
cannedResponse: isOutgoing && hasText && !isMessageDeleted.value,
copyLink: !isFailedOrProcessing,
translate: !isFailedOrProcessing && !isMessageDeleted.value && hasText,
replyTo:
!props.private &&
props.inboxSupportsReplyTo.outgoing &&
!isFailedOrProcessing,
};
});
@@ -499,8 +506,8 @@ provideMessageContext({
<div
class="[grid-area:bubble] flex"
:class="{
'ltr:pl-8 rtl:pr-8 justify-end': orientation === ORIENTATION.RIGHT,
'ltr:pr-8 rtl:pl-8': orientation === ORIENTATION.LEFT,
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
}"
@contextmenu="openContextMenu($event)"
@@ -516,7 +523,7 @@ provideMessageContext({
</div>
<div v-if="shouldShowContextMenu" class="context-menu-wrap">
<ContextMenu
v-if="isBubble && !isMessageDeleted"
v-if="isBubble"
:context-menu-position="contextMenuPosition"
:is-open="showContextMenu"
:enabled-options="contextMenuEnabledOptions"
@@ -40,7 +40,7 @@ const senderName = computed(() => {
<Icon :icon="icon" class="text-white size-4" />
</slot>
</div>
<div class="space-y-1">
<div class="space-y-1 overflow-hidden">
<div v-if="senderName" class="text-n-slate-12 text-sm truncate">
{{
t(senderTranslationKey, {
@@ -519,7 +519,7 @@ const menuItems = computed(() => {
</div>
</section>
<nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
<ul class="flex flex-col gap-2 m-0 list-none">
<ul class="flex flex-col gap-1.5 m-0 list-none">
<SidebarGroup
v-for="item in menuItems"
:key="item.name"
@@ -8,7 +8,6 @@ import {
computed,
watch,
onMounted,
onUnmounted,
defineEmits,
} from 'vue';
import { useStore } from 'vuex';
@@ -44,7 +43,7 @@ import {
useSnakeCase,
} from 'dashboard/composables/useTransformKeys';
import { useEmitter } from 'dashboard/composables/emitter';
import { useEventListener, useScrollLock } from '@vueuse/core';
import { useEventListener } from '@vueuse/core';
import { emitter } from 'shared/helpers/mitt';
@@ -87,12 +86,8 @@ const store = useStore();
const conversationListRef = ref(null);
const conversationDynamicScroller = ref(null);
const conversationListScrollableElement = computed(
() => conversationDynamicScroller.value?.$el
);
const conversationListScrollLock = useScrollLock(
conversationListScrollableElement
);
provide('contextMenuElementTarget', conversationDynamicScroller);
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
@@ -746,7 +741,6 @@ function allSelectedConversationsStatus(status) {
function onContextMenuToggle(state) {
isContextMenuOpen.value = state;
conversationListScrollLock.value = state;
}
function toggleSelectAll(check) {
@@ -770,10 +764,6 @@ onMounted(() => {
}
});
onUnmounted(() => {
conversationListScrollLock.value = false;
});
provide('selectConversation', selectConversation);
provide('deSelectConversation', deSelectConversation);
provide('assignAgent', onAssignAgent);
@@ -824,7 +814,7 @@ watch(conversationFilters, (newVal, oldVal) => {
class="flex flex-col flex-shrink-0 bg-n-solid-1 conversations-list-wrap"
:class="[
{ hidden: !showConversationList },
isOnExpandedLayout ? 'basis-full' : 'w-[360px] 2xl:w-[420px]',
isOnExpandedLayout ? 'basis-full' : 'w-[340px] 2xl:w-[412px]',
]"
>
<slot />
@@ -80,16 +80,14 @@ const toggleConversationLayout = () => {
<template>
<div
class="flex items-center justify-between gap-2 px-4"
class="flex items-center justify-between gap-2 px-3 h-12"
:class="{
'pb-3 border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
'pt-3 pb-2': showV4View,
'mb-2 pb-0': !showV4View,
'border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
}"
>
<div class="flex items-center justify-center min-w-0">
<h1
class="text-lg font-medium truncate text-n-slate-12"
class="text-base font-medium truncate text-n-slate-12"
:title="pageTitle"
>
{{ pageTitle }}
@@ -20,7 +20,7 @@ export default {
<template>
<div>
<div
class="shadow-sm bg-slate-800 dark:bg-slate-700 rounded-[4px] items-center gap-3 inline-flex mb-2 max-w-[25rem] min-h-[1.875rem] min-w-[15rem] px-6 py-3 text-left"
class="shadow-sm bg-n-slate-12 dark:bg-n-slate-7 rounded-lg items-center gap-3 inline-flex mb-2 max-w-[25rem] min-h-[1.875rem] min-w-[15rem] px-6 py-3 text-left"
>
<div class="text-sm font-medium text-white dark:text-white">
{{ message }}
@@ -29,7 +29,7 @@ export default {
<router-link
v-if="action.type == 'link'"
:to="action.to"
class="font-medium cursor-pointer select-none text-woot-500 dark:text-woot-500 hover:text-woot-600 dark:hover:text-woot-600"
class="font-medium cursor-pointer select-none text-n-blue-10 hover:text-n-brand"
>
{{ action.message }}
</router-link>
@@ -1,62 +1,72 @@
<script>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import WootSnackbar from './Snackbar.vue';
import { emitter } from 'shared/helpers/mitt';
import { useI18n } from 'vue-i18n';
export default {
components: {
WootSnackbar,
},
props: {
duration: {
type: Number,
default: 2500,
},
const props = defineProps({
duration: {
type: Number,
default: 2500,
},
});
data() {
return {
snackMessages: [],
};
},
const { t } = useI18n();
mounted() {
emitter.on('newToastMessage', this.onNewToastMessage);
},
unmounted() {
emitter.off('newToastMessage', this.onNewToastMessage);
},
methods: {
onNewToastMessage({ message: originalMessage, action }) {
// FIX ME: This is a temporary workaround to pass string from functions
// that doesn't have the context of the VueApp.
const usei18n = action?.usei18n;
const duration = action?.duration || this.duration;
const message = usei18n ? this.$t(originalMessage) : originalMessage;
const snackMessages = ref([]);
const snackbarContainer = ref(null);
this.snackMessages.push({
key: new Date().getTime(),
message,
action,
});
window.setTimeout(() => {
this.snackMessages.splice(0, 1);
}, duration);
},
},
const showPopover = () => {
try {
const el = snackbarContainer.value;
if (el?.matches(':popover-open')) {
el.hidePopover();
}
el?.showPopover();
} catch (e) {
// ignore
}
};
const onNewToastMessage = ({ message: originalMessage, action }) => {
const message = action?.usei18n ? t(originalMessage) : originalMessage;
const duration = action?.duration || props.duration;
snackMessages.value.push({
key: Date.now(),
message,
action,
});
nextTick(showPopover);
setTimeout(() => {
snackMessages.value.shift();
}, duration);
};
onMounted(() => {
emitter.on('newToastMessage', onNewToastMessage);
});
onUnmounted(() => {
emitter.off('newToastMessage', onNewToastMessage);
});
</script>
<template>
<transition-group
name="toast-fade"
tag="div"
class="left-0 my-0 mx-auto max-w-[25rem] overflow-hidden absolute right-0 text-center top-4 z-[9999]"
<div
ref="snackbarContainer"
popover="manual"
class="fixed top-4 left-1/2 -translate-x-1/2 max-w-[25rem] w-[calc(100%-2rem)] text-center bg-transparent border-0 p-0 m-0 outline-none overflow-visible"
>
<WootSnackbar
v-for="snackMessage in snackMessages"
:key="snackMessage.key"
:message="snackMessage.message"
:action="snackMessage.action"
/>
</transition-group>
<transition-group name="toast-fade" tag="div">
<WootSnackbar
v-for="snackMessage in snackMessages"
:key="snackMessage.key"
:message="snackMessage.message"
:action="snackMessage.action"
/>
</transition-group>
</div>
</template>
@@ -1,6 +1,13 @@
<script setup>
import { computed, onMounted, nextTick, useTemplateRef } from 'vue';
import { useWindowSize, useElementBounding } from '@vueuse/core';
import {
computed,
onMounted,
nextTick,
onUnmounted,
useTemplateRef,
inject,
} from 'vue';
import { useWindowSize, useElementBounding, useScrollLock } from '@vueuse/core';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
@@ -11,27 +18,34 @@ const props = defineProps({
const emit = defineEmits(['close']);
const elementToLock = inject('contextMenuElementTarget', null);
const menuRef = useTemplateRef('menuRef');
const scrollLockElement = computed(() => {
if (!elementToLock?.value) return null;
return elementToLock.value?.$el;
});
const isLocked = useScrollLock(scrollLockElement);
const { width: windowWidth, height: windowHeight } = useWindowSize();
const { width: menuWidth, height: menuHeight } = useElementBounding(menuRef);
const calculatePosition = (x, y, menuW, menuH, windowW, windowH) => {
const PADDING = 16;
// Initial position
let left = x;
let top = y;
// Boundary checks
const isOverflowingRight = left + menuW > windowW;
const isOverflowingBottom = top + menuH > windowH;
const isOverflowingRight = left + menuW > windowW - PADDING;
const isOverflowingBottom = top + menuH > windowH - PADDING;
// Adjust position if overflowing
if (isOverflowingRight) left = windowW - menuW;
if (isOverflowingBottom) top = windowH - menuH;
if (isOverflowingRight) left = windowW - menuW - PADDING;
if (isOverflowingBottom) top = windowH - menuH - PADDING;
return {
left: Math.max(0, left),
top: Math.max(0, top),
left: Math.max(PADDING, left),
top: Math.max(PADDING, top),
};
};
@@ -54,8 +68,18 @@ const position = computed(() => {
});
onMounted(() => {
isLocked.value = true;
nextTick(() => menuRef.value?.focus());
});
const handleClose = () => {
isLocked.value = false;
emit('close');
};
onUnmounted(() => {
isLocked.value = false;
});
</script>
<template>
@@ -65,7 +89,7 @@ onMounted(() => {
class="fixed outline-none z-[9999] cursor-pointer"
:style="position"
tabindex="0"
@blur="emit('close')"
@blur="handleClose"
>
<slot />
</div>
@@ -48,12 +48,13 @@ useKeyboardEvents(keyboardEvents);
<template>
<woot-tabs
:index="activeTabIndex"
class="w-full px-4 py-0 tab--chat-type"
class="w-full px-3 -mt-1 py-0 tab--chat-type"
@change="onTabChange"
>
<woot-tabs-item
v-for="(item, index) in items"
:key="item.key"
class="text-sm"
:index="index"
:name="item.name"
:count="item.count"
@@ -4,17 +4,14 @@ import ConversationHeader from './ConversationHeader.vue';
import DashboardAppFrame from '../DashboardApp/Frame.vue';
import EmptyState from './EmptyState/EmptyState.vue';
import MessagesView from './MessagesView.vue';
import ConversationSidebar from './ConversationSidebar.vue';
export default {
components: {
ConversationSidebar,
ConversationHeader,
DashboardAppFrame,
EmptyState,
MessagesView,
},
props: {
inboxId: {
type: [Number, String],
@@ -34,7 +31,6 @@ export default {
default: true,
},
},
emits: ['contactPanelToggle'],
data() {
return { activeIndex: 0 };
},
@@ -86,9 +82,6 @@ export default {
}
this.$store.dispatch('conversationLabels/get', this.currentChat.id);
},
onToggleContactPanel() {
this.$emit('contactPanelToggle');
},
onDashboardAppTabChange(index) {
this.activeIndex = index;
},
@@ -98,7 +91,7 @@ export default {
<template>
<div
class="conversation-details-wrap bg-n-background"
class="conversation-details-wrap bg-n-background relative"
:class="{
'border-l rtl:border-l-0 rtl:border-r border-n-weak': !isOnExpandedLayout,
}"
@@ -107,14 +100,12 @@ export default {
v-if="currentChat.id"
:chat="currentChat"
:is-inbox-view="isInboxView"
:is-contact-panel-open="isContactPanelOpen"
:show-back-button="isOnExpandedLayout && !isInboxView"
@contact-panel-toggle="onToggleContactPanel"
/>
<woot-tabs
v-if="dashboardApps.length && currentChat.id"
:index="activeIndex"
class="-mt-px bg-white dashboard-app--tabs dark:bg-slate-900"
class="-mt-px dashboard-app--tabs border-t border-t-n-background"
@change="onDashboardAppTabChange"
>
<woot-tabs-item
@@ -130,18 +121,12 @@ export default {
v-if="currentChat.id"
:inbox-id="inboxId"
:is-inbox-view="isInboxView"
:is-contact-panel-open="isContactPanelOpen"
@contact-panel-toggle="onToggleContactPanel"
/>
<EmptyState
v-if="!currentChat.id && !isInboxView"
:is-on-expanded-layout="isOnExpandedLayout"
/>
<ConversationSidebar
v-if="showContactPanel"
:current-chat="currentChat"
@toggle-contact-panel="onToggleContactPanel"
/>
<slot />
</div>
<DashboardAppFrame
v-for="(dashboardApp, index) in dashboardApps"
@@ -243,7 +243,7 @@ export default {
<template>
<div
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-4 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-3 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
:class="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 border-n-weak':
isActiveChat,
@@ -278,7 +278,7 @@ export default {
:badge="inboxBadge"
:username="currentContact.name"
:status="currentContact.availability_status"
size="40px"
size="32px"
/>
</div>
<div
@@ -1,6 +1,5 @@
<script>
import { mapGetters } from 'vuex';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import BackButton from '../BackButton.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName.vue';
@@ -29,11 +28,7 @@ export default {
props: {
chat: {
type: Object,
default: () => {},
},
isContactPanelOpen: {
type: Boolean,
default: false,
default: () => ({}),
},
showBackButton: {
type: Boolean,
@@ -44,15 +39,6 @@ export default {
default: false,
},
},
emits: ['contactPanelToggle'],
setup(props, { emit }) {
const keyboardEvents = {
'Alt+KeyO': {
action: () => emit('contactPanelToggle'),
},
};
useKeyboardEvents(keyboardEvents);
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
@@ -99,13 +85,6 @@ export default {
}
return this.$t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
},
contactPanelToggleText() {
return `${
this.isContactPanelOpen
? this.$t('CONVERSATION.HEADER.CLOSE')
: this.$t('CONVERSATION.HEADER.OPEN')
} ${this.$t('CONVERSATION.HEADER.DETAILS')}`;
},
inbox() {
const { inbox_id: inboxId } = this.chat;
return this.$store.getters['inboxes/getInbox'](inboxId);
@@ -133,7 +112,7 @@ export default {
<template>
<div
class="flex flex-col items-center justify-between px-4 py-2 border-b bg-n-background border-n-weak md:flex-row"
class="flex flex-col items-center justify-between px-3 py-2 border-b bg-n-background border-n-weak md:flex-row h-12"
>
<div
class="flex flex-col items-center justify-center flex-1 w-full min-w-0"
@@ -150,6 +129,7 @@ export default {
:badge="inboxBadge"
:username="currentContact.name"
:status="currentContact.availability_status"
size="32px"
/>
<div
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
@@ -157,9 +137,9 @@ export default {
<div
class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit"
>
<NextButton link slate @click.prevent="$emit('contactPanelToggle')">
<NextButton link slate>
<span
class="text-base font-medium truncate leading-tight text-n-slate-12"
class="text-sm font-medium truncate leading-tight text-n-slate-12"
>
{{ currentContact.name }}
</span>
@@ -180,19 +160,11 @@ export default {
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
{{ snoozedDisplayText }}
</span>
<NextButton
link
xs
blue
:label="contactPanelToggleText"
@click="$emit('contactPanelToggle')"
/>
</div>
</div>
</div>
<div
class="flex flex-row items-center justify-end flex-grow gap-2 mt-3 header-actions-wrap lg:mt-0"
:class="{ 'justify-end': isContactPanelOpen }"
>
<SLACardLabel v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
<Linear
@@ -1,11 +1,10 @@
<script setup>
import { computed, ref } from 'vue';
import { computed } from 'vue';
import CopilotContainer from '../../copilot/CopilotContainer.vue';
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from '../../../featureFlags';
import { useUISettings } from 'dashboard/composables/useUISettings';
const props = defineProps({
currentChat: {
@@ -14,33 +13,8 @@ const props = defineProps({
},
});
const emit = defineEmits(['toggleContactPanel']);
const { t } = useI18n();
const channelType = computed(() => props.currentChat?.meta?.channel || '');
const CONTACT_TABS_OPTIONS = [
{ key: 'CONTACT', value: 'contact' },
{ key: 'COPILOT', value: 'copilot' },
];
const tabs = computed(() => {
return CONTACT_TABS_OPTIONS.map(tab => ({
label: t(`CONVERSATION.SIDEBAR.${tab.key}`),
value: tab.value,
}));
});
const activeTab = ref(0);
const toggleContactPanel = () => {
emit('toggleContactPanel');
};
const handleTabChange = selectedTab => {
activeTab.value = tabs.value.findIndex(
tabItem => tabItem.value === selectedTab.value
);
};
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
@@ -49,29 +23,37 @@ const isFeatureEnabledonAccount = useMapGetter(
const showCopilotTab = computed(() =>
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
);
const { uiSettings } = useUISettings();
const activeTab = computed(() => {
const {
is_contact_sidebar_open: isContactSidebarOpen,
is_copilot_panel_open: isCopilotPanelOpen,
} = uiSettings.value;
if (isContactSidebarOpen) {
return 0;
}
if (isCopilotPanelOpen) {
return 1;
}
return null;
});
</script>
<template>
<div
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-80 min-w-80 2xl:min-w-96 2xl:w-96 flex flex-col bg-n-background"
>
<div v-if="showCopilotTab" class="p-2">
<TabBar
:tabs="tabs"
:initial-active-tab="activeTab"
class="w-full [&>button]:w-full"
@tab-changed="handleTabChange"
/>
</div>
<div class="flex flex-1 overflow-auto">
<ContactPanel
v-if="!activeTab"
v-show="activeTab === 0"
:conversation-id="currentChat.id"
:inbox-id="currentChat.inbox_id"
:on-toggle="toggleContactPanel"
/>
<CopilotContainer
v-else-if="activeTab === 1 && showCopilotTab"
v-show="activeTab === 1 && showCopilotTab"
:key="currentChat.id"
:conversation-inbox-type="channelType"
:conversation-id="currentChat.id"
@@ -185,8 +185,17 @@ export default {
contextMenuEnabledOptions() {
return {
copy: this.hasText,
delete: this.hasText || this.hasAttachments,
cannedResponse: this.isOutgoing && this.hasText,
delete:
(this.hasText || this.hasAttachments) &&
!this.isMessageDeleted &&
!this.isFailed,
cannedResponse:
this.isOutgoing && this.hasText && !this.isMessageDeleted,
copyLink: !this.isFailed || !this.isProcessing,
translate:
(!this.isFailed || !this.isProcessing) &&
!this.isMessageDeleted &&
this.hasText,
replyTo: !this.data.private && this.inboxSupportsReplyTo.outgoing,
};
},
@@ -328,7 +337,7 @@ export default {
return !this.sender.type || this.sender.type === 'agent_bot';
},
shouldShowContextMenu() {
return !(this.isFailed || this.isPending || this.isUnsupported);
return !this.isUnsupported;
},
showAvatar() {
if (this.isOutgoing || this.isTemplate) {
@@ -1,5 +1,5 @@
<script>
import { ref } from 'vue';
import { ref, provide } from 'vue';
// composable
import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
@@ -38,8 +38,6 @@ import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { FEATURE_FLAGS } from '../../../featureFlags';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
Message,
@@ -47,22 +45,11 @@ export default {
ReplyBox,
Banner,
ConversationLabelSuggestion,
NextButton,
},
mixins: [inboxMixin],
props: {
isContactPanelOpen: {
type: Boolean,
default: false,
},
isInboxView: {
type: Boolean,
default: false,
},
},
emits: ['contactPanelToggle'],
setup() {
const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null);
const { isEnterprise } = useConfig();
const closePopOutReplyBox = () => {
@@ -98,6 +85,8 @@ export default {
FEATURE_FLAGS.CHATWOOT_V4
);
provide('contextMenuElementTarget', conversationPanelRef);
return {
isEnterprise,
isPopOutReplyBox,
@@ -108,6 +97,7 @@ export default {
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
showNextBubbles,
conversationPanelRef,
};
},
data() {
@@ -199,12 +189,6 @@ export default {
isATweet() {
return this.conversationType === 'tweet';
},
isRightOrLeftIcon() {
if (this.isContactPanelOpen) {
return 'arrow-chevron-right';
}
return 'arrow-chevron-left';
},
getLastSeenAt() {
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
return contactLastSeenAt;
@@ -440,9 +424,6 @@ export default {
relevantMessages
);
},
onToggleContactPanel() {
this.$emit('contactPanelToggle');
},
setScrollParams() {
this.heightBeforeLoad = this.conversationPanel.scrollHeight;
this.scrollTopBeforeLoad = this.conversationPanel.scrollTop;
@@ -526,21 +507,9 @@ export default {
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
/>
<div class="flex justify-end">
<NextButton
faded
xs
slate
class="!rounded-r-none rtl:rotate-180 !rounded-2xl !fixed z-10"
:icon="
isContactPanelOpen ? 'i-ph-caret-right-fill' : 'i-ph-caret-left-fill'
"
:class="isInboxView ? 'top-52 md:top-40' : 'top-32'"
@click="onToggleContactPanel"
/>
</div>
<NextMessageList
v-if="showNextBubbles"
ref="conversationPanelRef"
class="conversation-panel"
:current-user-id="currentUserId"
:first-unread-id="unReadMessages[0]?.id"
@@ -572,7 +541,7 @@ export default {
/>
</template>
</NextMessageList>
<ul v-else class="conversation-panel">
<ul v-else ref="conversationPanelRef" class="conversation-panel">
<transition name="slide-up">
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
<li class="min-h-[4rem]">
@@ -33,6 +33,7 @@ class ActionCableConnector extends BaseActionCableConnector {
'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated,
'account.cache_invalidated': this.onCacheInvalidate,
'copilot.message.created': this.onCopilotMessageCreated,
};
}
@@ -189,6 +190,10 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('notifications/updateNotification', data);
};
onCopilotMessageCreated = data => {
this.app.$store.dispatch('copilotMessages/upsert', data);
};
onCacheInvalidate = data => {
const keys = data.cache_keys;
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
@@ -0,0 +1,67 @@
import { describe, it, beforeEach, expect, vi } from 'vitest';
import ActionCableConnector from '../actionCable';
vi.mock('shared/helpers/mitt', () => ({
emitter: {
emit: vi.fn(),
},
}));
vi.mock('dashboard/composables/useImpersonation', () => ({
useImpersonation: () => ({
isImpersonating: { value: false },
}),
}));
global.chatwootConfig = {
websocketURL: 'wss://test.chatwoot.com',
};
describe('ActionCableConnector - Copilot Tests', () => {
let store;
let actionCable;
let mockDispatch;
beforeEach(() => {
vi.clearAllMocks();
mockDispatch = vi.fn();
store = {
$store: {
dispatch: mockDispatch,
getters: {
getCurrentAccountId: 1,
},
},
};
actionCable = ActionCableConnector.init(store.$store, 'test-token');
});
describe('copilot event handlers', () => {
it('should register the copilot.message.created event handler', () => {
expect(Object.keys(actionCable.events)).toContain(
'copilot.message.created'
);
expect(actionCable.events['copilot.message.created']).toBe(
actionCable.onCopilotMessageCreated
);
});
it('should handle the copilot.message.created event through the ActionCable system', () => {
const copilotData = {
id: 2,
content: 'This is a copilot message from ActionCable',
conversation_id: 456,
created_at: '2025-05-27T15:58:04-06:00',
account_id: 1,
};
actionCable.onReceived({
event: 'copilot.message.created',
data: copilotData,
});
expect(mockDispatch).toHaveBeenCalledWith(
'copilotMessages/upsert',
copilotData
);
});
});
});
@@ -4,6 +4,7 @@
"PHONE_INPUT": {
"PLACEHOLDER": "Search",
"EMPTY_STATE": "No results found"
}
},
"CLOSE": "Close"
}
}
@@ -4,12 +4,14 @@
"ALL": "All",
"CONTACTS": "Contacts",
"CONVERSATIONS": "Conversations",
"MESSAGES": "Messages"
"MESSAGES": "Messages",
"ARTICLES": "Articles"
},
"SECTION": {
"CONTACTS": "Contacts",
"CONVERSATIONS": "Conversations",
"MESSAGES": "Messages"
"MESSAGES": "Messages",
"ARTICLES": "Articles"
},
"VIEW_MORE": "View more",
"LOAD_MORE": "Load more",
@@ -47,6 +47,7 @@ export default {
emits: ['open', 'close', 'replyTo'],
setup() {
const { getPlainText } = useMessageFormatter();
return {
getPlainText,
};
@@ -167,7 +168,7 @@ export default {
</woot-modal>
<!-- Confirm Deletion -->
<woot-delete-modal
v-if="showDeleteModal"
v-if="showDeleteModal && enabledOptions['delete']"
v-model:show="showDeleteModal"
class="context-menu--delete-modal"
:on-close="closeDeleteModal"
@@ -212,7 +213,7 @@ export default {
@click.stop="handleCopy"
/>
<MenuItem
v-if="enabledOptions['copy']"
v-if="enabledOptions['translate']"
:option="{
icon: 'translate',
label: $t('CONVERSATION.CONTEXT_MENU.TRANSLATE'),
@@ -222,6 +223,7 @@ export default {
/>
<hr />
<MenuItem
v-if="enabledOptions['copyLink']"
:option="{
icon: 'link',
label: $t('CONVERSATION.CONTEXT_MENU.COPY_PERMALINK'),
@@ -0,0 +1,69 @@
<script setup>
import { computed } from 'vue';
import Icon from 'next/icon/Icon.vue';
import { frontendURL } from 'dashboard/helper/URLHelper';
import MessageFormatter from 'shared/helpers/MessageFormatter';
const props = defineProps({
id: { type: [String, Number], default: 0 },
title: { type: String, default: '' },
description: { type: String, default: '' },
category: { type: String, default: '' },
locale: { type: String, default: '' },
content: { type: String, default: '' },
portalSlug: { type: String, required: true },
accountId: { type: [String, Number], default: 0 },
});
const MAX_LENGTH = 300;
const navigateTo = computed(() => {
return frontendURL(
`accounts/${props.accountId}/portals/${props.portalSlug}/${props.locale}/articles/edit/${props.id}`
);
});
const truncatedContent = computed(() => {
if (!props.content) return props.description || '';
// Use MessageFormatter to properly convert markdown to plain text
const formatter = new MessageFormatter(props.content);
const plainText = formatter.plainText.trim();
return plainText.length > MAX_LENGTH
? `${plainText.substring(0, MAX_LENGTH)}...`
: plainText;
});
</script>
<template>
<router-link
:to="navigateTo"
class="flex items-start p-2 rounded-xl cursor-pointer hover:bg-n-slate-2"
>
<div
class="flex items-center justify-center w-6 h-6 mt-0.5 rounded bg-n-slate-3"
>
<Icon icon="i-lucide-library-big" class="text-n-slate-10" />
</div>
<div class="ltr:ml-2 rtl:mr-2 min-w-0 flex-1">
<div class="flex items-center gap-2">
<h5 class="text-sm font-medium truncate min-w-0 text-n-slate-12">
{{ title }}
</h5>
<span
v-if="category"
class="text-xs font-medium whitespace-nowrap capitalize bg-n-slate-3 px-1 py-0.5 rounded text-n-slate-10"
>
{{ category }}
</span>
</div>
<p
v-if="truncatedContent"
class="mt-1 text-sm text-n-slate-11 line-clamp-2"
>
{{ truncatedContent }}
</p>
</div>
</router-link>
</template>
@@ -0,0 +1,53 @@
<script setup>
import { useMapGetter } from 'dashboard/composables/store.js';
import SearchResultSection from './SearchResultSection.vue';
import SearchResultArticleItem from './SearchResultArticleItem.vue';
defineProps({
articles: {
type: Array,
default: () => [],
},
query: {
type: String,
default: '',
},
isFetching: {
type: Boolean,
default: false,
},
showTitle: {
type: Boolean,
default: true,
},
});
const accountId = useMapGetter('getCurrentAccountId');
</script>
<template>
<SearchResultSection
:title="$t('SEARCH.SECTION.ARTICLES')"
:empty="!articles.length"
:query="query"
:show-title="showTitle"
:is-fetching="isFetching"
>
<ul v-if="articles.length" class="space-y-1.5 list-none">
<li v-for="article in articles" :key="article.id">
<SearchResultArticleItem
:id="article.id"
:title="article.title"
:description="article.description"
:content="article.content"
:portal-slug="article.portal_slug"
:locale="article.locale"
:account-id="accountId"
:category="article.category_name"
:status="article.status"
/>
</li>
</ul>
</SearchResultSection>
</template>
@@ -8,6 +8,7 @@ import {
ROLES,
CONVERSATION_PERMISSIONS,
CONTACT_PERMISSIONS,
PORTAL_PERMISSIONS,
} from 'dashboard/constants/permissions.js';
import {
getUserPermissions,
@@ -22,6 +23,7 @@ import SearchTabs from './SearchTabs.vue';
import SearchResultConversationsList from './SearchResultConversationsList.vue';
import SearchResultMessagesList from './SearchResultMessagesList.vue';
import SearchResultContactsList from './SearchResultContactsList.vue';
import SearchResultArticlesList from './SearchResultArticlesList.vue';
const router = useRouter();
const store = useStore();
@@ -34,6 +36,7 @@ const pages = ref({
contacts: 1,
conversations: 1,
messages: 1,
articles: 1,
});
const currentUser = useMapGetter('getCurrentUser');
@@ -43,6 +46,7 @@ const conversationRecords = useMapGetter(
'conversationSearch/getConversationRecords'
);
const messageRecords = useMapGetter('conversationSearch/getMessageRecords');
const articleRecords = useMapGetter('conversationSearch/getArticleRecords');
const uiFlags = useMapGetter('conversationSearch/getUIFlags');
const addTypeToRecords = (records, type) =>
@@ -57,6 +61,9 @@ const mappedConversations = computed(() =>
const mappedMessages = computed(() =>
addTypeToRecords(messageRecords, 'message')
);
const mappedArticles = computed(() =>
addTypeToRecords(articleRecords, 'article')
);
const isSelectedTabAll = computed(() => selectedTab.value === 'all');
@@ -66,6 +73,7 @@ const sliceRecordsIfAllTab = items =>
const contacts = computed(() => sliceRecordsIfAllTab(mappedContacts));
const conversations = computed(() => sliceRecordsIfAllTab(mappedConversations));
const messages = computed(() => sliceRecordsIfAllTab(mappedMessages));
const articles = computed(() => sliceRecordsIfAllTab(mappedArticles));
const filterByTab = tab =>
computed(() => selectedTab.value === tab || isSelectedTabAll.value);
@@ -73,6 +81,7 @@ const filterByTab = tab =>
const filterContacts = filterByTab('contacts');
const filterConversations = filterByTab('conversations');
const filterMessages = filterByTab('messages');
const filterArticles = filterByTab('articles');
const userPermissions = computed(() =>
getUserPermissions(currentUser.value, currentAccountId.value)
@@ -80,7 +89,12 @@ const userPermissions = computed(() =>
const TABS_CONFIG = {
all: {
permissions: [CONTACT_PERMISSIONS, ...ROLES, ...CONVERSATION_PERMISSIONS],
permissions: [
CONTACT_PERMISSIONS,
...ROLES,
...CONVERSATION_PERMISSIONS,
PORTAL_PERMISSIONS,
],
count: () => null, // No count for all tab
},
contacts: {
@@ -95,6 +109,10 @@ const TABS_CONFIG = {
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
count: () => mappedMessages.value.length,
},
articles: {
permissions: [...ROLES, PORTAL_PERMISSIONS],
count: () => mappedArticles.value.length,
},
};
const tabs = computed(() => {
@@ -123,6 +141,10 @@ const totalSearchResultsCount = computed(() => {
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
count: () => conversations.value.length + messages.value.length,
},
articles: {
permissions: [...ROLES, PORTAL_PERMISSIONS],
count: () => articles.value.length,
},
};
return filterItemsByPermission(
permissionCounts,
@@ -138,12 +160,13 @@ const activeTabIndex = computed(() => {
});
const isFetchingAny = computed(() => {
const { contact, message, conversation, isFetching } = uiFlags.value;
const { contact, message, conversation, article, isFetching } = uiFlags.value;
return (
isFetching ||
contact.isFetching ||
message.isFetching ||
conversation.isFetching
conversation.isFetching ||
article.isFetching
);
});
@@ -171,6 +194,7 @@ const showLoadMore = computed(() => {
contacts: mappedContacts.value,
conversations: mappedConversations.value,
messages: mappedMessages.value,
articles: mappedArticles.value,
}[selectedTab.value];
return (
@@ -185,10 +209,11 @@ const showViewMore = computed(() => ({
conversations:
mappedConversations.value?.length > 5 && isSelectedTabAll.value,
messages: mappedMessages.value?.length > 5 && isSelectedTabAll.value,
articles: mappedArticles.value?.length > 5 && isSelectedTabAll.value,
}));
const clearSearchResult = () => {
pages.value = { contacts: 1, conversations: 1, messages: 1 };
pages.value = { contacts: 1, conversations: 1, messages: 1, articles: 1 };
store.dispatch('conversationSearch/clearSearchResults');
};
@@ -214,6 +239,7 @@ const loadMore = () => {
contacts: 'conversationSearch/contactSearch',
conversations: 'conversationSearch/conversationSearch',
messages: 'conversationSearch/messageSearch',
articles: 'conversationSearch/articleSearch',
};
if (uiFlags.value.isFetching || selectedTab.value === 'all') return;
@@ -328,6 +354,28 @@ onUnmounted(() => {
/>
</Policy>
<Policy
:permissions="[...ROLES, PORTAL_PERMISSIONS]"
class="flex flex-col justify-center"
>
<SearchResultArticlesList
v-if="filterArticles"
:is-fetching="uiFlags.article.isFetching"
:articles="articles"
:query="query"
:show-title="isSelectedTabAll"
/>
<NextButton
v-if="showViewMore.articles"
:label="t(`SEARCH.VIEW_MORE`)"
icon="i-lucide-eye"
slate
sm
outline
@click="selectedTab = 'articles'"
/>
</Policy>
<div v-if="showLoadMore" class="flex justify-center mt-4 mb-6">
<NextButton
v-if="!isSelectedTabAll"
@@ -3,6 +3,7 @@ import {
ROLES,
CONVERSATION_PERMISSIONS,
CONTACT_PERMISSIONS,
PORTAL_PERMISSIONS,
} from 'dashboard/constants/permissions.js';
import SearchView from './components/SearchView.vue';
@@ -12,7 +13,12 @@ export const routes = [
path: frontendURL('accounts/:accountId/search'),
name: 'search',
meta: {
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS, CONTACT_PERMISSIONS],
permissions: [
...ROLES,
...CONVERSATION_PERMISSIONS,
CONTACT_PERMISSIONS,
PORTAL_PERMISSIONS,
],
},
component: SearchView,
},
@@ -11,14 +11,14 @@ import AccordionItem from 'dashboard/components/Accordion/AccordionItem.vue';
import ContactConversations from './ContactConversations.vue';
import ConversationAction from './ConversationAction.vue';
import ConversationParticipant from './ConversationParticipant.vue';
import ContactInfo from './contact/ContactInfo.vue';
import ContactNotes from './contact/ContactNotes.vue';
import ConversationInfo from './ConversationInfo.vue';
import CustomAttributes from './customAttributes/CustomAttributes.vue';
import Draggable from 'vuedraggable';
import MacrosList from './Macros/List.vue';
import ShopifyOrdersList from '../../../components/widgets/conversation/ShopifyOrdersList.vue';
import ShopifyOrdersList from 'dashboard/components/widgets/conversation/ShopifyOrdersList.vue';
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
const props = defineProps({
conversationId: {
@@ -29,10 +29,6 @@ const props = defineProps({
type: Number,
default: undefined,
},
onToggle: {
type: Function,
default: () => {},
},
});
const {
@@ -89,8 +85,6 @@ watch(conversationId, (newConversationId, prevConversationId) => {
watch(contactId, getContactDetails);
const onPanelToggle = props.onToggle;
const onDragEnd = () => {
dragging.value = false;
updateUISettings({
@@ -98,6 +92,13 @@ const onDragEnd = () => {
});
};
const closeContactPanel = () => {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: false,
});
};
onMounted(() => {
conversationSidebarItems.value = conversationSidebarItemsOrder.value;
getContactDetails();
@@ -107,11 +108,11 @@ onMounted(() => {
<template>
<div class="w-full">
<ContactInfo
:contact="contact"
:channel-type="channelType"
@toggle-panel="onPanelToggle"
<SidebarActionsHeader
:title="$t('CONVERSATION.SIDEBAR.CONTACT')"
@close="closeContactPanel"
/>
<ContactInfo :contact="contact" :channel-type="channelType" />
<div class="list-group pb-8">
<Draggable
:list="conversationSidebarItems"
@@ -10,6 +10,8 @@ import { BUS_EVENTS } from 'shared/constants/busEvents';
import CmdBarConversationSnooze from 'dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue';
import { emitter } from 'shared/helpers/mitt';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import SidepanelSwitch from 'dashboard/components-next/Conversation/SidepanelSwitch.vue';
import ConversationSidebar from 'dashboard/components/widgets/conversation/ConversationSidebar.vue';
export default {
components: {
@@ -17,6 +19,8 @@ export default {
ConversationBox,
PopOverSearch,
CmdBarConversationSnooze,
SidepanelSwitch,
ConversationSidebar,
},
beforeRouteLeave(to, from, next) {
// Clear selected state if navigating away from a conversation to a route without a conversationId to prevent stale data issues
@@ -87,13 +91,17 @@ export default {
this.uiSettings;
return conversationDisplayType !== CONDENSED;
},
isContactPanelOpen() {
if (this.currentChat.id) {
const { is_contact_sidebar_open: isContactSidebarOpen } =
this.uiSettings;
return isContactSidebarOpen;
shouldShowSidebar() {
if (!this.currentChat.id) {
return false;
}
return false;
const {
is_contact_sidebar_open: isContactSidebarOpen,
is_copilot_panel_open: isCopilotPanelOpen,
} = this.uiSettings;
return isContactSidebarOpen || isCopilotPanelOpen;
},
showPopOverSearch() {
return !this.isFeatureEnabledonAccount(
@@ -189,11 +197,6 @@ export default {
this.$store.dispatch('clearSelectedState');
}
},
onToggleContactPanel() {
this.updateUISettings({
is_contact_sidebar_open: !this.isContactPanelOpen,
});
},
onSearch() {
this.showSearchModal = true;
},
@@ -225,10 +228,11 @@ export default {
<ConversationBox
v-if="showMessageView"
:inbox-id="inboxId"
:is-contact-panel-open="isContactPanelOpen"
:is-on-expanded-layout="isOnExpandedLayout"
@contact-panel-toggle="onToggleContactPanel"
/>
>
<SidepanelSwitch v-if="currentChat.id" />
</ConversationBox>
<ConversationSidebar v-if="shouldShowSidebar" :current-chat="currentChat" />
<CmdBarConversationSnooze />
</section>
</template>
@@ -24,7 +24,7 @@ const PortalsSettingsIndexPage = () =>
const meta = {
featureFlag: FEATURE_FLAGS.HELP_CENTER,
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
permissions: ['administrator', 'knowledge_base_manage'],
};
const portalRoutes = [
{
@@ -85,19 +85,13 @@ const portalRoutes = [
{
path: getPortalRoute('new'),
name: 'portals_new',
meta: {
featureFlag: FEATURE_FLAGS.HELP_CENTER,
permissions: ['administrator', 'knowledge_base_manage'],
},
meta,
component: PortalsNew,
},
{
path: getPortalRoute(':navigationPath'),
name: 'portals_index',
meta: {
featureFlag: FEATURE_FLAGS.HELP_CENTER,
permissions: ['administrator', 'knowledge_base_manage'],
},
meta,
component: PortalsIndex,
},
];
@@ -117,7 +117,7 @@ export default {
<div class="flex flex-col h-auto overflow-auto integration-hooks">
<woot-modal-header
:header-title="integration.name"
:header-content="integration.short_description || integration.description"
:header-content="integration.short_description"
/>
<FormKit
v-model="values"
@@ -0,0 +1,19 @@
import CopilotMessagesAPI from 'dashboard/api/captain/copilotMessages';
import { createStore } from './storeFactory';
export default createStore({
name: 'CopilotMessages',
API: CopilotMessagesAPI,
getters: {
getMessagesByThreadId: state => copilotThreadId => {
return state.records.filter(
record => record.copilot_thread?.id === Number(copilotThreadId)
);
},
},
actions: mutationTypes => ({
upsert({ commit }, data) {
commit(mutationTypes.UPSERT, data);
},
}),
});
@@ -0,0 +1,7 @@
import CopilotThreadsAPI from 'dashboard/api/captain/copilotThreads';
import { createStore } from './storeFactory';
export default createStore({
name: 'CopilotThreads',
API: CopilotThreadsAPI,
});
@@ -1,5 +1,11 @@
import { throwErrorMessage } from 'dashboard/store/utils/api';
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import {
createRecord,
deleteRecord,
getRecords,
showRecord,
updateRecord,
} from './storeFactoryHelper';
export const generateMutationTypes = name => {
const capitalizedName = name.toUpperCase();
@@ -10,6 +16,7 @@ export const generateMutationTypes = name => {
EDIT: `EDIT_${capitalizedName}`,
DELETE: `DELETE_${capitalizedName}`,
SET_META: `SET_${capitalizedName}_META`,
UPSERT: `UPSERT_${capitalizedName}`,
};
};
@@ -33,7 +40,6 @@ export const createGetters = () => ({
getMeta: state => state.meta,
});
// store/mutations.js
export const createMutations = mutationTypes => ({
[mutationTypes.SET_UI_FLAG](state, data) {
state.uiFlags = {
@@ -51,78 +57,19 @@ export const createMutations = mutationTypes => ({
[mutationTypes.ADD]: MutationHelpers.create,
[mutationTypes.EDIT]: MutationHelpers.update,
[mutationTypes.DELETE]: MutationHelpers.destroy,
[mutationTypes.UPSERT]: MutationHelpers.setSingleRecord,
});
// store/actions/crud.js
export const createCrudActions = (API, mutationTypes) => ({
async get({ commit }, params = {}) {
commit(mutationTypes.SET_UI_FLAG, { fetchingList: true });
try {
const response = await API.get(params);
commit(mutationTypes.SET, response.data.payload);
commit(mutationTypes.SET_META, response.data.meta);
return response.data.payload;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { fetchingList: false });
}
},
async show({ commit }, id) {
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: true });
try {
const response = await API.show(id);
commit(mutationTypes.ADD, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: false });
}
},
async create({ commit }, dataObj) {
commit(mutationTypes.SET_UI_FLAG, { creatingItem: true });
try {
const response = await API.create(dataObj);
commit(mutationTypes.ADD, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { creatingItem: false });
}
},
async update({ commit }, { id, ...updateObj }) {
commit(mutationTypes.SET_UI_FLAG, { updatingItem: true });
try {
const response = await API.update(id, updateObj);
commit(mutationTypes.EDIT, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { updatingItem: false });
}
},
async delete({ commit }, id) {
commit(mutationTypes.SET_UI_FLAG, { deletingItem: true });
try {
await API.delete(id);
commit(mutationTypes.DELETE, id);
return id;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { deletingItem: false });
}
},
get: getRecords(mutationTypes, API),
show: showRecord(mutationTypes, API),
create: createRecord(mutationTypes, API),
update: updateRecord(mutationTypes, API),
delete: deleteRecord(mutationTypes, API),
});
export const createStore = options => {
const { name, API, actions } = options;
const { name, API, actions, getters } = options;
const mutationTypes = generateMutationTypes(name);
const customActions = actions ? actions(mutationTypes) : {};
@@ -130,7 +77,10 @@ export const createStore = options => {
return {
namespaced: true,
state: createInitialState(),
getters: createGetters(),
getters: {
...createGetters(),
...(getters || {}),
},
mutations: createMutations(mutationTypes),
actions: {
...createCrudActions(API, mutationTypes),
@@ -0,0 +1,380 @@
import { throwErrorMessage } from 'dashboard/store/utils/api';
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import {
generateMutationTypes,
createInitialState,
createGetters,
createMutations,
createCrudActions,
createStore,
} from './storeFactory';
vi.mock('dashboard/store/utils/api', () => ({
throwErrorMessage: vi.fn(),
}));
vi.mock('shared/helpers/vuex/mutationHelpers', () => ({
set: vi.fn(),
create: vi.fn(),
update: vi.fn(),
destroy: vi.fn(),
setSingleRecord: vi.fn(),
}));
describe('storeFactory', () => {
describe('generateMutationTypes', () => {
it('generates correct mutation types with capitalized name', () => {
const result = generateMutationTypes('test');
expect(result).toEqual({
SET_UI_FLAG: 'SET_TEST_UI_FLAG',
SET: 'SET_TEST',
ADD: 'ADD_TEST',
EDIT: 'EDIT_TEST',
DELETE: 'DELETE_TEST',
SET_META: 'SET_TEST_META',
UPSERT: 'UPSERT_TEST',
});
});
});
describe('createInitialState', () => {
it('returns the correct initial state structure', () => {
const result = createInitialState();
expect(result).toEqual({
records: [],
meta: {},
uiFlags: {
fetchingList: false,
fetchingItem: false,
creatingItem: false,
updatingItem: false,
deletingItem: false,
},
});
});
});
describe('createGetters', () => {
it('returns getters with correct implementations', () => {
const getters = createGetters();
const state = {
records: [{ id: 2 }, { id: 1 }, { id: 3 }],
uiFlags: { fetchingList: true },
meta: { totalCount: 10, page: 1 },
};
expect(getters.getRecords(state)).toEqual([
{ id: 3 },
{ id: 2 },
{ id: 1 },
]);
expect(getters.getRecord(state)(2)).toEqual({ id: 2 });
expect(getters.getRecord(state)(4)).toEqual({});
expect(getters.getUIFlags(state)).toEqual({
fetchingList: true,
});
expect(getters.getMeta(state)).toEqual({
totalCount: 10,
page: 1,
});
});
});
describe('createMutations', () => {
it('creates mutations with correct implementations', () => {
const mutationTypes = generateMutationTypes('test');
const mutations = createMutations(mutationTypes);
const state = { uiFlags: { fetchingList: false } };
mutations[mutationTypes.SET_UI_FLAG](state, { fetchingList: true });
expect(state.uiFlags).toEqual({ fetchingList: true });
const metaState = { meta: {} };
mutations[mutationTypes.SET_META](metaState, {
total_count: '10',
page: '2',
});
expect(metaState.meta).toEqual({ totalCount: 10, page: 2 });
expect(mutations[mutationTypes.SET]).toBe(MutationHelpers.set);
expect(mutations[mutationTypes.ADD]).toBe(MutationHelpers.create);
expect(mutations[mutationTypes.EDIT]).toBe(MutationHelpers.update);
expect(mutations[mutationTypes.DELETE]).toBe(MutationHelpers.destroy);
expect(mutations[mutationTypes.UPSERT]).toBe(
MutationHelpers.setSingleRecord
);
});
});
describe('createCrudActions', () => {
let API;
let commit;
let mutationTypes;
let actions;
beforeEach(() => {
API = {
get: vi.fn(),
show: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
commit = vi.fn();
mutationTypes = generateMutationTypes('test');
actions = createCrudActions(API, mutationTypes);
});
describe('get action', () => {
it('handles successful API response', async () => {
const payload = [{ id: 1 }];
const meta = { total_count: 10, page: 1 };
API.get.mockResolvedValue({ data: { payload, meta } });
const result = await actions.get({ commit });
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingList: true,
});
expect(commit).toHaveBeenCalledWith(mutationTypes.SET, payload);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_META, meta);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingList: false,
});
expect(result).toEqual(payload);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.get.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.get({ commit });
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingList: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingList: false,
});
expect(result).toEqual('Error thrown');
});
});
describe('show action', () => {
it('handles successful API response', async () => {
const data = { id: 1, name: 'Test' };
API.show.mockResolvedValue({ data });
const result = await actions.show({ commit }, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingItem: true,
});
expect(commit).toHaveBeenCalledWith(mutationTypes.ADD, data);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingItem: false,
});
expect(result).toEqual(data);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.show.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.show({ commit }, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingItem: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
fetchingItem: false,
});
expect(result).toEqual('Error thrown');
});
});
describe('create action', () => {
it('handles successful API response', async () => {
const data = { id: 1, name: 'Test' };
API.create.mockResolvedValue({ data });
const result = await actions.create({ commit }, { name: 'Test' });
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
creatingItem: true,
});
expect(commit).toHaveBeenCalledWith(mutationTypes.UPSERT, data);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
creatingItem: false,
});
expect(result).toEqual(data);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.create.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.create({ commit }, { name: 'Test' });
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
creatingItem: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
creatingItem: false,
});
expect(result).toEqual('Error thrown');
});
});
describe('update action', () => {
it('handles successful API response', async () => {
const data = { id: 1, name: 'Updated' };
API.update.mockResolvedValue({ data });
const result = await actions.update(
{ commit },
{ id: 1, name: 'Updated' }
);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
updatingItem: true,
});
expect(API.update).toHaveBeenCalledWith(1, { name: 'Updated' });
expect(commit).toHaveBeenCalledWith(mutationTypes.EDIT, data);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
updatingItem: false,
});
expect(result).toEqual(data);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.update.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.update(
{ commit },
{ id: 1, name: 'Updated' }
);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
updatingItem: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
updatingItem: false,
});
expect(result).toEqual('Error thrown');
});
});
describe('delete action', () => {
it('handles successful API response', async () => {
API.delete.mockResolvedValue({});
const result = await actions.delete({ commit }, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
deletingItem: true,
});
expect(API.delete).toHaveBeenCalledWith(1);
expect(commit).toHaveBeenCalledWith(mutationTypes.DELETE, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
deletingItem: false,
});
expect(result).toEqual(1);
});
it('handles API error', async () => {
const error = new Error('API Error');
API.delete.mockRejectedValue(error);
throwErrorMessage.mockReturnValue('Error thrown');
const result = await actions.delete({ commit }, 1);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
deletingItem: true,
});
expect(throwErrorMessage).toHaveBeenCalledWith(error);
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
deletingItem: false,
});
expect(result).toEqual('Error thrown');
});
});
});
describe('createStore', () => {
it('creates a complete store with default options', () => {
const API = {};
const store = createStore({ name: 'test', API });
expect(store.namespaced).toBe(true);
expect(store.state).toEqual(createInitialState());
expect(Object.keys(store.getters)).toEqual([
'getRecords',
'getRecord',
'getUIFlags',
'getMeta',
]);
expect(Object.keys(store.mutations)).toEqual([
'SET_TEST_UI_FLAG',
'SET_TEST_META',
'SET_TEST',
'ADD_TEST',
'EDIT_TEST',
'DELETE_TEST',
'UPSERT_TEST',
]);
expect(Object.keys(store.actions)).toEqual([
'get',
'show',
'create',
'update',
'delete',
]);
});
it('creates a store with custom actions and getters', () => {
const API = {};
const customGetters = { customGetter: () => 'custom' };
const customActions = () => ({
customAction: () => 'custom',
});
const store = createStore({
name: 'test',
API,
getters: customGetters,
actions: customActions,
});
expect(store.getters).toHaveProperty('customGetter');
expect(store.actions).toHaveProperty('customAction');
expect(Object.keys(store.getters)).toEqual([
'getRecords',
'getRecord',
'getUIFlags',
'getMeta',
'customGetter',
]);
expect(Object.keys(store.actions)).toEqual([
'get',
'show',
'create',
'update',
'delete',
'customAction',
]);
});
});
});
@@ -0,0 +1,77 @@
import { throwErrorMessage } from 'dashboard/store/utils/api';
export const getRecords =
(mutationTypes, API) =>
async ({ commit }, params = {}) => {
commit(mutationTypes.SET_UI_FLAG, { fetchingList: true });
try {
const response = await API.get(params);
commit(mutationTypes.SET, response.data.payload);
commit(mutationTypes.SET_META, response.data.meta);
return response.data.payload;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { fetchingList: false });
}
};
export const showRecord =
(mutationTypes, API) =>
async ({ commit }, id) => {
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: true });
try {
const response = await API.show(id);
commit(mutationTypes.ADD, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: false });
}
};
export const createRecord =
(mutationTypes, API) =>
async ({ commit }, dataObj) => {
commit(mutationTypes.SET_UI_FLAG, { creatingItem: true });
try {
const response = await API.create(dataObj);
commit(mutationTypes.UPSERT, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { creatingItem: false });
}
};
export const updateRecord =
(mutationTypes, API) =>
async ({ commit }, { id, ...updateObj }) => {
commit(mutationTypes.SET_UI_FLAG, { updatingItem: true });
try {
const response = await API.update(id, updateObj);
commit(mutationTypes.EDIT, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { updatingItem: false });
}
};
export const deleteRecord =
(mutationTypes, API) =>
async ({ commit }, id) => {
commit(mutationTypes.SET_UI_FLAG, { deletingItem: true });
try {
await API.delete(id);
commit(mutationTypes.DELETE, id);
return id;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { deletingItem: false });
}
};
+5
View File
@@ -51,6 +51,9 @@ import captainDocuments from './captain/document';
import captainResponses from './captain/response';
import captainInboxes from './captain/inboxes';
import captainBulkActions from './captain/bulkActions';
import copilotThreads from './captain/copilotThreads';
import copilotMessages from './captain/copilotMessages';
const plugins = [];
export default createStore({
@@ -106,6 +109,8 @@ export default createStore({
captainResponses,
captainInboxes,
captainBulkActions,
copilotThreads,
copilotMessages,
},
plugins,
});
@@ -5,12 +5,14 @@ export const initialState = {
contactRecords: [],
conversationRecords: [],
messageRecords: [],
articleRecords: [],
uiFlags: {
isFetching: false,
isSearchCompleted: false,
contact: { isFetching: false },
conversation: { isFetching: false },
message: { isFetching: false },
article: { isFetching: false },
},
};
@@ -27,6 +29,9 @@ export const getters = {
getMessageRecords(state) {
return state.messageRecords;
},
getArticleRecords(state) {
return state.articleRecords;
},
getUIFlags(state) {
return state.uiFlags;
},
@@ -65,6 +70,7 @@ export const actions = {
dispatch('contactSearch', { q }),
dispatch('conversationSearch', { q }),
dispatch('messageSearch', { q }),
dispatch('articleSearch', { q }),
]);
} catch (error) {
// Ignore error
@@ -108,6 +114,17 @@ export const actions = {
commit(types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: false });
}
},
async articleSearch({ commit }, { q, page = 1 }) {
commit(types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: true });
try {
const { data } = await SearchAPI.articles({ q, page });
commit(types.ARTICLE_SEARCH_SET, data.payload.articles);
} catch (error) {
// Ignore error
} finally {
commit(types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: false });
}
},
async clearSearchResults({ commit }) {
commit(types.CLEAR_SEARCH_RESULTS);
},
@@ -126,6 +143,9 @@ export const mutations = {
[types.MESSAGE_SEARCH_SET](state, records) {
state.messageRecords = [...state.messageRecords, ...records];
},
[types.ARTICLE_SEARCH_SET](state, records) {
state.articleRecords = [...state.articleRecords, ...records];
},
[types.SEARCH_CONVERSATIONS_SET_UI_FLAG](state, uiFlags) {
state.uiFlags = { ...state.uiFlags, ...uiFlags };
},
@@ -141,10 +161,14 @@ export const mutations = {
[types.MESSAGE_SEARCH_SET_UI_FLAG](state, uiFlags) {
state.uiFlags.message = { ...state.uiFlags.message, ...uiFlags };
},
[types.ARTICLE_SEARCH_SET_UI_FLAG](state, uiFlags) {
state.uiFlags.article = { ...state.uiFlags.article, ...uiFlags };
},
[types.CLEAR_SEARCH_RESULTS](state) {
state.contactRecords = [];
state.conversationRecords = [];
state.messageRecords = [];
state.articleRecords = [];
},
};
@@ -75,6 +75,7 @@ describe('#actions', () => {
q: 'test',
});
expect(dispatch).toHaveBeenCalledWith('messageSearch', { q: 'test' });
expect(dispatch).toHaveBeenCalledWith('articleSearch', { q: 'test' });
});
});
@@ -150,6 +151,30 @@ describe('#actions', () => {
});
});
describe('#articleSearch', () => {
it('should handle successful article search', async () => {
axios.get.mockResolvedValue({
data: { payload: { articles: [{ id: 1 }] } },
});
await actions.articleSearch({ commit }, { q: 'test', page: 1 });
expect(commit.mock.calls).toEqual([
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: true }],
[types.ARTICLE_SEARCH_SET, [{ id: 1 }]],
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: false }],
]);
});
it('should handle failed article search', async () => {
axios.get.mockRejectedValue({});
await actions.articleSearch({ commit }, { q: 'test' });
expect(commit.mock.calls).toEqual([
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: true }],
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#clearSearchResults', () => {
it('should commit clear search results mutation', () => {
actions.clearSearchResults({ commit });
@@ -37,6 +37,15 @@ describe('#getters', () => {
]);
});
it('getArticleRecords', () => {
const state = {
articleRecords: [{ id: 1, title: 'Article 1' }],
};
expect(getters.getArticleRecords(state)).toEqual([
{ id: 1, title: 'Article 1' },
]);
});
it('getUIFlags', () => {
const state = {
uiFlags: {
@@ -45,6 +54,7 @@ describe('#getters', () => {
contact: { isFetching: true },
message: { isFetching: false },
conversation: { isFetching: false },
article: { isFetching: false },
},
};
expect(getters.getUIFlags(state)).toEqual({
@@ -53,6 +63,7 @@ describe('#getters', () => {
contact: { isFetching: true },
message: { isFetching: false },
conversation: { isFetching: false },
article: { isFetching: false },
});
});
});
@@ -101,17 +101,39 @@ describe('#mutations', () => {
});
});
describe('#ARTICLE_SEARCH_SET', () => {
it('should append new article records to existing ones', () => {
const state = { articleRecords: [{ id: 1 }] };
mutations[types.ARTICLE_SEARCH_SET](state, [{ id: 2 }]);
expect(state.articleRecords).toEqual([{ id: 1 }, { id: 2 }]);
});
});
describe('#ARTICLE_SEARCH_SET_UI_FLAG', () => {
it('set article search UI flags correctly', () => {
const state = {
uiFlags: {
article: { isFetching: true },
},
};
mutations[types.ARTICLE_SEARCH_SET_UI_FLAG](state, { isFetching: false });
expect(state.uiFlags.article).toEqual({ isFetching: false });
});
});
describe('#CLEAR_SEARCH_RESULTS', () => {
it('should clear all search records', () => {
const state = {
contactRecords: [{ id: 1 }],
conversationRecords: [{ id: 1 }],
messageRecords: [{ id: 1 }],
articleRecords: [{ id: 1 }],
};
mutations[types.CLEAR_SEARCH_RESULTS](state);
expect(state.contactRecords).toEqual([]);
expect(state.conversationRecords).toEqual([]);
expect(state.messageRecords).toEqual([]);
expect(state.articleRecords).toEqual([]);
});
});
});
@@ -317,8 +317,10 @@ export default {
CONVERSATION_SEARCH_SET: 'CONVERSATION_SEARCH_SET',
CONVERSATION_SEARCH_SET_UI_FLAG: 'CONVERSATION_SEARCH_SET_UI_FLAG',
MESSAGE_SEARCH_SET: 'MESSAGE_SEARCH_SET',
ARTICLE_SEARCH_SET: 'ARTICLE_SEARCH_SET',
CLEAR_SEARCH_RESULTS: 'CLEAR_SEARCH_RESULTS',
MESSAGE_SEARCH_SET_UI_FLAG: 'MESSAGE_SEARCH_SET_UI_FLAG',
ARTICLE_SEARCH_SET_UI_FLAG: 'ARTICLE_SEARCH_SET_UI_FLAG',
FULL_SEARCH_SET_UI_FLAG: 'FULL_SEARCH_SET_UI_FLAG',
SET_CONVERSATION_PARTICIPANTS_UI_FLAG:
'SET_CONVERSATION_PARTICIPANTS_UI_FLAG',
+10 -17
View File
@@ -2,6 +2,7 @@ import { createApp } from 'vue';
import VueDOMPurifyHTML from 'vue-dompurify-html';
import { domPurifyConfig } from '../shared/helpers/HTMLSanitizer';
import { directive as onClickaway } from 'vue3-click-away';
import { isSameHost } from '@chatwoot/utils';
import slugifyWithCounter from '@sindresorhus/slugify';
import PublicArticleSearch from './components/PublicArticleSearch.vue';
@@ -27,31 +28,23 @@ export const getHeadingsfromTheArticle = () => {
export const openExternalLinksInNewTab = () => {
const { customDomain, hostURL } = window.portalConfig;
const isSameHost =
window.location.href.includes(customDomain) ||
window.location.href.includes(hostURL);
// Modify external links only on articles page
const isOnArticlePage =
isSameHost && document.querySelector('#cw-article-content') !== null;
document.querySelector('#cw-article-content') !== null;
document.addEventListener('click', event => {
if (!isOnArticlePage) return;
// Some of the links come wrapped in strong tag through prosemirror
const link = event.target.closest('a');
const isTagAnchor = event.target.tagName === 'A';
const isParentTagAnchor =
event.target.tagName === 'STRONG' &&
event.target.parentNode.tagName === 'A';
if (isTagAnchor || isParentTagAnchor) {
const link = isTagAnchor ? event.target : event.target.parentNode;
if (link) {
const currentLocation = window.location.href;
const linkHref = link.href;
// Check against current location and custom domains
const isInternalLink =
link.hostname === window.location.hostname ||
link.href.includes(customDomain) ||
link.href.includes(hostURL);
isSameHost(linkHref, currentLocation) ||
(customDomain && isSameHost(linkHref, customDomain)) ||
(hostURL && isSameHost(linkHref, hostURL));
if (!isInternalLink) {
link.target = '_blank';
+140 -1
View File
@@ -1,6 +1,9 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { JSDOM } from 'jsdom';
import { InitializationHelpers } from '../portalHelpers';
import {
InitializationHelpers,
openExternalLinksInNewTab,
} from '../portalHelpers';
describe('InitializationHelpers.navigateToLocalePage', () => {
let dom;
@@ -44,3 +47,139 @@ describe('InitializationHelpers.navigateToLocalePage', () => {
);
});
});
describe('openExternalLinksInNewTab', () => {
let dom;
let document;
let window;
beforeEach(() => {
dom = new JSDOM(
`<!DOCTYPE html>
<html>
<body>
<div id="cw-article-content">
<a href="https://external.com" id="external">External</a>
<a href="https://app.chatwoot.com/page" id="internal">Internal</a>
<a href="https://custom.domain.com/page" id="custom">Custom</a>
<a href="https://example.com" id="nested"><code>Code</code><strong>Bold</strong></a>
<ul>
<li>Visit the preferences centre here &gt; <a href="https://external.com" id="list-link"><strong>https://external.com</strong></a></li>
</ul>
</div>
</body>
</html>`,
{ url: 'https://app.chatwoot.com/hc/article' }
);
document = dom.window.document;
window = dom.window;
window.portalConfig = {
customDomain: 'custom.domain.com',
hostURL: 'app.chatwoot.com',
};
global.document = document;
global.window = window;
});
afterEach(() => {
dom = null;
document = null;
window = null;
delete global.document;
delete global.window;
});
const simulateClick = selector => {
const element = document.querySelector(selector);
const event = new window.MouseEvent('click', { bubbles: true });
element.dispatchEvent(event);
return element.closest('a') || element;
};
it('opens external links in new tab', () => {
openExternalLinksInNewTab();
const link = simulateClick('#external');
expect(link.target).toBe('_blank');
expect(link.rel).toBe('noopener noreferrer');
});
it('preserves internal links', () => {
openExternalLinksInNewTab();
const internal = simulateClick('#internal');
const custom = simulateClick('#custom');
expect(internal.target).not.toBe('_blank');
expect(custom.target).not.toBe('_blank');
});
it('handles clicks on nested elements', () => {
openExternalLinksInNewTab();
simulateClick('#nested code');
simulateClick('#nested strong');
const link = document.getElementById('nested');
expect(link.target).toBe('_blank');
expect(link.rel).toBe('noopener noreferrer');
});
it('handles links inside list items with strong tags', () => {
openExternalLinksInNewTab();
// Click on the strong element inside the link in the list
simulateClick('#list-link strong');
const link = document.getElementById('list-link');
expect(link.target).toBe('_blank');
expect(link.rel).toBe('noopener noreferrer');
});
it('opens external links in a new tab even if customDomain is empty', () => {
window = dom.window;
window.portalConfig = {
hostURL: 'app.chatwoot.com',
};
global.window = window;
openExternalLinksInNewTab();
const link = simulateClick('#external');
const internal = simulateClick('#internal');
const custom = simulateClick('#custom');
expect(link.target).toBe('_blank');
expect(link.rel).toBe('noopener noreferrer');
expect(internal.target).not.toBe('_blank');
// this will be blank since the configs customDomain is empty
// which is a fair expectation
expect(custom.target).toBe('_blank');
});
it('opens external links in a new tab even if hostURL is empty', () => {
window = dom.window;
window.portalConfig = {
customDomain: 'custom.domain.com',
};
global.window = window;
openExternalLinksInNewTab();
const link = simulateClick('#external');
const internal = simulateClick('#internal');
const custom = simulateClick('#custom');
expect(link.target).toBe('_blank');
expect(link.rel).toBe('noopener noreferrer');
expect(internal.target).not.toBe('_blank');
expect(custom.target).not.toBe('_blank');
});
});
+27
View File
@@ -0,0 +1,27 @@
class Internal::DeleteAccountsJob < ApplicationJob
queue_as :scheduled_jobs
def perform
delete_expired_accounts
end
private
def delete_expired_accounts
accounts_pending_deletion.each do |account|
AccountDeletionService.new(account: account).perform
end
end
def accounts_pending_deletion
Account.where("custom_attributes->>'marked_for_deletion_at' IS NOT NULL")
.select { |account| deletion_period_expired?(account) }
end
def deletion_period_expired?(account)
deletion_time = account.custom_attributes['marked_for_deletion_at']
return false if deletion_time.blank?
DateTime.parse(deletion_time) <= Time.current
end
end
@@ -0,0 +1,52 @@
class AdministratorNotifications::AccountComplianceMailer < AdministratorNotifications::BaseMailer
def account_deleted(account)
return if instance_admin_email.blank?
subject = subject_for(account)
meta = build_meta(account)
send_notification(subject, to: instance_admin_email, meta: meta)
end
private
def build_meta(account)
deleted_users = params[:soft_deleted_users] || []
user_info_list = deleted_users.map do |user|
{
'user_id' => user[:id].to_s,
'user_email' => user[:original_email].to_s
}
end
{
'instance_url' => instance_url,
'account_id' => account.id,
'account_name' => account.name,
'deleted_at' => format_time(Time.current.iso8601),
'deletion_reason' => account.custom_attributes['marked_for_deletion_reason'] || 'not specified',
'marked_for_deletion_at' => format_time(account.custom_attributes['marked_for_deletion_at']),
'soft_deleted_users' => user_info_list,
'deleted_user_count' => user_info_list.size
}
end
def format_time(time_string)
return 'not specified' if time_string.blank?
Time.zone.parse(time_string).strftime('%B %d, %Y %H:%M:%S %Z')
end
def subject_for(account)
"Account Deletion Notice for #{account.id} - #{account.name}"
end
def instance_admin_email
GlobalConfig.get('CHATWOOT_INSTANCE_ADMIN_EMAIL')['CHATWOOT_INSTANCE_ADMIN_EMAIL']
end
def instance_url
ENV.fetch('FRONTEND_URL', 'not available')
end
end
@@ -5,6 +5,7 @@ module ActivityMessageHandler
include LabelActivityMessageHandler
include SlaActivityMessageHandler
include TeamActivityMessageHandler
include CsatActivityMessageHandler
private
@@ -0,0 +1,8 @@
module CsatActivityMessageHandler
extend ActiveSupport::Concern
def create_csat_not_sent_activity_message
content = I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window')
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
end
end
+3 -2
View File
@@ -39,7 +39,8 @@ class Integrations::App
def action
case params[:id]
when 'slack'
"#{params[:action]}&client_id=#{ENV.fetch('SLACK_CLIENT_ID', nil)}&redirect_uri=#{self.class.slack_integration_url}"
client_id = GlobalConfigService.load('SLACK_CLIENT_ID', nil)
"#{params[:action]}&client_id=#{client_id}&redirect_uri=#{self.class.slack_integration_url}"
when 'linear'
build_linear_action
else
@@ -50,7 +51,7 @@ class Integrations::App
def active?(account)
case params[:id]
when 'slack'
ENV['SLACK_CLIENT_SECRET'].present?
GlobalConfigService.load('SLACK_CLIENT_SECRET', nil).present?
when 'linear'
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
when 'shopify'
+50
View File
@@ -0,0 +1,50 @@
class AccountDeletionService
attr_reader :account, :soft_deleted_users
def initialize(account:)
@account = account
@soft_deleted_users = []
end
def perform
Rails.logger.info("Deleting account #{account.id} - #{account.name} that was marked for deletion")
soft_delete_orphaned_users
send_compliance_notification
DeleteObjectJob.perform_later(account)
end
private
def send_compliance_notification
AdministratorNotifications::AccountComplianceMailer.with(
account: account,
soft_deleted_users: soft_deleted_users
).account_deleted(account).deliver_later
end
def soft_delete_orphaned_users
account.users.each do |user|
# Find all account_users for this user excluding the current account
other_accounts = user.account_users.where.not(account_id: account.id).count
# If user has no other accounts, soft delete them
next unless other_accounts.zero?
# Soft delete user by appending -deleted.com to email
original_email = user.email
user.email = "#{original_email}-deleted.com"
user.skip_reconfirmation!
user.save!
user_info = {
id: user.id.to_s,
original_email: original_email
}
soft_deleted_users << user_info
Rails.logger.info("Soft deleted user #{user.id} with email #{original_email}")
end
end
end
@@ -6,17 +6,18 @@ class Crm::Leadsquared::Mappers::ConversationMapper
# so this limits it
ACTIVITY_NOTE_MAX_SIZE = 1800
def self.map_conversation_activity(conversation)
new(conversation).conversation_activity
def self.map_conversation_activity(hook, conversation)
new(hook, conversation).conversation_activity
end
def self.map_transcript_activity(conversation, messages = nil)
new(conversation, messages).transcript_activity
def self.map_transcript_activity(hook, conversation)
new(hook, conversation).transcript_activity
end
def initialize(conversation, messages = nil)
def initialize(hook, conversation)
@hook = hook
@timezone = Time.find_zone(hook.settings['timezone']) || Time.zone
@conversation = conversation
@messages = messages
end
def conversation_activity
@@ -41,14 +42,14 @@ class Crm::Leadsquared::Mappers::ConversationMapper
private
attr_reader :conversation, :messages
attr_reader :conversation
def formatted_creation_time
conversation.created_at.strftime('%Y-%m-%d %H:%M:%S')
conversation.created_at.in_time_zone(@timezone).strftime('%Y-%m-%d %H:%M:%S')
end
def transcript_messages
@transcript_messages ||= messages || conversation.messages.chat.select(&:conversation_transcriptable?)
@transcript_messages ||= conversation.messages.chat.select(&:conversation_transcriptable?)
end
def format_messages
@@ -77,8 +78,7 @@ class Crm::Leadsquared::Mappers::ConversationMapper
end
def message_time(message)
# TODO: Figure out what timezone to send the time in
message.created_at.strftime('%Y-%m-%d %H:%M')
message.created_at.in_time_zone(@timezone).strftime('%Y-%m-%d %H:%M')
end
def sender_name(message)
@@ -37,7 +37,7 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
activity_type: 'conversation',
activity_code_key: 'conversation_activity_code',
metadata_key: 'created_activity_id',
activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_conversation_activity(conversation)
activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_conversation_activity(@hook, conversation)
)
end
@@ -50,7 +50,7 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
activity_type: 'transcript',
activity_code_key: 'transcript_activity_code',
metadata_key: 'transcript_activity_id',
activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_transcript_activity(conversation)
activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_transcript_activity(@hook, conversation)
)
end
@@ -25,11 +25,12 @@ class Crm::Leadsquared::SetupService
response = @client.get('Authentication.svc/UserByAccessKey.Get')
endpoint_host = response['LSQCommonServiceURLs']['api']
app_host = response['LSQCommonServiceURLs']['app']
timezone = response['TimeZone']
endpoint_url = "https://#{endpoint_host}/v2/"
app_url = "https://#{app_host}/"
update_hook_settings({ :endpoint_url => endpoint_url, :app_url => app_url })
update_hook_settings({ :endpoint_url => endpoint_url, :app_url => app_url, :timezone => timezone })
# replace the clients
@client = Crm::Leadsquared::Api::BaseClient.new(@access_key, @secret_key, endpoint_url)
@@ -17,7 +17,7 @@ class MessageTemplates::HookExecutionService
::MessageTemplates::Template::OutOfOffice.new(conversation: conversation).perform if should_send_out_of_office_message?
::MessageTemplates::Template::Greeting.new(conversation: conversation).perform if should_send_greeting?
::MessageTemplates::Template::EmailCollect.new(conversation: conversation).perform if inbox.enable_email_collect && should_send_email_collect?
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform if should_send_csat_survey?
handle_csat_survey
end
def should_send_out_of_office_message?
@@ -65,13 +65,26 @@ class MessageTemplates::HookExecutionService
true
end
def should_send_csat_survey?
def handle_csat_survey
return unless csat_enabled_conversation?
# only send CSAT once in a conversation
return if conversation.messages.where(content_type: :input_csat).present?
return if csat_already_sent?
true
# Only send CSAT if agent can still reply by checking the messaging window restriction
# https://www.chatwoot.com/docs/self-hosted/supported-features#outgoing-message-restriction
if within_messaging_window?
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform
else
conversation.create_csat_not_sent_activity_message
end
end
def csat_already_sent?
conversation.messages.where(content_type: :input_csat).present?
end
def within_messaging_window?
conversation.can_reply?
end
end
MessageTemplates::HookExecutionService.prepend_mod_with('MessageTemplates::HookExecutionService')
+11 -1
View File
@@ -9,8 +9,10 @@ class SearchService
{ conversations: filter_conversations }
when 'Contact'
{ contacts: filter_contacts }
when 'Article'
{ articles: filter_articles }
else
{ contacts: filter_contacts, messages: filter_messages, conversations: filter_conversations }
{ contacts: filter_contacts, messages: filter_messages, conversations: filter_conversations, articles: filter_articles }
end
end
@@ -90,4 +92,12 @@ class SearchService
ILIKE :search OR identifier ILIKE :search", search: "%#{search_query}%"
).resolved_contacts.order_on_last_activity_at('desc').page(params[:page]).per(15)
end
def filter_articles
@articles = current_account.articles
.text_search(search_query)
.reorder('updated_at DESC')
.page(params[:page])
.per(15)
end
end
@@ -0,0 +1,8 @@
json.id article.id
json.title article.title
json.locale article.locale
json.content article.content
json.slug article.slug
json.portal_slug article.portal.slug
json.account_id article.account_id
json.category_name article.category&.name
@@ -0,0 +1,15 @@
json.id conversation.display_id
json.account_id conversation.account_id
json.created_at conversation.created_at.to_i
json.message do
json.partial! 'message', formats: [:json], message: conversation.messages.try(:first)
end
json.contact do
json.partial! 'contact', formats: [:json], contact: conversation.contact if conversation.try(:contact).present?
end
json.inbox do
json.partial! 'inbox', formats: [:json], inbox: conversation.inbox if conversation.try(:inbox).present?
end
json.agent do
json.partial! 'agent', formats: [:json], agent: conversation.assignee if conversation.try(:assignee).present?
end
@@ -0,0 +1,7 @@
json.payload do
json.articles do
json.array! @result[:articles] do |article|
json.partial! 'article', formats: [:json], article: article
end
end
end
@@ -1,21 +1,7 @@
json.payload do
json.conversations do
json.array! @result[:conversations] do |conversation|
json.id conversation.display_id
json.account_id conversation.account_id
json.created_at conversation.created_at.to_i
json.message do
json.partial! 'message', formats: [:json], message: conversation.messages.try(:first)
end
json.contact do
json.partial! 'contact', formats: [:json], contact: conversation.contact if conversation.try(:contact).present?
end
json.inbox do
json.partial! 'inbox', formats: [:json], inbox: conversation.inbox if conversation.try(:inbox).present?
end
json.agent do
json.partial! 'agent', formats: [:json], agent: conversation.assignee if conversation.try(:assignee).present?
end
json.partial! 'conversation_search_result', formats: [:json], conversation: conversation
end
end
json.contacts do
@@ -23,10 +9,14 @@ json.payload do
json.partial! 'contact', formats: [:json], contact: contact
end
end
json.messages do
json.array! @result[:messages] do |message|
json.partial! 'message', formats: [:json], message: message
end
end
json.articles do
json.array! @result[:articles] do |article|
json.partial! 'article', formats: [:json], article: article
end
end
end
@@ -0,0 +1,30 @@
<p>Hello,</p>
<p>This is a notification to inform you that an account has been permanently deleted from your Chatwoot instance.</p>
<p>
<strong>Chatwoot Installation:</strong> {{ meta.instance_url }}<br>
<strong>Account ID:</strong> {{ meta.account_id }}<br>
<strong>Account Name:</strong> {{ meta.account_name }}<br>
<strong>Deleted At:</strong> {{ meta.deleted_at }}<br>
<strong>Marked for Deletion at:</strong> {{ meta.marked_for_deletion_at }}<br>
<strong>Deletion Reason:</strong> {{ meta.deletion_reason }}
</p>
{% if meta.deleted_user_count > 0 %}
<p>
<strong>Deleted Users ({{ meta.deleted_user_count }}):</strong><br>
{% for user in meta.soft_deleted_users %}
User ID: {{ user.user_id }}, Email: {{ user.user_email }}{% unless forloop.last %}<br>{% endunless %}
{% endfor %}
</p>
{% else %}
<p>
<strong>Deleted Users:</strong> None
</p>
{% endif %}
<p>This email serves as a record for compliance purposes.</p>
<p>Thank you,<br>
Chatwoot System</p>
@@ -159,4 +159,7 @@
<symbol id="icon-shopify" viewBox="0 0 32 32">
<path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/>
</symbol>
</svg>
<symbol id="icon-slack" viewBox="0 0 24 24">
<path fill="currentColor" d="M6.527 14.514A1.973 1.973 0 0 1 4.56 16.48a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h1.967zm.992 0c0-1.083.885-1.968 1.968-1.968s1.967.885 1.967 1.968v4.927a1.973 1.973 0 0 1-1.967 1.968a1.973 1.973 0 0 1-1.968-1.968zm1.968-7.987A1.973 1.973 0 0 1 7.519 4.56c0-1.083.885-1.967 1.968-1.967s1.967.884 1.967 1.967v1.968zm0 .992c1.083 0 1.967.884 1.967 1.967a1.973 1.973 0 0 1-1.967 1.968H4.56a1.973 1.973 0 0 1-1.968-1.968c0-1.083.885-1.967 1.968-1.967zm7.986 1.967c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967a1.973 1.973 0 0 1-1.968 1.968h-1.968zm-.991 0a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968V4.56c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967zm-1.968 7.987c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968v-1.968zm0-.992a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h4.927c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.967z"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 41 KiB

+9
View File
@@ -62,6 +62,15 @@ Rails.application.configure do
# Disable host check during development
config.hosts = nil
# GitHub Codespaces configuration
if ENV['CODESPACES']
# Allow web console access from any IP
config.web_console.whitelisted_ips = %w(0.0.0.0/0 ::/0)
# Allow CSRF from codespace URLs
config.force_ssl = false
config.action_controller.forgery_protection_origin_check = false
end
# customize using the environment variables
config.log_level = ENV.fetch('LOG_LEVEL', 'debug').to_sym
+1 -1
View File
@@ -146,7 +146,7 @@
premium: true
- name: chatwoot_v4
display_name: Chatwoot V4
enabled: false
enabled: true
- name: report_v4
display_name: Report V4
enabled: true
+22
View File
@@ -235,6 +235,14 @@
description: Used to notify Chatwoot about account abuses, potential threads (Should be a Discord Webhook URL)
# ------- End of Chatwoot Internal Config for Self Hosted ----#
# ------- Compliance Related Config ----#
- name: CHATWOOT_INSTANCE_ADMIN_EMAIL
display_title: 'Instance Admin Email'
value:
description: 'The email of the instance administrator to receive compliance-related notifications'
locked: false
# ------- End of Compliance Related Config ----#
## ------ Configs added for enterprise clients ------ ##
- name: API_CHANNEL_NAME
value:
@@ -280,6 +288,20 @@
type: secret
## ------ End of Configs added for Linear ------ ##
## ------ Configs added for Slack ------ ##
- name: SLACK_CLIENT_ID
display_title: 'Slack Client ID'
value:
locked: false
description: 'Slack client ID'
- name: SLACK_CLIENT_SECRET
display_title: 'Slack Client Secret'
value:
locked: false
description: 'Slack client secret'
type: secret
## ------ End of Configs added for Slack ------ ##
# ------- Shopify Integration Config ------- #
- name: SHOPIFY_CLIENT_ID
display_title: 'Shopify Client ID'
+1
View File
@@ -205,6 +205,7 @@ leadsquared:
'secret_key': { 'type': 'string' },
'endpoint_url': { 'type': 'string' },
'app_url': { 'type': 'string' },
'timezone': { 'type': 'string' },
'enable_conversation_activity': { 'type': 'boolean' },
'enable_transcript_activity': { 'type': 'boolean' },
'conversation_activity_score': { 'type': 'string' },
+10
View File
@@ -185,6 +185,8 @@ en:
removed: '%{user_name} removed %{labels}'
sla:
added: '%{user_name} added SLA policy %{sla_name}'
csat:
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
removed: '%{user_name} removed SLA policy %{sla_name}'
muted: '%{user_name} has muted the conversation'
unmuted: '%{user_name} has unmuted the conversation'
@@ -213,33 +215,41 @@ en:
online:
delete: '%{contact_name} is Online, please try again later'
integration_apps:
# Note: webhooks and dashboard_apps don't need short_description as they use different modal components
dashboard_apps:
name: 'Dashboard Apps'
description: 'Dashboard Apps allow you to create and embed applications that display user information, orders, or payment history, providing more context to your customer support agents.'
dyte:
name: 'Dyte'
short_description: 'Start video/voice calls with customers directly from Chatwoot.'
description: 'Dyte is a product that integrates audio and video functionalities into your application. With this integration, your agents can start video/voice calls with your customers directly from Chatwoot.'
meeting_name: '%{agent_name} has started a meeting'
slack:
name: 'Slack'
short_description: 'Receive notifications and respond to conversations directly in Slack.'
description: "Integrate Chatwoot with Slack to keep your team in sync. This integration allows you to receive notifications for new conversations and respond to them directly within Slack's interface."
webhooks:
name: 'Webhooks'
description: 'Webhook events provide real-time updates about activities in your Chatwoot account. You can subscribe to your preferred events, and Chatwoot will send you HTTP callbacks with the updates.'
dialogflow:
name: 'Dialogflow'
short_description: 'Build chatbots to handle initial queries before transferring to agents.'
description: 'Build chatbots with Dialogflow and easily integrate them into your inbox. These bots can handle initial queries before transferring them to a customer service agent.'
google_translate:
name: 'Google Translate'
short_description: 'Automatically translate customer messages for agents.'
description: "Integrate Google Translate to help agents easily translate customer messages. This integration automatically detects the language and converts it to the agent's or admin's preferred language."
openai:
name: 'OpenAI'
short_description: 'AI-powered reply suggestions, summarization, and message enhancement.'
description: 'Leverage the power of large language models from OpenAI with the features such as reply suggestions, summarization, message rephrasing, spell-checking, and label classification.'
linear:
name: 'Linear'
short_description: 'Create and link Linear issues directly from conversations.'
description: 'Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process.'
shopify:
name: 'Shopify'
short_description: 'Access order details and customer data from your Shopify store.'
description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.'
leadsquared:
name: 'LeadSquared'
+1
View File
@@ -137,6 +137,7 @@ Rails.application.routes.draw do
get :conversations
get :messages
get :contacts
get :articles
end
end
+7
View File
@@ -39,3 +39,10 @@ process_stale_contacts_job:
cron: '30 04 * * *'
class: 'Internal::ProcessStaleContactsJob'
queue: housekeeping
# executed daily at 0100 UTC
# to delete accounts marked for deletion
delete_accounts_job:
cron: '0 1 * * *'
class: 'Internal::DeleteAccountsJob'
queue: scheduled_jobs
@@ -12,9 +12,10 @@ class Api::V1::Accounts::Captain::CopilotMessagesController < Api::V1::Accounts:
def create
@copilot_message = @copilot_thread.copilot_messages.create!(
message: params[:message],
message: { content: params[:message] },
message_type: :user
)
@copilot_message.enqueue_response_job(params[:conversation_id], Current.user.id)
end
private
@@ -18,7 +18,12 @@ class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::
assistant: assistant
)
@copilot_thread.copilot_messages.create!(message_type: :user, message: copilot_thread_params[:message])
copilot_message = @copilot_thread.copilot_messages.create!(
message_type: :user,
message: { content: copilot_thread_params[:message] }
)
copilot_message.enqueue_response_job(copilot_thread_params[:conversation_id], Current.user.id)
end
end
@@ -33,7 +38,7 @@ class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::
end
def copilot_thread_params
params.permit(:message, :assistant_id)
params.permit(:message, :assistant_id, :conversation_id)
end
def permitted_params
@@ -19,9 +19,9 @@ module Enterprise::Api::V1::Accounts::ConversationsController
response = Captain::Copilot::ChatService.new(
assistant,
previous_messages: copilot_params[:previous_messages],
conversation_history: @conversation.to_llm_text,
language: @conversation.account.locale_english_name
previous_history: copilot_params[:previous_history],
conversation_id: @conversation.display_id,
user_id: Current.user.id
).generate_response(copilot_params[:message])
render json: { message: response['response'] }
@@ -44,6 +44,6 @@ module Enterprise::Api::V1::Accounts::ConversationsController
private
def copilot_params
params.permit(:previous_messages, :message, :assistant_id)
params.permit(:previous_history, :message, :assistant_id)
end
end
@@ -33,6 +33,6 @@ module Enterprise::SuperAdmin::AppConfigsController
def internal_config_options
%w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY ANALYTICS_TOKEN CLEARBIT_API_KEY DASHBOARD_SCRIPTS INACTIVE_WHATSAPP_NUMBERS BLOCKED_EMAIL_DOMAINS
CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL]
CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL]
end
end
+23 -5
View File
@@ -1,6 +1,6 @@
module Captain::ChatHelper
def request_chat_completion
Rails.logger.debug { "[CAPTAIN][ChatCompletion] #{@messages}" }
log_chat_completion_request
response = @client.chat(
parameters: {
@@ -13,15 +13,22 @@ module Captain::ChatHelper
)
handle_response(response)
rescue StandardError => e
Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error in chat completion: #{e}"
raise e
end
private
def handle_response(response)
Rails.logger.debug { "[CAPTAIN][ChatCompletion] #{response}" }
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
message = response.dig('choices', 0, 'message')
if message['tool_calls']
process_tool_calls(message['tool_calls'])
else
JSON.parse(message['content'].strip)
message = JSON.parse(message['content'].strip)
persist_message(message, 'assistant')
message
end
end
@@ -41,12 +48,14 @@ module Captain::ChatHelper
if @tool_registry.respond_to?(function_name)
execute_tool(function_name, arguments, tool_call_id)
else
process_invalid_tool_call(tool_call_id)
process_invalid_tool_call(function_name, tool_call_id)
end
end
def execute_tool(function_name, arguments, tool_call_id)
persist_message({ content: "Using tool #{function_name}", function_name: function_name }, 'assistant_thinking')
result = @tool_registry.send(function_name, arguments)
persist_message({ content: "Completed #{function_name} tool call", function_name: function_name }, 'assistant_thinking')
append_tool_response(result, tool_call_id)
end
@@ -57,7 +66,8 @@ module Captain::ChatHelper
}
end
def process_invalid_tool_call(tool_call_id)
def process_invalid_tool_call(function_name, tool_call_id)
persist_message({ content: 'Invalid tool call', function_name: function_name }, 'assistant_thinking')
append_tool_response('Tool not available', tool_call_id)
end
@@ -68,4 +78,12 @@ module Captain::ChatHelper
content: content
}
end
def log_chat_completion_request
Rails.logger.info(
"#{self.class.name} Assistant: #{@assistant.id}, Requesting chat completion
for messages #{@messages} with #{@tool_registry&.registered_tools&.length || 0} tools
"
)
end
end
@@ -48,6 +48,12 @@ messenger:
enabled: true
icon: 'icon-messenger-line'
config_key: 'facebook'
instagram:
name: 'Instagram'
description: 'Stay connected with your customers on Instagram'
enabled: true
icon: 'icon-instagram'
config_key: 'instagram'
whatsapp:
name: 'WhatsApp'
description: 'Manage your WhatsApp business interactions from Chatwoot.'
@@ -81,19 +87,19 @@ microsoft:
config_key: 'microsoft'
linear:
name: 'Linear'
description: 'Configuration for setting up Linear'
description: 'Configuration for setting up Linear Integration'
enabled: true
icon: 'icon-linear'
config_key: 'linear'
instagram:
name: 'Instagram'
description: 'Configuration for setting up Instagram'
slack:
name: 'Slack'
description: 'Configuration for setting up Slack Integration'
enabled: true
icon: 'icon-instagram'
config_key: 'instagram'
icon: 'icon-slack'
config_key: 'slack'
shopify:
name: 'Shopify'
description: 'Configuration for setting up Shopify'
description: 'Configuration for setting up Shopify Integration'
enabled: true
icon: 'icon-shopify'
config_key: 'shopify'
@@ -0,0 +1,25 @@
class Captain::Copilot::ResponseJob < ApplicationJob
queue_as :default
def perform(assistant:, conversation_id:, user_id:, copilot_thread_id:, message:)
Rails.logger.info("#{self.class.name} Copilot response job for assistant_id=#{assistant.id} user_id=#{user_id}")
generate_chat_response(
assistant: assistant,
conversation_id: conversation_id,
user_id: user_id,
copilot_thread_id: copilot_thread_id,
message: message
)
end
private
def generate_chat_response(assistant:, conversation_id:, user_id:, copilot_thread_id:, message:)
Captain::Copilot::ChatService.new(
assistant,
user_id: user_id,
copilot_thread_id: copilot_thread_id,
conversation_id: conversation_id
).generate_response(message)
end
end
+23 -5
View File
@@ -19,13 +19,12 @@ class CopilotMessage < ApplicationRecord
belongs_to :copilot_thread
belongs_to :account
before_validation :ensure_account
enum message_type: { user: 0, assistant: 1, assistant_thinking: 2 }
validates :message_type, presence: true, inclusion: { in: message_types.keys }
validates :message_type, presence: true
validates :message, presence: true
before_validation :ensure_account
validate :validate_message_attributes
after_create_commit :broadcast_message
def push_event_data
@@ -38,13 +37,32 @@ class CopilotMessage < ApplicationRecord
}
end
def enqueue_response_job(conversation_id, user_id)
Captain::Copilot::ResponseJob.perform_later(
assistant: copilot_thread.assistant,
conversation_id: conversation_id,
user_id: user_id,
copilot_thread_id: copilot_thread.id,
message: message['content']
)
end
private
def ensure_account
self.account = copilot_thread.account
self.account_id = copilot_thread&.account_id
end
def broadcast_message
Rails.configuration.dispatcher.dispatch(COPILOT_MESSAGE_CREATED, Time.zone.now, copilot_message: self)
end
def validate_message_attributes
return if message.blank?
allowed_keys = %w[content reasoning function_name]
invalid_keys = message.keys - allowed_keys
errors.add(:message, "contains invalid attributes: #{invalid_keys.join(', ')}") if invalid_keys.any?
end
end
+1 -1
View File
@@ -40,7 +40,7 @@ class CopilotThread < ApplicationRecord
.order(created_at: :asc)
.map do |copilot_message|
{
content: copilot_message.message,
content: copilot_message.message['content'],
role: copilot_message.message_type
}
end
@@ -6,7 +6,6 @@ module Enterprise::Concerns::User
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
has_many :copilot_threads, dependent: :destroy_async
has_many :copilot_messages, dependent: :destroy_async
end
def ensure_installation_pricing_plan_quantity
@@ -3,49 +3,110 @@ require 'openai'
class Captain::Copilot::ChatService < Llm::BaseOpenAiService
include Captain::ChatHelper
attr_reader :assistant, :account, :user, :copilot_thread, :previous_history, :messages
def initialize(assistant, config)
super()
@assistant = assistant
@conversation_history = config[:conversation_history]
@previous_messages = config[:previous_messages] || []
@language = config[:language] || 'english'
@account = assistant.account
@user = nil
@copilot_thread = nil
@previous_history = []
setup_user(config)
setup_message_history(config)
register_tools
@messages = [system_message, conversation_history_context] + @previous_messages
@response = ''
@messages = build_messages(config)
end
def generate_response(input)
@messages << { role: 'user', content: input } if input.present?
response = request_chat_completion
Rails.logger.info("[CAPTAIN][CopilotChatService] Incrementing response usage for #{@assistant.account.id}")
@assistant.account.increment_response_usage
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
Rails.logger.info(
"#{self.class.name} Assistant: #{@assistant.id}, Incrementing response usage for account #{@account.id}"
)
@account.increment_response_usage
response
end
private
def setup_user(config)
@user = @account.users.find_by(id: config[:user_id]) if config[:user_id].present?
end
def build_messages(config)
messages= [system_message]
messages << account_id_context
messages += @previous_history if @previous_history.present?
messages += current_viewing_history(config[:conversation_id]) if config[:conversation_id].present?
messages
end
def setup_message_history(config)
Rails.logger.info(
"#{self.class.name} Assistant: #{@assistant.id}, Previous History: #{config[:previous_history]&.length || 0}, Language: #{config[:language]}"
)
@copilot_thread = @account.copilot_threads.find_by(id: config[:copilot_thread_id]) if config[:copilot_thread_id].present?
@previous_history = if @copilot_thread.present?
@copilot_thread.previous_history
else
config[:previous_history].presence || []
end
end
def register_tools
@tool_registry = Captain::ToolRegistryService.new(@assistant)
@tool_registry = Captain::ToolRegistryService.new(@assistant, user: @user)
@tool_registry.register_tool(Captain::Tools::SearchDocumentationService)
@tool_registry.register_tool(Captain::Tools::Copilot::GetArticleService)
@tool_registry.register_tool(Captain::Tools::Copilot::GetContactService)
@tool_registry.register_tool(Captain::Tools::Copilot::GetConversationService)
@tool_registry.register_tool(Captain::Tools::Copilot::SearchArticlesService)
@tool_registry.register_tool(Captain::Tools::Copilot::SearchContactsService)
@tool_registry.register_tool(Captain::Tools::Copilot::SearchConversationsService)
@tool_registry.register_tool(Captain::Tools::Copilot::SearchLinearIssuesService)
end
def system_message
{
role: 'system',
content: Captain::Llm::SystemPromptsService.copilot_response_generator(@assistant.config['product_name'], @language)
content: Captain::Llm::SystemPromptsService.copilot_response_generator(@assistant.config['product_name'])
}
end
def conversation_history_context
def account_id_context
{
role: 'system',
content: "
Message History with the user is below:
#{@conversation_history}
"
content: "The current account id is #{@account.id}. The account is using #{@account.locale_english_name} as the language."
}
end
def current_viewing_history(conversation_id)
conversation = @account.conversations.find_by(display_id: conversation_id)
return [] unless conversation
Rails.logger.info("#{self.class.name} Assistant: #{@assistant.id}, Setting viewing history for conversation_id=#{conversation_id}")
contact_id = conversation.contact_id
[{
role: 'system',
content: <<~HISTORY.strip
You are currently viewing the conversation with the following details:
Conversation ID: #{conversation_id}
Contact ID: #{contact_id}
HISTORY
}]
end
def persist_message(message, message_type = 'assistant')
return if @copilot_thread.blank?
@copilot_thread.copilot_messages.create!(
message: message,
message_type: message_type
)
end
end
@@ -21,7 +21,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
private
def register_tools
@tool_registry = Captain::ToolRegistryService.new(@assistant)
@tool_registry = Captain::ToolRegistryService.new(@assistant, user: nil)
@tool_registry.register_tool(Captain::Tools::SearchDocumentationService)
end
@@ -31,4 +31,8 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
content: Captain::Llm::SystemPromptsService.assistant_response_generator(@assistant.name, @assistant.config['product_name'], @assistant.config)
}
end
def persist_message(message, message_type = 'assistant')
# No need to implement
end
end
@@ -56,18 +56,18 @@ class Captain::Llm::SystemPromptsService
SYSTEM_PROMPT_MESSAGE
end
def copilot_response_generator(product_name, language)
def copilot_response_generator(product_name)
<<~SYSTEM_PROMPT_MESSAGE
[Identity]
You are Captain, a helpful and friendly copilot assistant for support agents using the product #{product_name}. Your primary role is to assist support agents by retrieving information, compiling accurate responses, and guiding them through customer interactions.
You should only provide information related to #{product_name} and must not address queries about other products or external events.
[Context]
You will be provided with the message history between the support agent and the customer. Use this context to understand the conversation flow, identify unresolved queries, and ensure responses are relevant and consistent with previous interactions. Always maintain a coherent and professional tone throughout the conversation.
Identify unresolved queries, and ensure responses are relevant and consistent with previous interactions. Always maintain a coherent and professional tone throughout the conversation.
[Response Guidelines]
- Use natural, polite, and conversational language that is clear and easy to follow. Keep sentences short and use simple words.
- Reply in the language the agent is using, if you're not able to detect the language, reply in #{language}.
- Reply in the language the agent is using, if you're not able to detect the language.
- Provide brief and relevant responsestypically one or two sentences unless a more detailed explanation is necessary.
- Do not use your own training data or assumptions to answer queries. Base responses strictly on the provided information.
- If the query is unclear, ask concise clarifying questions instead of making assumptions.
@@ -46,7 +46,7 @@ class Captain::Tools::Copilot::SearchLinearIssuesService < Captain::Tools::BaseS
end
def active?
@assistant.account.hooks.find_by(app_id: 'linear').present?
@user.present? && @assistant.account.hooks.exists?(app_id: 'linear')
end
private
+9 -1
View File
@@ -7,6 +7,7 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
MP4_REGEX = %r{https?://(?:www\.)?.+\.(mp4)}
ARCADE_REGEX = %r{https?://(?:www\.)?app\.arcade\.software/share/([^&/]+)}
WISTIA_REGEX = %r{https?://(?:www\.)?([^/]+)\.wistia\.com/medias/([^&/]+)}
BUNNY_REGEX = %r{https?://iframe\.mediadelivery\.net/play/(\d+)/([^&/?]+)}
def text(node)
content = node.string_content
@@ -52,7 +53,8 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
MP4_REGEX => :make_video_embed,
LOOM_REGEX => :make_loom_embed,
ARCADE_REGEX => :make_arcade_embed,
WISTIA_REGEX => :make_wistia_embed
WISTIA_REGEX => :make_wistia_embed,
BUNNY_REGEX => :make_bunny_embed
}
embedding_methods.each do |regex, method|
@@ -104,4 +106,10 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
video_id = arcade_match[1]
EmbedRenderer.arcade(video_id)
end
def make_bunny_embed(bunny_match)
library_id = bunny_match[1]
video_id = bunny_match[2]
EmbedRenderer.bunny(library_id, video_id)
end
end
+15
View File
@@ -84,4 +84,19 @@ module EmbedRenderer
</div>
)
end
def self.bunny(library_id, video_id)
%(
<div style="position: relative; padding-top: 56.25%;">
<iframe
src="https://iframe.mediadelivery.net/embed/#{library_id}/#{video_id}?autoplay=false&loop=false&muted=false&preload=true&responsive=true"
title="Bunny video player"
loading="lazy"
style="border: 0; position: absolute; top: 0; height: 100%; width: 100%;"
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
allowfullscreen>
</iframe>
</div>
)
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
class GlobalConfigService
def self.load(config_key, default_value)
config = ENV.fetch(config_key) { GlobalConfig.get(config_key)[config_key] }
config = GlobalConfig.get(config_key)[config_key]
return config if config.present?
# To support migrating existing instance relying on env variables

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